├── .github └── workflows │ └── static.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── LICENSE ├── README.md ├── demo ├── index.html ├── index.js └── styles.css ├── eslint.config.js ├── package-lock.json ├── package.json ├── playwright.config.js ├── src ├── index.d.ts └── index.js └── tests └── example.spec.js /.github/workflows/static.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['master'] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: 'pages' 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Install dependencies 35 | run: npm ci 36 | - name: Build 37 | run: npm run demo:build 38 | - name: Setup Pages 39 | uses: actions/configure-pages@v5 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | # Upload entire repository 44 | path: './dist/' 45 | - name: Deploy to GitHub Pages 46 | id: deployment 47 | uses: actions/deploy-pages@v4 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # node_modules 2 | node_modules 3 | 4 | # npmrc 5 | .npmrc 6 | 7 | # Playwright 8 | /test-results/ 9 | /playwright-report/ 10 | /blob-report/ 11 | /playwright/.cache/ 12 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo 2 | .husky 3 | .vscode 4 | .github 5 | eslint.config.js 6 | 7 | # Playwright 8 | /test-results/ 9 | /playwright-report/ 10 | /blob-report/ 11 | /playwright/.cache/ 12 | playwright.config.js 13 | 14 | # Tests 15 | tests -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Active Theory 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Split Text 2 | 3 | Features: 4 | 5 | - splitting text into lines, words, and chars 6 | - support for CJT languages (Traditional/Simplified Chinese, Japanese, Korean, Thai) 7 | - nested HTML elements (with all the types of splits) 8 | - text balancing 9 | - accessibility 10 | - emoji support 11 | 12 | [Demo](https://activetheory.github.io/split-text/) 13 | 14 | ## Installation 15 | 16 | ```bash 17 | npm install @activetheory/split-text 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```js 23 | import SplitText from '@activetheory/split-text'; 24 | 25 | const el = document.querySelector('.el'); 26 | 27 | const splitTextInstance = new SplitText(el); 28 | 29 | console.log(splitTextInstance.lines); 30 | console.log(splitTextInstance.words); 31 | console.log(splitTextInstance.chars); 32 | 33 | // Useful for animations purposes 34 | for (const line of splitTextInstance.lines) { 35 | console.log(line.__words); 36 | console.log(line.__wordCount); 37 | } 38 | 39 | for (const word of splitTextInstance.words) { 40 | console.log(word.__lineIndex); 41 | } 42 | ``` 43 | 44 | ## Options 45 | 46 | - `el`: The element to split. 47 | - `type`: The type of split to perform. Can be `lines`, `words`, or `chars`. Defaults to `lines`. 48 | - `minLines`: The minimum number of lines to split. Defaults to `1`. 49 | - `lineThreshold`: The threshold for splitting lines. Defaults to `0.2`. 50 | - `noAriaLabel`: Whether to not add .sr-only content. Defaults to `false`. 51 | - `noBalance`: Whether to not balance the text using @activetheory/balance-text. Defaults to `false`. 52 | - `balanceRatio`: The ratio of the width of the element to the width of the parent. Defaults to `1`. 53 | - `handleCJT`: Whether to handle CJT characters. Defaults to `false`. 54 | 55 | ## Properties 56 | 57 | - `isSplit`: Whether the text has been split. 58 | - `chars`: The characters of the text. 59 | - `words`: The words of the text. 60 | - `lines`: The lines of the text. 61 | - `originals`: The original elements of the text. 62 | 63 | ## Methods 64 | 65 | - `split()`: Split the text. 66 | - `revert()`: Revert the text to the original. 67 | 68 | ## Demo 69 | 70 | See the [demo](./demo) folder for examples. 71 | To run the demo, run `npm run dev`. 72 | 73 | ## Notes 74 | 75 | ### CJT locales 76 | 77 | The `handleCJT` option will leverage `​` to split the text properly. 78 | This library does not handle this automatically, you need to manually add `​` in your text. 79 | However, it's not mandatory, you can still use the library without it and most of the time it will work nicely. 80 | We suggest to have a look at https://github.com/google/budoux/ for more information about how to place `​` in your text. 81 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 10 |
11 |

Split Text

12 |
13 |
14 | 15 |
16 | Options 17 |
18 | 19 | 20 |
21 |
22 | 23 | 24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 | 47 | 48 |
49 |
50 | 51 | 52 |
53 |
54 |
55 | Styles 56 |
57 | 58 | 59 |
60 |
61 | 62 | 63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | 71 |
72 |
73 |
74 |
75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import './styles.css'; 2 | import SplitText from '../src/index.js'; 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | // Get DOM elements 6 | const inputText = document.getElementById('input-text'); 7 | const outputText = document.getElementById('output-text'); 8 | const originalText = document.getElementById('original-text'); 9 | const revertButton = document.getElementById('output-revert'); 10 | 11 | // Get option checkboxes 12 | const typeLines = document.getElementById('input-type-lines'); 13 | const typeWords = document.getElementById('input-type-words'); 14 | const typeChars = document.getElementById('input-type-chars'); 15 | const noAriaLabel = document.getElementById('input-no-aria-label'); 16 | const noBalance = document.getElementById('input-no-balance'); 17 | const handleCJT = document.getElementById('input-handle-cjt'); 18 | const cssBalance = document.getElementById('input-balance'); 19 | const balanceRatio = document.getElementById('input-balance-ratio'); 20 | const minLines = document.getElementById('input-min-lines'); 21 | const lineThreshold = document.getElementById('input-line-threshold'); 22 | 23 | // Get font size input 24 | const fontSize = document.getElementById('input-font-size'); 25 | 26 | let splitInstance = null; 27 | 28 | // Helper to get current options 29 | const getOptions = () => { 30 | const types = []; 31 | if (typeLines.checked) types.push('lines'); 32 | if (typeWords.checked) types.push('words'); 33 | if (typeChars.checked) types.push('chars'); 34 | 35 | if (cssBalance.checked) { 36 | noBalance.setAttribute('disabled', 'disabled'); 37 | balanceRatio.setAttribute('disabled', 'disabled'); 38 | } else { 39 | noBalance.removeAttribute('disabled'); 40 | balanceRatio.removeAttribute('disabled'); 41 | } 42 | 43 | return { 44 | type: types.join(','), 45 | noAriaLabel: noAriaLabel.checked, 46 | noBalance: noBalance.checked, 47 | handleCJT: handleCJT.checked, 48 | balanceRatio: Number(balanceRatio.value), 49 | minLines: Number(minLines.value), 50 | lineThreshold: Number(lineThreshold.value), 51 | }; 52 | }; 53 | 54 | // Update split text 55 | const updateSplit = () => { 56 | outputText.style.removeProperty('max-width'); 57 | 58 | if (cssBalance.checked) { 59 | outputText.style.setProperty('text-wrap', 'balance'); 60 | } else { 61 | outputText.style.removeProperty('text-wrap'); 62 | } 63 | 64 | const text = inputText.value.trim(); 65 | if (!text) return; 66 | 67 | // Store original text 68 | originalText.innerHTML = text; 69 | 70 | // Update output 71 | outputText.innerHTML = text; 72 | 73 | // Create new split instance 74 | splitInstance = new SplitText(outputText, getOptions()); 75 | }; 76 | 77 | const updateFontSize = () => { 78 | outputText.style.fontSize = `${fontSize.value}px`; 79 | updateSplit(); 80 | }; 81 | 82 | // Event listeners 83 | inputText.addEventListener('input', updateSplit); 84 | fontSize.addEventListener('input', updateFontSize); 85 | [typeLines, typeWords, typeChars, noAriaLabel, noBalance, handleCJT, cssBalance, balanceRatio, minLines, lineThreshold].forEach((checkbox) => { 86 | checkbox.addEventListener('change', updateSplit); 87 | }); 88 | 89 | // Revert button 90 | revertButton.addEventListener('click', () => { 91 | outputText.style.removeProperty('max-width'); 92 | 93 | if (splitInstance) { 94 | splitInstance.revert(); 95 | splitInstance = null; 96 | } 97 | }); 98 | 99 | window.addEventListener('resize', () => { 100 | updateSplit(); 101 | }); 102 | 103 | // Initial text 104 | inputText.value = /* html */ ` 105 |

split anything 🐳 🍔 🍕 into words, chars, lines

106 |

Try typing some text to see it split into lines, words, and characters!

107 |

Link here

108 | 113 | `.trim(); 114 | 115 | updateSplit(); 116 | }); 117 | -------------------------------------------------------------------------------- /demo/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 3 | system-ui, 4 | -apple-system, 5 | BlinkMacSystemFont, 6 | 'Segoe UI', 7 | Roboto, 8 | Oxygen, 9 | Ubuntu, 10 | Cantarell, 11 | 'Open Sans', 12 | 'Helvetica Neue', 13 | sans-serif; 14 | font-kerning: none; 15 | -webkit-text-rendering: optimizeSpeed; 16 | text-rendering: optimizeSpeed; 17 | } 18 | 19 | main { 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | flex-direction: column; 24 | padding: 2rem; 25 | max-width: 1200px; 26 | margin: 0 auto; 27 | } 28 | 29 | h1 { 30 | margin-bottom: 2rem; 31 | align-self: start; 32 | } 33 | 34 | main > div { 35 | display: flex; 36 | gap: 2rem; 37 | width: 100%; 38 | } 39 | 40 | .col { 41 | flex: 1; 42 | display: flex; 43 | flex-direction: column; 44 | gap: 1rem; 45 | } 46 | 47 | textarea { 48 | display: block; 49 | width: auto; 50 | max-width: 100%; 51 | padding: 1rem; 52 | font-size: 1rem; 53 | border: 1px solid #ccc; 54 | border-radius: 4px; 55 | resize: vertical; 56 | min-height: 200px; 57 | } 58 | 59 | fieldset { 60 | border: 1px solid #ccc; 61 | border-radius: 4px; 62 | padding: 1rem; 63 | } 64 | 65 | fieldset > div { 66 | margin: 0.5rem 0; 67 | display: flex; 68 | gap: 0.5rem; 69 | } 70 | 71 | .output-container { 72 | min-height: 200px; 73 | padding: 1rem; 74 | border: 1px solid #ccc; 75 | border-radius: 4px; 76 | margin-bottom: 1rem; 77 | } 78 | 79 | button { 80 | padding: 0.5rem 1rem; 81 | font-size: 1rem; 82 | background: #007bff; 83 | color: white; 84 | border: none; 85 | border-radius: 4px; 86 | cursor: pointer; 87 | } 88 | 89 | button:hover { 90 | background: #0056b3; 91 | } 92 | 93 | #original-text { 94 | margin-top: 1rem; 95 | padding: 1rem; 96 | border: 1px solid #ccc; 97 | border-radius: 4px; 98 | color: #666; 99 | } 100 | 101 | /* Split text styles */ 102 | .line { 103 | background-color: #f0f0f0; 104 | } 105 | 106 | .word { 107 | position: relative; 108 | display: inline-block; 109 | } 110 | 111 | .char { 112 | position: relative; 113 | display: inline-block; 114 | } 115 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginJs from '@eslint/js'; 3 | 4 | /** @type {import('eslint').Linter.Config[]} */ 5 | export default [{ languageOptions: { globals: globals.browser } }, pluginJs.configs.recommended]; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@activetheory/split-text", 3 | "version": "1.1.2", 4 | "description": "Split text within HTML elements into individual lines, words, and/or characters", 5 | "main": "./src/index.js", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "exports": { 10 | ".": { 11 | "types": "./src/index.d.ts", 12 | "default": "./src/index.js" 13 | } 14 | }, 15 | "types": "./src/index.d.ts", 16 | "scripts": { 17 | "dev": "vite demo", 18 | "demo:build": "vite build --outDir ../dist --emptyOutDir --base ./ ./demo/", 19 | "format": "prettier --write --ignore-unknown", 20 | "eslint": "eslint --fix src", 21 | "release": "release-it", 22 | "prepare": "husky", 23 | "playwright:test": "playwright test", 24 | "test": "concurrently -c red,blue -n \"dev,test\" --pad-prefix \"npm run dev\" \"npm run playwright:test\"" 25 | }, 26 | "sideEffects": false, 27 | "type": "module", 28 | "author": { 29 | "name": "Active Theory", 30 | "email": "hello@activetheory.net", 31 | "url": "https://activetheory.net" 32 | }, 33 | "license": "MIT", 34 | "repository": { 35 | "type": "git", 36 | "url": "git+https://github.com/activetheory/split-text.git" 37 | }, 38 | "prettier": { 39 | "arrowParens": "always", 40 | "bracketSpacing": true, 41 | "endOfLine": "lf", 42 | "htmlWhitespaceSensitivity": "css", 43 | "printWidth": 200, 44 | "quoteProps": "as-needed", 45 | "semi": true, 46 | "singleQuote": true, 47 | "tabWidth": 2, 48 | "trailingComma": "es5", 49 | "useTabs": false 50 | }, 51 | "devDependencies": { 52 | "@axe-core/playwright": "^4.10.1", 53 | "@eslint/js": "^9.18.0", 54 | "@playwright/test": "^1.50.0", 55 | "@types/node": "^22.12.0", 56 | "concurrently": "^9.1.2", 57 | "eslint": "^9.18.0", 58 | "globals": "^15.14.0", 59 | "husky": "^9.1.7", 60 | "lint-staged": "^15.4.1", 61 | "prettier": "^3.4.2", 62 | "release-it": "^18.1.1", 63 | "vite": "^6.0.9" 64 | }, 65 | "release-it": { 66 | "github": { 67 | "release": true 68 | }, 69 | "npm": { 70 | "publish": true 71 | } 72 | }, 73 | "lint-staged": { 74 | "**/*": [ 75 | "npm run eslint", 76 | "npm run format" 77 | ] 78 | }, 79 | "dependencies": { 80 | "@activetheory/balance-text": "^1.0.2" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // import dotenv from 'dotenv'; 8 | // import path from 'path'; 9 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 10 | 11 | /** 12 | * @see https://playwright.dev/docs/test-configuration 13 | */ 14 | export default defineConfig({ 15 | testDir: './tests', 16 | /* Run tests in files in parallel */ 17 | fullyParallel: true, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: false, 20 | /* Retry on CI only */ 21 | retries: 0, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: 1, 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: 'html', 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | /* Base URL to use in actions like `await page.goto('/')`. */ 29 | // baseURL: 'http://127.0.0.1:3000', 30 | 31 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 32 | trace: 'on-first-retry', 33 | }, 34 | 35 | /* Configure projects for major browsers */ 36 | projects: [ 37 | { 38 | name: 'chromium', 39 | use: { ...devices['Desktop Chrome'] }, 40 | }, 41 | 42 | { 43 | name: 'firefox', 44 | use: { ...devices['Desktop Firefox'] }, 45 | }, 46 | 47 | { 48 | name: 'webkit', 49 | use: { ...devices['Desktop Safari'] }, 50 | }, 51 | 52 | /* Test against mobile viewports. */ 53 | // { 54 | // name: 'Mobile Chrome', 55 | // use: { ...devices['Pixel 5'] }, 56 | // }, 57 | // { 58 | // name: 'Mobile Safari', 59 | // use: { ...devices['iPhone 12'] }, 60 | // }, 61 | 62 | /* Test against branded browsers. */ 63 | // { 64 | // name: 'Microsoft Edge', 65 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 66 | // }, 67 | // { 68 | // name: 'Google Chrome', 69 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 70 | // }, 71 | ], 72 | 73 | /* Run your local dev server before starting the tests */ 74 | // webServer: { 75 | // command: 'npm run start', 76 | // url: 'http://127.0.0.1:3000', 77 | // reuseExistingServer: !process.env.CI, 78 | // }, 79 | }); 80 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | type SplitType = 'lines' | 'words' | 'chars' | 'lines,words' | 'lines,chars' | 'words,chars' | 'lines,words,chars'; 2 | 3 | export type SplitTextOptions = { 4 | type?: SplitType; 5 | minLines?: number; 6 | lineThreshold?: number; 7 | noAriaLabel?: boolean; 8 | noBalance?: boolean; 9 | balanceRatio?: number; 10 | handleCJT?: boolean; 11 | }; 12 | 13 | export default class SplitText { 14 | constructor(element: HTMLElement, options?: SplitTextOptions); 15 | split(): void; 16 | revert(): void; 17 | isSplit: boolean; 18 | chars: string[]; 19 | words: string[]; 20 | lines: string[]; 21 | originals: HTMLElement[]; 22 | } 23 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import relayout from '@activetheory/balance-text'; 2 | 3 | function toArray(e, parent) { 4 | return !e || e.length === 0 ? [] : e.nodeName ? [e] : [].slice.call(e[0].nodeName ? e : (parent || document).querySelectorAll(e)); 5 | } 6 | 7 | const NBSP = String.fromCharCode(160); 8 | const NNBSP = String.fromCharCode(8239); 9 | const SPACES = [' ', NBSP, NNBSP]; 10 | const BLOCK_TAGS = ['DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'UL', 'OL', 'LI']; 11 | 12 | export const isFontReady = () => { 13 | const promise = new Promise((resolve) => { 14 | document.fonts.ready.then(resolve); 15 | }); 16 | return promise; 17 | }; 18 | 19 | export default class SplitText { 20 | isSplit = false; 21 | chars = []; 22 | words = []; 23 | lines = []; 24 | originals = []; 25 | lineParents = []; 26 | elements = []; 27 | options = {}; 28 | 29 | constructor( 30 | element, 31 | options = { 32 | lineThreshold: 0.2, 33 | type: 'lines', 34 | noAriaLabel: false, 35 | noBalance: false, 36 | balanceRatio: 1, 37 | minLines: 1, 38 | handleCJT: false, 39 | } 40 | ) { 41 | this.elements = toArray(element); 42 | this.options = options; 43 | 44 | this.options.lineThreshold = typeof this.options.lineThreshold === 'number' ? this.options.lineThreshold : 0.2; 45 | this.options.noAriaLabel = typeof this.options.noAriaLabel === 'boolean' ? this.options.noAriaLabel : false; 46 | this.options.noBalance = typeof this.options.noBalance === 'boolean' ? this.options.noBalance : false; 47 | this.options.minLines = typeof this.options.minLines === 'number' ? this.options.minLines : 1; 48 | this.options.handleCJT = typeof this.options.handleCJT === 'boolean' ? this.options.handleCJT : false; 49 | this.options.type = typeof this.options.type === 'string' ? this.options.type : 'lines'; 50 | this.split(); 51 | } 52 | 53 | split() { 54 | if (this.isSplit) this.revert(); 55 | 56 | const by = (this.options.type || 'lines').split(',').map((s) => s.trim()); 57 | const byLines = ~by.indexOf('lines'); 58 | const byWords = ~by.indexOf('words'); 59 | const byChars = ~by.indexOf('chars'); 60 | 61 | this.elements.forEach((element, i) => { 62 | element.__isParent = true; 63 | 64 | this.originals[i] = element.innerHTML.trim(); 65 | 66 | // replace all zero width space with 67 | element.innerHTML = element.innerHTML 68 | .trim() 69 | .split(/\u200b/) 70 | .join(''); 71 | 72 | this.balance(element); 73 | 74 | if (byWords || byLines || byChars) { 75 | this.words.push(...this.splitElement(element, 'word', /\s+/, true)); 76 | 77 | if (this.words.length === 1 && this.words[0].offsetWidth < element.parentElement.offsetWidth) { 78 | // if we have a single word and it's smaller than the parent element, remove the max-width from the line parents 79 | this.lineParents.forEach((l) => l.style.removeProperty('max-width')); 80 | } 81 | 82 | if (byLines) { 83 | this.detectLinesTop(element, this.words, this.options.lineThreshold); 84 | const proceed = this.checkMinLines(element, this.words); 85 | if (!proceed) return; 86 | element.style.removeProperty('width'); 87 | this.attachBr(element, this.words); 88 | this.splitBr(element); 89 | this.replaceWords(element, (byLines || byWords) && !byChars); 90 | this.lines.push(...this.splitLines(element)); 91 | const earlyReturn = this.checkBalance(element, i); 92 | if (!earlyReturn) return; 93 | this.safeCheckBalance = 0; 94 | } 95 | 96 | if (byLines && !byWords && !byChars) { 97 | this.lines.forEach((l) => { 98 | l.__words.forEach((w) => { 99 | w.insertAdjacentHTML('beforebegin', w.textContent); 100 | w.remove(); 101 | }); 102 | l.normalize(); 103 | }); 104 | this.words.length = 0; 105 | this.chars.length = 0; 106 | } 107 | 108 | if (byChars) { 109 | this.words.forEach((e) => this.chars.push(...this.splitElement(e, 'char', '', false))); 110 | if (!byWords) { 111 | this.chars.forEach((e) => { 112 | e.parentElement.insertAdjacentHTML('beforebegin', e.outerHTML); 113 | e.remove(); 114 | }); 115 | this.chars = toArray(element.getElementsByClassName('char')); 116 | this.words.forEach((e) => e.remove()); 117 | this.words.length = 0; 118 | } 119 | } 120 | } 121 | 122 | if (!this.options.noAriaLabel && (byChars || byWords)) { 123 | this.recursiveAriaLabel(element); 124 | 125 | // Handle A tags 126 | const focusableTags = toArray(element.querySelectorAll('a, button')); 127 | focusableTags.forEach(this.createAriaLabel); 128 | } 129 | }); 130 | 131 | this.isSplit = true; 132 | } 133 | 134 | recursiveAriaLabel(element) { 135 | const blockTags = toArray(element.childNodes).filter((child) => BLOCK_TAGS.includes(child.tagName)); 136 | if (blockTags.length) { 137 | blockTags.forEach((child) => { 138 | this.recursiveAriaLabel(child); 139 | }); 140 | } else { 141 | this.createAriaLabel(element); 142 | } 143 | } 144 | 145 | createAriaLabel(element) { 146 | const span = document.createElement('span'); 147 | span.classList.add('sr-only'); 148 | span.style.setProperty('position', 'absolute'); 149 | span.style.setProperty('width', '1px'); 150 | span.style.setProperty('height', '1px'); 151 | span.style.setProperty('padding', '0'); 152 | span.style.setProperty('margin', '-1px'); 153 | span.style.setProperty('overflow', 'hidden'); 154 | span.style.setProperty('clip', 'rect(0, 0, 0, 0)'); 155 | span.style.setProperty('white-space', 'nowrap'); 156 | span.style.setProperty('border', '0'); 157 | span.textContent = element.textContent; 158 | element.appendChild(span); 159 | } 160 | 161 | /** 162 | * There are some occasions where the lines after being balanced, exceeds the element bounding box 163 | * this check prevents that, and takes into accout also very long kebab cased string. 164 | */ 165 | checkBalance(_, index) { 166 | if (this.options.noBalance) return true; 167 | const removeParentMaxWidth = this.lines.filter((l) => l.scrollWidth > l.parentElement.offsetWidth); 168 | for (let i = 0; i < removeParentMaxWidth.length; i++) { 169 | const l = removeParentMaxWidth[i]; 170 | const text = l.__words[0]?.textContent; 171 | // Special check for kebab string -> a4e5ee87-8e56-4b73-ad56-4e2d9b7aba16 172 | if (l.__wordCount === 1 && text.match(/\b\w+-\w+\b/) && this.safeCheckBalance <= 5) { 173 | // split the text into parts and add zero width spaces 174 | const newWordText = text.split('-').join('-​'); 175 | this.originals[index] = this.originals[index].replace(text, newWordText); 176 | this.safeCheckBalance++; 177 | this.revert(); 178 | this.split(); 179 | return false; 180 | } 181 | l.parentElement.style.removeProperty('max-width'); 182 | } 183 | return true; 184 | } 185 | 186 | revert() { 187 | if (this.originals.length === 0) return; 188 | this.elements.forEach((el, i) => (el.innerHTML = this.originals[i])); 189 | [this.lines, this.words, this.chars, this.originals].forEach((arr) => (arr.length = 0)); 190 | this.isSplit = false; 191 | } 192 | 193 | recursiveBalance(e) { 194 | e.normalize(); 195 | toArray(e.childNodes).forEach((next) => { 196 | next.normalize(); 197 | next.__lineParent = Boolean(next.tagName && next.hasChildNodes() && BLOCK_TAGS.includes(next.tagName)); 198 | if (next.__lineParent && e?.__lineParent && !e.__isParent) { 199 | e.__lineParent = false; 200 | } 201 | this.recursiveBalance(next); 202 | }); 203 | } 204 | 205 | recursiveCheckLineParent(e, lineParents) { 206 | toArray(e.childNodes).forEach((next) => { 207 | if (next.__lineParent) { 208 | next.__idx = null; 209 | // check if the __lineParent element has a valid text node to split 210 | if (next.textContent.replace(/\s+/g, ' ').trim().length > 0) { 211 | next.__lines = [this.createLine()]; 212 | lineParents.push(next); 213 | } 214 | } 215 | this.recursiveCheckLineParent(next, lineParents); 216 | }); 217 | } 218 | 219 | balance(el) { 220 | // save all line parents 221 | this.lineParents = []; 222 | 223 | this.recursiveBalance(el); 224 | 225 | this.recursiveCheckLineParent(el, this.lineParents); 226 | 227 | let useParent = true; 228 | if (!this.lineParents.length) { 229 | this.lineParents.push(el); 230 | el.__lines = [this.createLine()]; 231 | el.__lineParent = true; 232 | el.__idx = null; 233 | useParent = false; 234 | } 235 | 236 | el.__lineParent = true; 237 | 238 | if (!this.options.noBalance) { 239 | this.lineParents.forEach((e) => 240 | relayout({ 241 | el: e, 242 | ratio: this.options.balanceRatio, 243 | useParent, 244 | }) 245 | ); 246 | } 247 | } 248 | 249 | recursiveFindBr(e, brs, onlyNew = true) { 250 | e.normalize(); 251 | toArray(e.childNodes).forEach((next) => { 252 | if (next.tagName === 'BR' && (onlyNew ? !next.__newBR : true)) brs.push(next); 253 | else this.recursiveFindBr(next, brs, onlyNew); 254 | }); 255 | } 256 | 257 | findAllBr(el, onlyNew = true) { 258 | const brs = []; 259 | 260 | this.recursiveFindBr(el, brs, onlyNew); 261 | 262 | return brs; 263 | } 264 | 265 | splitBr(el) { 266 | let j = 0; 267 | const brs = this.findAllBr(el); 268 | while (j < brs.length) { 269 | let i = 0; 270 | let parent = brs[j++].parentElement; 271 | 272 | if (!parent) return this.splitBr(el); 273 | 274 | while (!parent.__lineParent) { 275 | if (i++ >= 100) return; 276 | if (!parent.parentElement) return this.splitBr(el); 277 | const cloneInnerHTML = parent.innerHTML; 278 | const parentClone = parent.cloneNode(); 279 | const parentTagName = parentClone.tagName.toLowerCase(); 280 | const cloneOuterHTML = parentClone.outerHTML.split(``).join(''); 281 | const newInnerHTML = cloneInnerHTML.split(/]*>/).join(`
${cloneOuterHTML.trim()}`); 282 | parent = parent.parentElement; 283 | parent.innerHTML = parent.innerHTML.replace(cloneInnerHTML.trim(), newInnerHTML.trim()); 284 | toArray(parent.childNodes).forEach((child) => { 285 | if (child.tagName === 'BR') child.__newBR = true; 286 | else if (child.nodeType !== 3 && child.textContent.trim().length === 0) child.remove(); 287 | }); 288 | } 289 | } 290 | } 291 | 292 | isNextBr(el) { 293 | return el.nextElementSibling?.tagName === 'BR'; 294 | } 295 | 296 | isPrevBr(el) { 297 | return el.previousElementSibling?.tagName === 'BR'; 298 | } 299 | 300 | attachBr(_, els) { 301 | let prevLineParent; 302 | let prevTop = els[0]?.__top || 0; 303 | els.forEach((w, i) => { 304 | const prevEl = els[i - 1]; 305 | 306 | if (prevTop !== w.__top && prevEl) { 307 | const lineParent = this.findLineParent(w); 308 | if (!lineParent.__idx) lineParent.__idx = `l${w.__top}`; 309 | 310 | if (!this.isPrevBr(w.parentElement) && !this.isPrevBr(w) && !this.isNextBr(prevEl) && (!prevLineParent || prevLineParent?.__idx === lineParent.__idx)) { 311 | w.insertAdjacentHTML('beforebegin', '
'); 312 | } 313 | prevLineParent = lineParent; 314 | prevTop = w.__top; 315 | } 316 | }); 317 | } 318 | 319 | findLineParent(el) { 320 | let parent = el.parentElement; 321 | let found = false; 322 | while (!found) { 323 | if (parent.__lineParent) found = parent; 324 | parent = parent.parentElement; 325 | } 326 | return found; 327 | } 328 | 329 | replaceWords(element, notByChars) { 330 | Array.from(element.getElementsByClassName('word')).forEach((el, i) => { 331 | el.replaceWith(this.words[i]); 332 | if (el.__isCJT && notByChars) { 333 | this.words[i].innerHTML = this.words[i].textContent; 334 | } 335 | }); 336 | } 337 | 338 | // CJT locales + Thai 339 | isCJTChar(char) { 340 | return /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF\u0E00-\u0E7F]/.test(char); 341 | } 342 | 343 | handleRawElement(parentEl, el, key, splitOn, preserveWhitespace, elements, allElements) { 344 | // Get the text to split 345 | const wholeText = el.wholeText || ''; 346 | let contents = wholeText; 347 | 348 | // If there's no text after removing all whitespace, preserve the original whitespace 349 | if (!contents.trim().length) { 350 | allElements.push(document.createTextNode(wholeText)); 351 | return; 352 | } 353 | 354 | // If we're splitting into words/chars, trim the content but preserve spaces 355 | if (key === 'word' || key === 'char') { 356 | contents = wholeText.trim(); 357 | // Preserve leading whitespace 358 | if (SPACES.includes(wholeText[0])) { 359 | allElements.push(document.createTextNode(wholeText[0])); 360 | } 361 | 362 | if (this.options.handleCJT && key === 'word') { 363 | // Split text into words first 364 | const words = contents.split(/(\s+)/).filter(Boolean); 365 | 366 | for (let i = 0; i < words.length; i++) { 367 | const word = words[i]; 368 | 369 | // If it's a whitespace, preserve it 370 | if (/^\s+$/.test(word)) { 371 | allElements.push(document.createTextNode(word)); 372 | continue; 373 | } 374 | 375 | // Process each character in the word 376 | let currentWord = ''; 377 | let isCJTMode = false; 378 | 379 | for (let j = 0; j < word.length; j++) { 380 | const char = word[j]; 381 | const isCurrentCharCJT = this.isCJTChar(char); 382 | 383 | // Mode switch or end of word 384 | if (isCurrentCharCJT !== isCJTMode || j === word.length - 1) { 385 | // Add the current character to the word 386 | if (j === word.length - 1) { 387 | currentWord += char; 388 | } 389 | 390 | // Create element if we have accumulated text 391 | if (currentWord) { 392 | const splitEl = this.createElement(parentEl, key, currentWord); 393 | elements.push(splitEl); 394 | allElements.push(splitEl); 395 | 396 | // If it's CJT, split into individual characters 397 | if (isCJTMode) { 398 | splitEl.__isCJT = true; 399 | this.chars.push(...this.splitElement(splitEl, 'char', '', false)); 400 | } 401 | } 402 | 403 | // Reset for next segment 404 | currentWord = j === word.length - 1 ? '' : char; 405 | isCJTMode = isCurrentCharCJT; 406 | } else { 407 | currentWord += char; 408 | } 409 | } 410 | } 411 | } else { 412 | // Regular handling for non-CJT text or when handleCJT is false 413 | if (key === 'char') { 414 | // Use Array.from to properly split Unicode characters including emojis 415 | const chars = Array.from(contents); 416 | chars.forEach((char) => { 417 | const splitEl = this.createElement(parentEl, key, char); 418 | elements.push(splitEl); 419 | allElements.push(splitEl); 420 | }); 421 | } else { 422 | // Split content preserving all whitespace 423 | const parts = contents.split(/([\s\u00A0\u202F]+)/); 424 | parts.forEach((part, i) => { 425 | if (i % 2 === 1) { 426 | // Odd indices are whitespace - preserve them exactly 427 | allElements.push(document.createTextNode(part)); 428 | } else if (part) { 429 | // Even indices are words - process them 430 | const splitEl = this.createElement(parentEl, key, part); 431 | elements.push(splitEl); 432 | allElements.push(splitEl); 433 | } 434 | }); 435 | } 436 | } 437 | 438 | // insert trailing space if there was one and preserve   439 | if (SPACES.includes(wholeText[wholeText.length - 1])) { 440 | allElements.push(document.createTextNode(wholeText[wholeText.length - 1])); 441 | } 442 | } 443 | } 444 | 445 | splitElement(el, key, splitOn, preserveWhitespace) { 446 | // Combine any strange text nodes or empty whitespace. 447 | el.normalize(); 448 | 449 | // Use fragment to prevent unnecessary DOM thrashing. 450 | const elements = []; 451 | const F = document.createDocumentFragment(); 452 | 453 | const allElements = []; 454 | 455 | toArray(el.childNodes).forEach((next) => { 456 | if (next.tagName && !next.hasChildNodes()) { 457 | // keep elements without child nodes (no text and no children) 458 | return allElements.push(next); 459 | } 460 | 461 | // Recursively run through child nodes 462 | if (next.childNodes.length) { 463 | allElements.push(next); 464 | elements.push(...this.splitElement(next, key, splitOn, preserveWhitespace)); 465 | } else { 466 | this.handleRawElement(F, next, key, splitOn, preserveWhitespace, elements, allElements); 467 | } 468 | }); 469 | 470 | allElements.forEach((e) => F.appendChild(e)); 471 | 472 | // Clear out the existing element 473 | el.innerHTML = ''; 474 | el.appendChild(F); 475 | 476 | return elements; 477 | } 478 | 479 | offsetTop(el, offsetParent) { 480 | let offsetGrandparent = offsetParent.offsetParent; 481 | let top = 0; 482 | let p = el; 483 | while (p && p !== offsetParent && p !== offsetGrandparent) { 484 | top += p.offsetTop; 485 | p = p.offsetParent; 486 | } 487 | return top; 488 | } 489 | 490 | // if you ever get odd line breaks try increasing lineThreshold which is 0.2 by default which means "20% of the font-size" 491 | detectLinesTop(el, els, lineThreshold) { 492 | let lineOffsetY = -999; 493 | 494 | const computedStyle = window.getComputedStyle(el); 495 | const fontSize = parseFloat(computedStyle['fontSize'] || 0); 496 | const threshold = fontSize * lineThreshold; 497 | 498 | const result = els.map((e) => { 499 | const top = Math.round(this.offsetTop(e, el)); 500 | if (Math.abs(top - lineOffsetY) > threshold) lineOffsetY = top; 501 | e.__top = lineOffsetY; 502 | return e.__top; 503 | }); 504 | 505 | return [...new Set(result)]; // deduplicate result 506 | } 507 | 508 | splitLines(el) { 509 | const lines = []; 510 | 511 | const brs = this.findAllBr(el, false); 512 | 513 | brs.forEach((br) => { 514 | const lineParent = this.findLineParent(br); 515 | const line = this.createLine(); 516 | line.__isLine = true; 517 | lineParent?.__lines?.push(line); 518 | }); 519 | 520 | let globalLineIndex = 0; 521 | this.lineParents.forEach((lp, i) => { 522 | let lineIndex = 0; 523 | if (i > 0) globalLineIndex++; 524 | toArray(lp.childNodes).forEach((next) => { 525 | if (next.tagName === 'BR') { 526 | globalLineIndex++; 527 | lineIndex++; 528 | next.remove(); 529 | } else { 530 | lp.__lines[lineIndex].appendChild(next); 531 | toArray(next.childNodes).forEach((e) => (e.__lineIndex = globalLineIndex)); 532 | next.__lineIndex = globalLineIndex; 533 | } 534 | }); 535 | lp.__lines.forEach((line) => lp.appendChild(line)); 536 | lines.push(...lp.__lines); 537 | }); 538 | 539 | lines.forEach((line) => { 540 | line.__words = toArray(line.getElementsByClassName('word')); 541 | line.__wordCount = line.__words.length; 542 | }); 543 | 544 | return lines; 545 | } 546 | 547 | createLine(parent) { 548 | const line = document.createElement('span'); 549 | line.style.setProperty('display', 'block'); 550 | line.className = 'line'; 551 | return parent ? parent.appendChild(line) : line; 552 | } 553 | 554 | createElement(parent, key, text) { 555 | const el = document.createElement('span'); 556 | el.style.setProperty('display', 'inline-block'); 557 | el.className = key; 558 | el.textContent = text; 559 | el.setAttribute('aria-hidden', true); 560 | return parent.appendChild(el); 561 | } 562 | 563 | checkMinLines(element, els) { 564 | if (this.options.minLines <= 1 || (this.options.minLines > 1 && els.length <= 1)) return true; 565 | let lineTop = els[0].__top, 566 | linesCount = 1; 567 | 568 | els.forEach((a) => { 569 | const top = a.__top; 570 | if (top > lineTop) { 571 | lineTop = top; 572 | linesCount++; 573 | } 574 | }); 575 | 576 | const diff = this.options.minLines - linesCount; 577 | if (diff > 1 && !this.warned) { 578 | this.warned = true; 579 | console.warn(`SplitText is ran ${diff} times. Careful as this option might be expensive 🫰`.toUpperCase(), element); 580 | } 581 | 582 | const word = this.words[this.words.length - 1]; 583 | let x = word.offsetLeft + word.offsetWidth * 0.9; 584 | if (element.offsetWidth < x) { 585 | x = element.offsetWidth - word.offsetWidth * 0.5; 586 | } 587 | if (linesCount < this.options.minLines && x > 0) { 588 | element.style.width = `${x}px`; 589 | 590 | this.revert(); 591 | this.balance(element); 592 | this.split(); 593 | 594 | return false; 595 | } 596 | 597 | return true; 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /tests/example.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import AxeBuilder from '@axe-core/playwright'; 3 | 4 | test.describe('SplitText Accessibility Tests', () => { 5 | test('should pass basic accessibility checks', async ({ page }) => { 6 | await page.goto('http://localhost:5173/'); 7 | 8 | const accessibilityScanResults = await new AxeBuilder({ page }).include('#output-text').analyze(); 9 | expect(accessibilityScanResults.violations).toEqual([]); 10 | }); 11 | }); 12 | --------------------------------------------------------------------------------