├── .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 |
66 |
67 |
70 |
Revert
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 |
109 | pizza margherita
110 | hamburger
111 | taco
112 |
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(`${parentTagName}>`).join('');
281 | const newInnerHTML = cloneInnerHTML.split(/ ]*>/).join(`${parentTagName}> ${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 |
--------------------------------------------------------------------------------