@underlying/text 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) underlyi.ng
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.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ <p align="center">
2
+ <img alt="underlying" src="https://underlyi.ng/wordmark-sapin.svg" width="280" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <strong>Accessible text splitting and physics-first reveal.</strong>
7
+ </p>
8
+
9
+ <p align="center">
10
+ <a href="https://underlyi.ng"><img alt="docs" src="https://img.shields.io/badge/docs-underlyi.ng-1C3426" /></a>
11
+ <img alt="text gzip" src="https://img.shields.io/badge/text-~2%20kB%20gzip-1C3426" />
12
+ <img alt="built on" src="https://img.shields.io/badge/built%20on-%40underlying%2Fcore-1C3426" />
13
+ <img alt="license" src="https://img.shields.io/badge/license-MIT-1C3426" />
14
+ </p>
15
+
16
+ > Beta. The API may still move before 1.0.
17
+
18
+ Split text into chars, words and lines you can animate - without breaking the things SplitText historically broke. The screen reader still reads the whole text, copy/paste is intact, and emoji stay whole.
19
+
20
+ ```sh
21
+ npm install @underlying/text @underlying/core
22
+ ```
23
+
24
+ ## `split()`
25
+
26
+ The accessible foundation. A visually-hidden real-text copy stays the only thing screen readers and copy/paste see; the animated pieces are `aria-hidden`.
27
+
28
+ ```ts
29
+ import { split } from '@underlying/text'
30
+ import { stagger, animate } from '@underlying/core'
31
+
32
+ const s = split(headline, { type: ['words', 'lines'] })
33
+ stagger(s.words, (el) => animate(el, { y: [20, 0], opacity: [0, 1] }), 30)
34
+ // s.revert() restores the element, byte-identical
35
+ ```
36
+
37
+ - **Accessible by construction** - a screen reader reads "Hello world", not "H-e-l-l-o", and never twice.
38
+ - **Emoji-safe** - chars use `Intl.Segmenter`, so flags, ZWJ families and skin-tone sequences stay one piece.
39
+ - **Real lines** - measured from layout (`offsetTop` after `document.fonts.ready`) and re-split on width resize.
40
+ - **Lossless `revert()`** and SSR-safe.
41
+
42
+ ## `reveal()`
43
+
44
+ One call: split, then spring the pieces in - a real spring, overshoot and all, not an eased curve. Reduced-motion safe (it shows immediately, no per-piece motion, under `prefers-reduced-motion`).
45
+
46
+ ```ts
47
+ import { reveal } from '@underlying/text'
48
+
49
+ reveal(headline, { by: 'words', each: 40, from: { y: 24, opacity: 0 } })
50
+ ```
51
+
52
+ ## `scramble()` and `typewriter()`
53
+
54
+ Content effects on the frame clock (a background tab pauses them). The final text is the accessible name throughout - the changing characters are `aria-hidden`, never read as gibberish.
55
+
56
+ ```ts
57
+ import { scramble, typewriter } from '@underlying/text'
58
+
59
+ scramble(title, 'underlying') // decode it in
60
+ typewriter(line, 'physics-first.') // type it in
61
+ ```
62
+
63
+ ## License
64
+
65
+ MIT (c) underlyi.ng
@@ -0,0 +1,16 @@
1
+ import { type Scheduler } from '@underlying/core';
2
+ export interface TextEffect {
3
+ /** Resolves when the effect completes (or on stop). Never rejects. */
4
+ readonly finished: Promise<void>;
5
+ /** Snap to the final text now. */
6
+ stop(): void;
7
+ }
8
+ /**
9
+ * Shared runner for the content effects (scramble, typewriter). The final text
10
+ * is the accessible name throughout (aria-label on the element) and the visible,
11
+ * changing text is aria-hidden - so a screen reader reads the result, never the
12
+ * intermediate gibberish. Runs on the frame clock (background-tab-safe), and
13
+ * under reduced motion it lands on the final text immediately.
14
+ */
15
+ export declare function runTextEffect(element: HTMLElement, finalText: string, duration: number, scheduler: Scheduler, render: (progress: number) => string): TextEffect;
16
+ //# sourceMappingURL=effect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effect.d.ts","sourceRoot":"","sources":["../src/effect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwC,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAEvF,MAAM,WAAW,UAAU;IACzB,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;IAChC,kCAAkC;IAClC,IAAI,IAAI,IAAI,CAAA;CACb;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,WAAW,EACpB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GACnC,UAAU,CAgDZ"}
package/dist/index.cjs ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const v=require("@underlying/core");function S(t,e){const n=Intl.Segmenter;return n!==void 0?Array.from(new n(e,{granularity:"grapheme"}).segment(t),h=>h.segment):[...t]}const H="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0";function N(t,e={}){const n=e.type??["words"],h=n.includes("chars"),c=n.includes("lines"),a=e.a11y??"copy",r=e.locale,b=e.resize??c,u=t.innerHTML,y=t.textContent??"",p=[],s=[],g=[];let l=t,m=!1;const o=()=>{p.length=0,s.length=0,g.length=0,l=document.createElement("span"),l.className="u-text",a!=="off"&&l.setAttribute("aria-hidden","true");for(const d of y.split(/(\s+)/)){if(d==="")continue;if(/^\s+$/.test(d)){l.appendChild(document.createTextNode(d));continue}const f=document.createElement("span");if(f.className="u-text__word",f.style.display="inline-block",h)for(const w of S(d,r)){const i=document.createElement("span");i.className="u-text__char",i.style.display="inline-block",i.textContent=w,f.appendChild(i),p.push(i)}else f.textContent=d;l.appendChild(f),s.push(f)}},_=()=>{if(t.replaceChildren(),a==="copy"){const d=document.createElement("span");d.style.cssText=H,d.innerHTML=u,t.appendChild(d)}else a==="label"&&t.setAttribute("aria-label",y);t.appendChild(l)},k=()=>{if(!c||s.length===0)return;const d=s.map(i=>i.offsetTop),f=[];let w=Number.NaN;s.forEach((i,M)=>{const x=d[M]??0;(f.length===0||Math.abs(x-w)>1)&&f.push([]),f[f.length-1].push(i),w=x});for(const i of f){const M=document.createElement("span");M.className="u-text__line",M.style.display="block";const x=i[0],z=i[i.length-1];l.insertBefore(M,x);const L=[];let C=x;for(;C!==null&&(L.push(C),C!==z);)C=C.nextSibling;for(const P of L)M.appendChild(P);g.push(M)}},A=()=>{o(),_(),k()},E=()=>{m||A()};A(),c&&typeof document<"u"&&"fonts"in document&&document.fonts.ready.then(E);let T=null,O=0;if(b&&c&&typeof ResizeObserver<"u"){let d=t.getBoundingClientRect().width;T=new ResizeObserver(f=>{var i;const w=((i=f[0])==null?void 0:i.contentRect.width)??d;w!==d&&(d=w,clearTimeout(O),O=window.setTimeout(E,200))}),T.observe(t)}return{chars:p,words:s,lines:g,revert(){m||(m=!0,T==null||T.disconnect(),clearTimeout(O),t.innerHTML=u,a==="label"&&t.removeAttribute("aria-label"),p.length=0,s.length=0,g.length=0)}}}const j={y:24,opacity:0},F={x:0,y:0,scale:1,opacity:1};function I(t,e={}){const n=e.by??"words",h=e.each??40,c=e.from??j,a={type:n==="chars"?["words","chars"]:n==="lines"?["words","lines"]:["words"],resize:!1};e.a11y!==void 0&&(a.a11y=e.a11y),e.locale!==void 0&&(a.locale=e.locale);const r=N(t,a),b=n==="chars"?r.chars:n==="lines"?r.lines:r.words;if(v.prefersReducedMotion())return{split:r,finished:Promise.resolve(),stop(){},revert(){r.revert()}};const u=e.scheduler,y=Object.keys(c).filter(o=>c[o]!==void 0),p={},s={};for(const o of y)p[o]=c[o],s[o]=F[o];const g=u!==void 0?{scheduler:u}:{};for(const o of b)v.setStyle(o,p,g);const l={reducedMotion:"fade"};e.duration!==void 0&&(l.duration=e.duration),e.stiffness!==void 0&&(l.stiffness=e.stiffness),e.damping!==void 0&&(l.damping=e.damping),u!==void 0&&(l.scheduler=u);const m=v.stagger(b,o=>v.animate(o,s,l),h,u!==void 0?{scheduler:u}:{});return{split:r,finished:m.finished,stop(){m.stop()},revert(){m.stop();for(const o of b)v.releaseStyle(o);r.revert()}}}function R(t,e,n,h,c){const a=t.getAttribute("aria-label");t.setAttribute("aria-label",e);const r=document.createElement("span");r.setAttribute("aria-hidden","true"),t.replaceChildren(r);let b=()=>{};const u=new Promise(m=>{b=m}),y=()=>{t.textContent=e,a===null?t.removeAttribute("aria-label"):t.setAttribute("aria-label",a),b()};if(v.prefersReducedMotion())return y(),{finished:u,stop(){}};let p=0,s=!1,g=()=>{};const l=({deltaMs:m})=>{if(s)return;p+=m;const o=Math.min(p/n,1);r.textContent=c(o),o>=1&&(s=!0,g(),y())};return g=h.subscribe(l),{finished:u,stop(){s||(s=!0,g(),y())}}}const B="!<>-_\\/[]{}=+*^?#abcdef0123456789";function D(t,e,n={}){const h=n.duration??1400,c=n.chars??B,a=n.scheduler??v.getSharedScheduler(),r=S(e,n.locale),b=()=>c[Math.floor(Math.random()*c.length)]??"";return R(t,e,h,a,u=>{const y=Math.floor(u*r.length);let p="";for(let s=0;s<r.length;s++){const g=r[s];p+=s<y||/\s/.test(g)?g:b()}return p})}function Y(t,e,n={}){const h=S(e,n.locale),c=n.duration??Math.max(400,h.length*55),a=n.scheduler??v.getSharedScheduler();return R(t,e,c,a,r=>h.slice(0,Math.floor(r*h.length)).join(""))}exports.graphemes=S;exports.reveal=I;exports.scramble=D;exports.split=N;exports.typewriter=Y;
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","sources":["../src/segment.ts","../src/split.ts","../src/reveal.ts","../src/effect.ts","../src/scramble.ts","../src/typewriter.ts"],"sourcesContent":["interface SegmenterLike {\n segment(input: string): Iterable<{ segment: string }>\n}\ninterface SegmenterCtor {\n new (locales?: string, options?: { granularity?: 'grapheme' | 'word' | 'sentence' }): SegmenterLike\n}\n\n/**\n * Split into grapheme clusters. Emoji, flags, ZWJ sequences (family/profession)\n * and combining marks / skin tones stay ONE piece - unlike [...string], which\n * fixes surrogate pairs but still shatters those into broken fragments.\n * Falls back to code-point iteration where Intl.Segmenter is unavailable.\n */\nexport function graphemes(text: string, locale?: string): string[] {\n const Segmenter = (Intl as { Segmenter?: SegmenterCtor }).Segmenter\n if (Segmenter !== undefined) {\n return Array.from(new Segmenter(locale, { granularity: 'grapheme' }).segment(text), (entry) => entry.segment)\n }\n return [...text]\n}\n","import { graphemes } from './segment'\n\nexport type SplitType = 'chars' | 'words' | 'lines'\nexport type SplitA11y = 'copy' | 'label' | 'off'\n\nexport interface SplitOptions {\n /** What to expose. Words are always built (as the structural unit); 'chars' and 'lines' opt in. Default ['words']. */\n type?: SplitType[]\n /**\n * How the original text stays readable. 'copy' (default): a visually-hidden real-text copy is the only\n * thing screen readers + copy/paste see, and the animated pieces are aria-hidden. 'label': aria-label on\n * the element. 'off': no accessibility handling (you own it).\n */\n a11y?: SplitA11y\n /** Locale for grapheme segmentation. */\n locale?: string\n /** Re-split lines on a width change (lines invalidate on reflow). Default true when 'lines' is requested. */\n resize?: boolean\n}\n\nexport interface Split {\n /** Per-grapheme spans (empty unless 'chars' requested). */\n readonly chars: HTMLElement[]\n /** Per-word spans. */\n readonly words: HTMLElement[]\n /** Per-line spans (empty unless 'lines' requested). Re-measured on font load and width resize. */\n readonly lines: HTMLElement[]\n /** Restore the element to its exact pre-split state and stop observing. */\n revert(): void\n}\n\n// The visually-hidden recipe (NOT display:none, which hides from screen readers too).\nconst SR_ONLY =\n 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0'\n\n/**\n * Split an element's text into animatable chars / words / lines WITHOUT breaking\n * accessibility: a screen reader still reads the whole text (once), copy/paste\n * is intact, and emoji stay whole. Feed the returned arrays to core's stagger().\n */\nexport function split(element: HTMLElement, options: SplitOptions = {}): Split {\n const types = options.type ?? ['words']\n const wantChars = types.includes('chars')\n const wantLines = types.includes('lines')\n const a11y = options.a11y ?? 'copy'\n const locale = options.locale\n const watchResize = options.resize ?? wantLines\n\n const originalHTML = element.innerHTML\n const text = element.textContent ?? ''\n\n const chars: HTMLElement[] = []\n const words: HTMLElement[] = []\n const lines: HTMLElement[] = []\n let pieces: HTMLElement = element // reassigned in buildPieces\n let reverted = false\n\n const buildPieces = (): void => {\n chars.length = 0\n words.length = 0\n lines.length = 0\n pieces = document.createElement('span')\n pieces.className = 'u-text'\n if (a11y !== 'off') pieces.setAttribute('aria-hidden', 'true')\n\n for (const token of text.split(/(\\s+)/)) {\n if (token === '') continue\n if (/^\\s+$/.test(token)) {\n pieces.appendChild(document.createTextNode(token)) // keep whitespace between words\n continue\n }\n const word = document.createElement('span')\n word.className = 'u-text__word'\n word.style.display = 'inline-block'\n if (wantChars) {\n for (const grapheme of graphemes(token, locale)) {\n const char = document.createElement('span')\n char.className = 'u-text__char'\n char.style.display = 'inline-block'\n char.textContent = grapheme\n word.appendChild(char)\n chars.push(char)\n }\n } else {\n word.textContent = token\n }\n pieces.appendChild(word)\n words.push(word)\n }\n }\n\n const mount = (): void => {\n element.replaceChildren()\n if (a11y === 'copy') {\n const readable = document.createElement('span')\n readable.style.cssText = SR_ONLY\n readable.innerHTML = originalHTML // preserves nested markup for screen readers + copy\n element.appendChild(readable)\n } else if (a11y === 'label') {\n element.setAttribute('aria-label', text)\n }\n element.appendChild(pieces)\n }\n\n // Lines are a LAYOUT fact: group words by their offsetTop, then box each group.\n const measureLines = (): void => {\n if (!wantLines || words.length === 0) return\n const tops = words.map((word) => word.offsetTop) // all reads first\n const groups: HTMLElement[][] = []\n let prev = Number.NaN\n words.forEach((word, i) => {\n const top = tops[i] ?? 0\n if (groups.length === 0 || Math.abs(top - prev) > 1) groups.push([])\n groups[groups.length - 1]!.push(word)\n prev = top\n })\n for (const group of groups) {\n const line = document.createElement('span')\n line.className = 'u-text__line'\n line.style.display = 'block'\n const first = group[0]!\n const last = group[group.length - 1]!\n pieces.insertBefore(line, first)\n const move: ChildNode[] = []\n let node: ChildNode | null = first\n while (node !== null) {\n move.push(node)\n if (node === last) break\n node = node.nextSibling\n }\n for (const child of move) line.appendChild(child) // words + the whitespace between them\n lines.push(line)\n }\n }\n\n const build = (): void => {\n buildPieces()\n mount()\n measureLines()\n }\n const reSplit = (): void => {\n if (!reverted) build()\n }\n\n build()\n\n // Web fonts change metrics -> line wrapping. Re-measure once they settle.\n if (wantLines && typeof document !== 'undefined' && 'fonts' in document) {\n void document.fonts.ready.then(reSplit)\n }\n\n // Width changes invalidate line membership; debounce and ignore height.\n let observer: ResizeObserver | null = null\n let timer = 0\n if (watchResize && wantLines && typeof ResizeObserver !== 'undefined') {\n let lastWidth = element.getBoundingClientRect().width\n observer = new ResizeObserver((entries) => {\n const width = entries[0]?.contentRect.width ?? lastWidth\n if (width === lastWidth) return\n lastWidth = width\n clearTimeout(timer)\n timer = window.setTimeout(reSplit, 200)\n })\n observer.observe(element)\n }\n\n return {\n chars,\n words,\n lines,\n revert() {\n if (reverted) return\n reverted = true\n observer?.disconnect()\n clearTimeout(timer)\n element.innerHTML = originalHTML\n if (a11y === 'label') element.removeAttribute('aria-label')\n chars.length = 0\n words.length = 0\n lines.length = 0\n },\n }\n}\n","import {\n animate,\n prefersReducedMotion,\n releaseStyle,\n setStyle,\n stagger,\n type AnimateOptions,\n type Scheduler,\n} from '@underlying/core'\nimport { split, type Split, type SplitA11y, type SplitOptions, type SplitType } from './split'\n\nexport interface RevealFrom {\n x?: number\n y?: number\n scale?: number\n opacity?: number\n}\n\nexport interface RevealOptions {\n /** Which granularity to stagger in. Default 'words'. */\n by?: SplitType\n /** Ms between pieces. Default 40. */\n each?: number\n /** The hidden / offset start state each piece springs from. Default { y: 24, opacity: 0 }. */\n from?: RevealFrom\n /** Per-piece tween duration (ms). Omitted = spring (the default). */\n duration?: number\n stiffness?: number\n damping?: number\n a11y?: SplitA11y\n locale?: string\n scheduler?: Scheduler\n}\n\nexport interface Reveal {\n /** The underlying split, for the pieces and revert(). */\n readonly split: Split\n /** Resolves when every piece has settled (or on stop). */\n readonly finished: Promise<void>\n stop(): void\n /** Stop, release the per-piece animation state, and restore the original DOM. */\n revert(): void\n}\n\ntype Channels = Partial<Record<'x' | 'y' | 'scale' | 'opacity', number>>\nconst DEFAULT_FROM: RevealFrom = { y: 24, opacity: 0 }\nconst IDENTITY: Record<keyof RevealFrom, number> = { x: 0, y: 0, scale: 1, opacity: 1 }\n\n/**\n * Split an element and stagger its pieces in on springs - overshoot and all,\n * because the reveal is a real spring, not an eased curve. Accessible (the text\n * is read whole) and reduced-motion safe (it shows immediately, no per-piece\n * motion, under prefers-reduced-motion).\n */\nexport function reveal(element: HTMLElement, options: RevealOptions = {}): Reveal {\n const by = options.by ?? 'words'\n const each = options.each ?? 40\n const from = options.from ?? DEFAULT_FROM\n\n const splitOptions: SplitOptions = {\n type: by === 'chars' ? ['words', 'chars'] : by === 'lines' ? ['words', 'lines'] : ['words'],\n resize: false,\n }\n if (options.a11y !== undefined) splitOptions.a11y = options.a11y\n if (options.locale !== undefined) splitOptions.locale = options.locale\n const result = split(element, splitOptions)\n const pieces = by === 'chars' ? result.chars : by === 'lines' ? result.lines : result.words\n\n // Reduced motion: the text is already visible at rest - leave it, no motion.\n if (prefersReducedMotion()) {\n return {\n split: result,\n finished: Promise.resolve(),\n stop() {},\n revert() {\n result.revert()\n },\n }\n }\n\n const scheduler = options.scheduler\n const keys = (Object.keys(from) as (keyof RevealFrom)[]).filter((key) => from[key] !== undefined)\n const fromTargets: Channels = {}\n const toTargets: Channels = {}\n for (const key of keys) {\n fromTargets[key] = from[key]!\n toTargets[key] = IDENTITY[key]\n }\n // Hide/offset everything up front (on the same scheduler the animation runs on) - no flash.\n const setOptions = scheduler !== undefined ? { scheduler } : {}\n for (const piece of pieces) setStyle(piece, fromTargets, setOptions)\n\n const animOptions: AnimateOptions = { reducedMotion: 'fade' }\n if (options.duration !== undefined) animOptions.duration = options.duration\n if (options.stiffness !== undefined) animOptions.stiffness = options.stiffness\n if (options.damping !== undefined) animOptions.damping = options.damping\n if (scheduler !== undefined) animOptions.scheduler = scheduler\n\n const handle = stagger(\n pieces,\n (piece) => animate(piece, toTargets, animOptions),\n each,\n scheduler !== undefined ? { scheduler } : {},\n )\n\n return {\n split: result,\n finished: handle.finished,\n stop() {\n handle.stop()\n },\n revert() {\n handle.stop()\n for (const piece of pieces) releaseStyle(piece)\n result.revert()\n },\n }\n}\n","import { prefersReducedMotion, type FrameInfo, type Scheduler } from '@underlying/core'\n\nexport interface TextEffect {\n /** Resolves when the effect completes (or on stop). Never rejects. */\n readonly finished: Promise<void>\n /** Snap to the final text now. */\n stop(): void\n}\n\n/**\n * Shared runner for the content effects (scramble, typewriter). The final text\n * is the accessible name throughout (aria-label on the element) and the visible,\n * changing text is aria-hidden - so a screen reader reads the result, never the\n * intermediate gibberish. Runs on the frame clock (background-tab-safe), and\n * under reduced motion it lands on the final text immediately.\n */\nexport function runTextEffect(\n element: HTMLElement,\n finalText: string,\n duration: number,\n scheduler: Scheduler,\n render: (progress: number) => string,\n): TextEffect {\n const previousLabel = element.getAttribute('aria-label')\n element.setAttribute('aria-label', finalText)\n const holder = document.createElement('span')\n holder.setAttribute('aria-hidden', 'true')\n element.replaceChildren(holder)\n\n let resolveFinished: () => void = () => {}\n const finished = new Promise<void>((resolve) => {\n resolveFinished = resolve\n })\n const finish = (): void => {\n element.textContent = finalText\n if (previousLabel === null) element.removeAttribute('aria-label')\n else element.setAttribute('aria-label', previousLabel)\n resolveFinished()\n }\n\n if (prefersReducedMotion()) {\n finish()\n return { finished, stop() {} }\n }\n\n let elapsed = 0\n let done = false\n let unsubscribe: () => void = () => {}\n const frame = ({ deltaMs }: FrameInfo): void => {\n if (done) return\n elapsed += deltaMs\n const progress = Math.min(elapsed / duration, 1)\n holder.textContent = render(progress)\n if (progress >= 1) {\n done = true\n unsubscribe()\n finish()\n }\n }\n unsubscribe = scheduler.subscribe(frame)\n\n return {\n finished,\n stop() {\n if (done) return\n done = true\n unsubscribe()\n finish()\n },\n }\n}\n","import { getSharedScheduler, type Scheduler } from '@underlying/core'\nimport { graphemes } from './segment'\nimport { runTextEffect, type TextEffect } from './effect'\n\nexport interface ScrambleOptions {\n /** Total time (ms). Default 1400. */\n duration?: number\n /** Pool of glyphs to cycle through for not-yet-decoded positions. */\n chars?: string\n locale?: string\n scheduler?: Scheduler\n}\n\nconst POOL = '!<>-_\\\\/[]{}=+*^?#abcdef0123456789'\n\n/**\n * Decode `text` into `element`: positions reveal left to right while the rest\n * cycle random glyphs, settling on the target. The final text is the accessible\n * name throughout (the scrambling is aria-hidden).\n */\nexport function scramble(element: HTMLElement, text: string, options: ScrambleOptions = {}): TextEffect {\n const duration = options.duration ?? 1400\n const pool = options.chars ?? POOL\n const scheduler = options.scheduler ?? getSharedScheduler()\n const target = graphemes(text, options.locale)\n const randomGlyph = (): string => pool[Math.floor(Math.random() * pool.length)] ?? ''\n\n return runTextEffect(element, text, duration, scheduler, (progress) => {\n const revealed = Math.floor(progress * target.length)\n let out = ''\n for (let i = 0; i < target.length; i++) {\n const glyph = target[i]!\n out += i < revealed || /\\s/.test(glyph) ? glyph : randomGlyph()\n }\n return out\n })\n}\n","import { getSharedScheduler, type Scheduler } from '@underlying/core'\nimport { graphemes } from './segment'\nimport { runTextEffect, type TextEffect } from './effect'\n\nexport interface TypewriterOptions {\n /** Total time (ms). Default scales with length (~55 ms/char, min 400). */\n duration?: number\n locale?: string\n scheduler?: Scheduler\n}\n\n/**\n * Type `text` into `element` one grapheme at a time. The full text is the\n * accessible name throughout (the partial text is aria-hidden), so a screen\n * reader reads it once, not character by character.\n */\nexport function typewriter(element: HTMLElement, text: string, options: TypewriterOptions = {}): TextEffect {\n const target = graphemes(text, options.locale)\n const duration = options.duration ?? Math.max(400, target.length * 55)\n const scheduler = options.scheduler ?? getSharedScheduler()\n\n return runTextEffect(element, text, duration, scheduler, (progress) =>\n target.slice(0, Math.floor(progress * target.length)).join(''),\n )\n}\n"],"names":["graphemes","text","locale","Segmenter","entry","SR_ONLY","split","element","options","types","wantChars","wantLines","a11y","watchResize","originalHTML","chars","words","lines","pieces","reverted","buildPieces","token","word","grapheme","char","mount","readable","measureLines","tops","groups","prev","i","top","group","line","first","last","move","node","child","build","reSplit","observer","timer","lastWidth","entries","width","_a","DEFAULT_FROM","IDENTITY","reveal","by","each","from","splitOptions","result","prefersReducedMotion","scheduler","keys","key","fromTargets","toTargets","setOptions","piece","setStyle","animOptions","handle","stagger","animate","releaseStyle","runTextEffect","finalText","duration","render","previousLabel","holder","resolveFinished","finished","resolve","finish","elapsed","done","unsubscribe","frame","deltaMs","progress","POOL","scramble","pool","getSharedScheduler","target","randomGlyph","revealed","out","glyph","typewriter"],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ export { split } from './split';
2
+ export type { Split, SplitOptions, SplitType, SplitA11y } from './split';
3
+ export { reveal } from './reveal';
4
+ export type { Reveal, RevealOptions, RevealFrom } from './reveal';
5
+ export { scramble } from './scramble';
6
+ export type { ScrambleOptions } from './scramble';
7
+ export { typewriter } from './typewriter';
8
+ export type { TypewriterOptions } from './typewriter';
9
+ export type { TextEffect } from './effect';
10
+ export { graphemes } from './segment';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAC/B,YAAY,EAAE,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACxE,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AACjC,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AACjE,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AACrC,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AACjD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AACzC,YAAY,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AACrD,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAC1C,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,182 @@
1
+ import { prefersReducedMotion as O, setStyle as P, stagger as F, animate as I, releaseStyle as j, getSharedScheduler as S } from "@underlying/core";
2
+ function A(t, e) {
3
+ const n = Intl.Segmenter;
4
+ return n !== void 0 ? Array.from(new n(e, { granularity: "grapheme" }).segment(t), (h) => h.segment) : [...t];
5
+ }
6
+ const B = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0";
7
+ function D(t, e = {}) {
8
+ const n = e.type ?? ["words"], h = n.includes("chars"), c = n.includes("lines"), a = e.a11y ?? "copy", r = e.locale, b = e.resize ?? c, u = t.innerHTML, y = t.textContent ?? "", p = [], s = [], g = [];
9
+ let l = t, m = !1;
10
+ const o = () => {
11
+ p.length = 0, s.length = 0, g.length = 0, l = document.createElement("span"), l.className = "u-text", a !== "off" && l.setAttribute("aria-hidden", "true");
12
+ for (const d of y.split(/(\s+)/)) {
13
+ if (d === "") continue;
14
+ if (/^\s+$/.test(d)) {
15
+ l.appendChild(document.createTextNode(d));
16
+ continue;
17
+ }
18
+ const f = document.createElement("span");
19
+ if (f.className = "u-text__word", f.style.display = "inline-block", h)
20
+ for (const v of A(d, r)) {
21
+ const i = document.createElement("span");
22
+ i.className = "u-text__char", i.style.display = "inline-block", i.textContent = v, f.appendChild(i), p.push(i);
23
+ }
24
+ else
25
+ f.textContent = d;
26
+ l.appendChild(f), s.push(f);
27
+ }
28
+ }, k = () => {
29
+ if (t.replaceChildren(), a === "copy") {
30
+ const d = document.createElement("span");
31
+ d.style.cssText = B, d.innerHTML = u, t.appendChild(d);
32
+ } else a === "label" && t.setAttribute("aria-label", y);
33
+ t.appendChild(l);
34
+ }, R = () => {
35
+ if (!c || s.length === 0) return;
36
+ const d = s.map((i) => i.offsetTop), f = [];
37
+ let v = Number.NaN;
38
+ s.forEach((i, w) => {
39
+ const C = d[w] ?? 0;
40
+ (f.length === 0 || Math.abs(C - v) > 1) && f.push([]), f[f.length - 1].push(i), v = C;
41
+ });
42
+ for (const i of f) {
43
+ const w = document.createElement("span");
44
+ w.className = "u-text__line", w.style.display = "block";
45
+ const C = i[0], z = i[i.length - 1];
46
+ l.insertBefore(w, C);
47
+ const N = [];
48
+ let T = C;
49
+ for (; T !== null && (N.push(T), T !== z); )
50
+ T = T.nextSibling;
51
+ for (const H of N) w.appendChild(H);
52
+ g.push(w);
53
+ }
54
+ }, E = () => {
55
+ o(), k(), R();
56
+ }, L = () => {
57
+ m || E();
58
+ };
59
+ E(), c && typeof document < "u" && "fonts" in document && document.fonts.ready.then(L);
60
+ let x = null, M = 0;
61
+ if (b && c && typeof ResizeObserver < "u") {
62
+ let d = t.getBoundingClientRect().width;
63
+ x = new ResizeObserver((f) => {
64
+ var i;
65
+ const v = ((i = f[0]) == null ? void 0 : i.contentRect.width) ?? d;
66
+ v !== d && (d = v, clearTimeout(M), M = window.setTimeout(L, 200));
67
+ }), x.observe(t);
68
+ }
69
+ return {
70
+ chars: p,
71
+ words: s,
72
+ lines: g,
73
+ revert() {
74
+ m || (m = !0, x == null || x.disconnect(), clearTimeout(M), t.innerHTML = u, a === "label" && t.removeAttribute("aria-label"), p.length = 0, s.length = 0, g.length = 0);
75
+ }
76
+ };
77
+ }
78
+ const Y = { y: 24, opacity: 0 }, G = { x: 0, y: 0, scale: 1, opacity: 1 };
79
+ function $(t, e = {}) {
80
+ const n = e.by ?? "words", h = e.each ?? 40, c = e.from ?? Y, a = {
81
+ type: n === "chars" ? ["words", "chars"] : n === "lines" ? ["words", "lines"] : ["words"],
82
+ resize: !1
83
+ };
84
+ e.a11y !== void 0 && (a.a11y = e.a11y), e.locale !== void 0 && (a.locale = e.locale);
85
+ const r = D(t, a), b = n === "chars" ? r.chars : n === "lines" ? r.lines : r.words;
86
+ if (O())
87
+ return {
88
+ split: r,
89
+ finished: Promise.resolve(),
90
+ stop() {
91
+ },
92
+ revert() {
93
+ r.revert();
94
+ }
95
+ };
96
+ const u = e.scheduler, y = Object.keys(c).filter((o) => c[o] !== void 0), p = {}, s = {};
97
+ for (const o of y)
98
+ p[o] = c[o], s[o] = G[o];
99
+ const g = u !== void 0 ? { scheduler: u } : {};
100
+ for (const o of b) P(o, p, g);
101
+ const l = { reducedMotion: "fade" };
102
+ e.duration !== void 0 && (l.duration = e.duration), e.stiffness !== void 0 && (l.stiffness = e.stiffness), e.damping !== void 0 && (l.damping = e.damping), u !== void 0 && (l.scheduler = u);
103
+ const m = F(
104
+ b,
105
+ (o) => I(o, s, l),
106
+ h,
107
+ u !== void 0 ? { scheduler: u } : {}
108
+ );
109
+ return {
110
+ split: r,
111
+ finished: m.finished,
112
+ stop() {
113
+ m.stop();
114
+ },
115
+ revert() {
116
+ m.stop();
117
+ for (const o of b) j(o);
118
+ r.revert();
119
+ }
120
+ };
121
+ }
122
+ function _(t, e, n, h, c) {
123
+ const a = t.getAttribute("aria-label");
124
+ t.setAttribute("aria-label", e);
125
+ const r = document.createElement("span");
126
+ r.setAttribute("aria-hidden", "true"), t.replaceChildren(r);
127
+ let b = () => {
128
+ };
129
+ const u = new Promise((m) => {
130
+ b = m;
131
+ }), y = () => {
132
+ t.textContent = e, a === null ? t.removeAttribute("aria-label") : t.setAttribute("aria-label", a), b();
133
+ };
134
+ if (O())
135
+ return y(), { finished: u, stop() {
136
+ } };
137
+ let p = 0, s = !1, g = () => {
138
+ };
139
+ const l = ({ deltaMs: m }) => {
140
+ if (s) return;
141
+ p += m;
142
+ const o = Math.min(p / n, 1);
143
+ r.textContent = c(o), o >= 1 && (s = !0, g(), y());
144
+ };
145
+ return g = h.subscribe(l), {
146
+ finished: u,
147
+ stop() {
148
+ s || (s = !0, g(), y());
149
+ }
150
+ };
151
+ }
152
+ const U = "!<>-_\\/[]{}=+*^?#abcdef0123456789";
153
+ function q(t, e, n = {}) {
154
+ const h = n.duration ?? 1400, c = n.chars ?? U, a = n.scheduler ?? S(), r = A(e, n.locale), b = () => c[Math.floor(Math.random() * c.length)] ?? "";
155
+ return _(t, e, h, a, (u) => {
156
+ const y = Math.floor(u * r.length);
157
+ let p = "";
158
+ for (let s = 0; s < r.length; s++) {
159
+ const g = r[s];
160
+ p += s < y || /\s/.test(g) ? g : b();
161
+ }
162
+ return p;
163
+ });
164
+ }
165
+ function J(t, e, n = {}) {
166
+ const h = A(e, n.locale), c = n.duration ?? Math.max(400, h.length * 55), a = n.scheduler ?? S();
167
+ return _(
168
+ t,
169
+ e,
170
+ c,
171
+ a,
172
+ (r) => h.slice(0, Math.floor(r * h.length)).join("")
173
+ );
174
+ }
175
+ export {
176
+ A as graphemes,
177
+ $ as reveal,
178
+ q as scramble,
179
+ D as split,
180
+ J as typewriter
181
+ };
182
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../src/segment.ts","../src/split.ts","../src/reveal.ts","../src/effect.ts","../src/scramble.ts","../src/typewriter.ts"],"sourcesContent":["interface SegmenterLike {\n segment(input: string): Iterable<{ segment: string }>\n}\ninterface SegmenterCtor {\n new (locales?: string, options?: { granularity?: 'grapheme' | 'word' | 'sentence' }): SegmenterLike\n}\n\n/**\n * Split into grapheme clusters. Emoji, flags, ZWJ sequences (family/profession)\n * and combining marks / skin tones stay ONE piece - unlike [...string], which\n * fixes surrogate pairs but still shatters those into broken fragments.\n * Falls back to code-point iteration where Intl.Segmenter is unavailable.\n */\nexport function graphemes(text: string, locale?: string): string[] {\n const Segmenter = (Intl as { Segmenter?: SegmenterCtor }).Segmenter\n if (Segmenter !== undefined) {\n return Array.from(new Segmenter(locale, { granularity: 'grapheme' }).segment(text), (entry) => entry.segment)\n }\n return [...text]\n}\n","import { graphemes } from './segment'\n\nexport type SplitType = 'chars' | 'words' | 'lines'\nexport type SplitA11y = 'copy' | 'label' | 'off'\n\nexport interface SplitOptions {\n /** What to expose. Words are always built (as the structural unit); 'chars' and 'lines' opt in. Default ['words']. */\n type?: SplitType[]\n /**\n * How the original text stays readable. 'copy' (default): a visually-hidden real-text copy is the only\n * thing screen readers + copy/paste see, and the animated pieces are aria-hidden. 'label': aria-label on\n * the element. 'off': no accessibility handling (you own it).\n */\n a11y?: SplitA11y\n /** Locale for grapheme segmentation. */\n locale?: string\n /** Re-split lines on a width change (lines invalidate on reflow). Default true when 'lines' is requested. */\n resize?: boolean\n}\n\nexport interface Split {\n /** Per-grapheme spans (empty unless 'chars' requested). */\n readonly chars: HTMLElement[]\n /** Per-word spans. */\n readonly words: HTMLElement[]\n /** Per-line spans (empty unless 'lines' requested). Re-measured on font load and width resize. */\n readonly lines: HTMLElement[]\n /** Restore the element to its exact pre-split state and stop observing. */\n revert(): void\n}\n\n// The visually-hidden recipe (NOT display:none, which hides from screen readers too).\nconst SR_ONLY =\n 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0'\n\n/**\n * Split an element's text into animatable chars / words / lines WITHOUT breaking\n * accessibility: a screen reader still reads the whole text (once), copy/paste\n * is intact, and emoji stay whole. Feed the returned arrays to core's stagger().\n */\nexport function split(element: HTMLElement, options: SplitOptions = {}): Split {\n const types = options.type ?? ['words']\n const wantChars = types.includes('chars')\n const wantLines = types.includes('lines')\n const a11y = options.a11y ?? 'copy'\n const locale = options.locale\n const watchResize = options.resize ?? wantLines\n\n const originalHTML = element.innerHTML\n const text = element.textContent ?? ''\n\n const chars: HTMLElement[] = []\n const words: HTMLElement[] = []\n const lines: HTMLElement[] = []\n let pieces: HTMLElement = element // reassigned in buildPieces\n let reverted = false\n\n const buildPieces = (): void => {\n chars.length = 0\n words.length = 0\n lines.length = 0\n pieces = document.createElement('span')\n pieces.className = 'u-text'\n if (a11y !== 'off') pieces.setAttribute('aria-hidden', 'true')\n\n for (const token of text.split(/(\\s+)/)) {\n if (token === '') continue\n if (/^\\s+$/.test(token)) {\n pieces.appendChild(document.createTextNode(token)) // keep whitespace between words\n continue\n }\n const word = document.createElement('span')\n word.className = 'u-text__word'\n word.style.display = 'inline-block'\n if (wantChars) {\n for (const grapheme of graphemes(token, locale)) {\n const char = document.createElement('span')\n char.className = 'u-text__char'\n char.style.display = 'inline-block'\n char.textContent = grapheme\n word.appendChild(char)\n chars.push(char)\n }\n } else {\n word.textContent = token\n }\n pieces.appendChild(word)\n words.push(word)\n }\n }\n\n const mount = (): void => {\n element.replaceChildren()\n if (a11y === 'copy') {\n const readable = document.createElement('span')\n readable.style.cssText = SR_ONLY\n readable.innerHTML = originalHTML // preserves nested markup for screen readers + copy\n element.appendChild(readable)\n } else if (a11y === 'label') {\n element.setAttribute('aria-label', text)\n }\n element.appendChild(pieces)\n }\n\n // Lines are a LAYOUT fact: group words by their offsetTop, then box each group.\n const measureLines = (): void => {\n if (!wantLines || words.length === 0) return\n const tops = words.map((word) => word.offsetTop) // all reads first\n const groups: HTMLElement[][] = []\n let prev = Number.NaN\n words.forEach((word, i) => {\n const top = tops[i] ?? 0\n if (groups.length === 0 || Math.abs(top - prev) > 1) groups.push([])\n groups[groups.length - 1]!.push(word)\n prev = top\n })\n for (const group of groups) {\n const line = document.createElement('span')\n line.className = 'u-text__line'\n line.style.display = 'block'\n const first = group[0]!\n const last = group[group.length - 1]!\n pieces.insertBefore(line, first)\n const move: ChildNode[] = []\n let node: ChildNode | null = first\n while (node !== null) {\n move.push(node)\n if (node === last) break\n node = node.nextSibling\n }\n for (const child of move) line.appendChild(child) // words + the whitespace between them\n lines.push(line)\n }\n }\n\n const build = (): void => {\n buildPieces()\n mount()\n measureLines()\n }\n const reSplit = (): void => {\n if (!reverted) build()\n }\n\n build()\n\n // Web fonts change metrics -> line wrapping. Re-measure once they settle.\n if (wantLines && typeof document !== 'undefined' && 'fonts' in document) {\n void document.fonts.ready.then(reSplit)\n }\n\n // Width changes invalidate line membership; debounce and ignore height.\n let observer: ResizeObserver | null = null\n let timer = 0\n if (watchResize && wantLines && typeof ResizeObserver !== 'undefined') {\n let lastWidth = element.getBoundingClientRect().width\n observer = new ResizeObserver((entries) => {\n const width = entries[0]?.contentRect.width ?? lastWidth\n if (width === lastWidth) return\n lastWidth = width\n clearTimeout(timer)\n timer = window.setTimeout(reSplit, 200)\n })\n observer.observe(element)\n }\n\n return {\n chars,\n words,\n lines,\n revert() {\n if (reverted) return\n reverted = true\n observer?.disconnect()\n clearTimeout(timer)\n element.innerHTML = originalHTML\n if (a11y === 'label') element.removeAttribute('aria-label')\n chars.length = 0\n words.length = 0\n lines.length = 0\n },\n }\n}\n","import {\n animate,\n prefersReducedMotion,\n releaseStyle,\n setStyle,\n stagger,\n type AnimateOptions,\n type Scheduler,\n} from '@underlying/core'\nimport { split, type Split, type SplitA11y, type SplitOptions, type SplitType } from './split'\n\nexport interface RevealFrom {\n x?: number\n y?: number\n scale?: number\n opacity?: number\n}\n\nexport interface RevealOptions {\n /** Which granularity to stagger in. Default 'words'. */\n by?: SplitType\n /** Ms between pieces. Default 40. */\n each?: number\n /** The hidden / offset start state each piece springs from. Default { y: 24, opacity: 0 }. */\n from?: RevealFrom\n /** Per-piece tween duration (ms). Omitted = spring (the default). */\n duration?: number\n stiffness?: number\n damping?: number\n a11y?: SplitA11y\n locale?: string\n scheduler?: Scheduler\n}\n\nexport interface Reveal {\n /** The underlying split, for the pieces and revert(). */\n readonly split: Split\n /** Resolves when every piece has settled (or on stop). */\n readonly finished: Promise<void>\n stop(): void\n /** Stop, release the per-piece animation state, and restore the original DOM. */\n revert(): void\n}\n\ntype Channels = Partial<Record<'x' | 'y' | 'scale' | 'opacity', number>>\nconst DEFAULT_FROM: RevealFrom = { y: 24, opacity: 0 }\nconst IDENTITY: Record<keyof RevealFrom, number> = { x: 0, y: 0, scale: 1, opacity: 1 }\n\n/**\n * Split an element and stagger its pieces in on springs - overshoot and all,\n * because the reveal is a real spring, not an eased curve. Accessible (the text\n * is read whole) and reduced-motion safe (it shows immediately, no per-piece\n * motion, under prefers-reduced-motion).\n */\nexport function reveal(element: HTMLElement, options: RevealOptions = {}): Reveal {\n const by = options.by ?? 'words'\n const each = options.each ?? 40\n const from = options.from ?? DEFAULT_FROM\n\n const splitOptions: SplitOptions = {\n type: by === 'chars' ? ['words', 'chars'] : by === 'lines' ? ['words', 'lines'] : ['words'],\n resize: false,\n }\n if (options.a11y !== undefined) splitOptions.a11y = options.a11y\n if (options.locale !== undefined) splitOptions.locale = options.locale\n const result = split(element, splitOptions)\n const pieces = by === 'chars' ? result.chars : by === 'lines' ? result.lines : result.words\n\n // Reduced motion: the text is already visible at rest - leave it, no motion.\n if (prefersReducedMotion()) {\n return {\n split: result,\n finished: Promise.resolve(),\n stop() {},\n revert() {\n result.revert()\n },\n }\n }\n\n const scheduler = options.scheduler\n const keys = (Object.keys(from) as (keyof RevealFrom)[]).filter((key) => from[key] !== undefined)\n const fromTargets: Channels = {}\n const toTargets: Channels = {}\n for (const key of keys) {\n fromTargets[key] = from[key]!\n toTargets[key] = IDENTITY[key]\n }\n // Hide/offset everything up front (on the same scheduler the animation runs on) - no flash.\n const setOptions = scheduler !== undefined ? { scheduler } : {}\n for (const piece of pieces) setStyle(piece, fromTargets, setOptions)\n\n const animOptions: AnimateOptions = { reducedMotion: 'fade' }\n if (options.duration !== undefined) animOptions.duration = options.duration\n if (options.stiffness !== undefined) animOptions.stiffness = options.stiffness\n if (options.damping !== undefined) animOptions.damping = options.damping\n if (scheduler !== undefined) animOptions.scheduler = scheduler\n\n const handle = stagger(\n pieces,\n (piece) => animate(piece, toTargets, animOptions),\n each,\n scheduler !== undefined ? { scheduler } : {},\n )\n\n return {\n split: result,\n finished: handle.finished,\n stop() {\n handle.stop()\n },\n revert() {\n handle.stop()\n for (const piece of pieces) releaseStyle(piece)\n result.revert()\n },\n }\n}\n","import { prefersReducedMotion, type FrameInfo, type Scheduler } from '@underlying/core'\n\nexport interface TextEffect {\n /** Resolves when the effect completes (or on stop). Never rejects. */\n readonly finished: Promise<void>\n /** Snap to the final text now. */\n stop(): void\n}\n\n/**\n * Shared runner for the content effects (scramble, typewriter). The final text\n * is the accessible name throughout (aria-label on the element) and the visible,\n * changing text is aria-hidden - so a screen reader reads the result, never the\n * intermediate gibberish. Runs on the frame clock (background-tab-safe), and\n * under reduced motion it lands on the final text immediately.\n */\nexport function runTextEffect(\n element: HTMLElement,\n finalText: string,\n duration: number,\n scheduler: Scheduler,\n render: (progress: number) => string,\n): TextEffect {\n const previousLabel = element.getAttribute('aria-label')\n element.setAttribute('aria-label', finalText)\n const holder = document.createElement('span')\n holder.setAttribute('aria-hidden', 'true')\n element.replaceChildren(holder)\n\n let resolveFinished: () => void = () => {}\n const finished = new Promise<void>((resolve) => {\n resolveFinished = resolve\n })\n const finish = (): void => {\n element.textContent = finalText\n if (previousLabel === null) element.removeAttribute('aria-label')\n else element.setAttribute('aria-label', previousLabel)\n resolveFinished()\n }\n\n if (prefersReducedMotion()) {\n finish()\n return { finished, stop() {} }\n }\n\n let elapsed = 0\n let done = false\n let unsubscribe: () => void = () => {}\n const frame = ({ deltaMs }: FrameInfo): void => {\n if (done) return\n elapsed += deltaMs\n const progress = Math.min(elapsed / duration, 1)\n holder.textContent = render(progress)\n if (progress >= 1) {\n done = true\n unsubscribe()\n finish()\n }\n }\n unsubscribe = scheduler.subscribe(frame)\n\n return {\n finished,\n stop() {\n if (done) return\n done = true\n unsubscribe()\n finish()\n },\n }\n}\n","import { getSharedScheduler, type Scheduler } from '@underlying/core'\nimport { graphemes } from './segment'\nimport { runTextEffect, type TextEffect } from './effect'\n\nexport interface ScrambleOptions {\n /** Total time (ms). Default 1400. */\n duration?: number\n /** Pool of glyphs to cycle through for not-yet-decoded positions. */\n chars?: string\n locale?: string\n scheduler?: Scheduler\n}\n\nconst POOL = '!<>-_\\\\/[]{}=+*^?#abcdef0123456789'\n\n/**\n * Decode `text` into `element`: positions reveal left to right while the rest\n * cycle random glyphs, settling on the target. The final text is the accessible\n * name throughout (the scrambling is aria-hidden).\n */\nexport function scramble(element: HTMLElement, text: string, options: ScrambleOptions = {}): TextEffect {\n const duration = options.duration ?? 1400\n const pool = options.chars ?? POOL\n const scheduler = options.scheduler ?? getSharedScheduler()\n const target = graphemes(text, options.locale)\n const randomGlyph = (): string => pool[Math.floor(Math.random() * pool.length)] ?? ''\n\n return runTextEffect(element, text, duration, scheduler, (progress) => {\n const revealed = Math.floor(progress * target.length)\n let out = ''\n for (let i = 0; i < target.length; i++) {\n const glyph = target[i]!\n out += i < revealed || /\\s/.test(glyph) ? glyph : randomGlyph()\n }\n return out\n })\n}\n","import { getSharedScheduler, type Scheduler } from '@underlying/core'\nimport { graphemes } from './segment'\nimport { runTextEffect, type TextEffect } from './effect'\n\nexport interface TypewriterOptions {\n /** Total time (ms). Default scales with length (~55 ms/char, min 400). */\n duration?: number\n locale?: string\n scheduler?: Scheduler\n}\n\n/**\n * Type `text` into `element` one grapheme at a time. The full text is the\n * accessible name throughout (the partial text is aria-hidden), so a screen\n * reader reads it once, not character by character.\n */\nexport function typewriter(element: HTMLElement, text: string, options: TypewriterOptions = {}): TextEffect {\n const target = graphemes(text, options.locale)\n const duration = options.duration ?? Math.max(400, target.length * 55)\n const scheduler = options.scheduler ?? getSharedScheduler()\n\n return runTextEffect(element, text, duration, scheduler, (progress) =>\n target.slice(0, Math.floor(progress * target.length)).join(''),\n )\n}\n"],"names":["graphemes","text","locale","Segmenter","entry","SR_ONLY","split","element","options","types","wantChars","wantLines","a11y","watchResize","originalHTML","chars","words","lines","pieces","reverted","buildPieces","token","word","grapheme","char","mount","readable","measureLines","tops","groups","prev","i","top","group","line","first","last","move","node","child","build","reSplit","observer","timer","lastWidth","entries","width","_a","DEFAULT_FROM","IDENTITY","reveal","by","each","from","splitOptions","result","prefersReducedMotion","scheduler","keys","key","fromTargets","toTargets","setOptions","piece","setStyle","animOptions","handle","stagger","animate","releaseStyle","runTextEffect","finalText","duration","render","previousLabel","holder","resolveFinished","finished","resolve","finish","elapsed","done","unsubscribe","frame","deltaMs","progress","POOL","scramble","pool","getSharedScheduler","target","randomGlyph","revealed","out","glyph","typewriter"],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ import { type Scheduler } from '@underlying/core';
2
+ import { type Split, type SplitA11y, type SplitType } from './split';
3
+ export interface RevealFrom {
4
+ x?: number;
5
+ y?: number;
6
+ scale?: number;
7
+ opacity?: number;
8
+ }
9
+ export interface RevealOptions {
10
+ /** Which granularity to stagger in. Default 'words'. */
11
+ by?: SplitType;
12
+ /** Ms between pieces. Default 40. */
13
+ each?: number;
14
+ /** The hidden / offset start state each piece springs from. Default { y: 24, opacity: 0 }. */
15
+ from?: RevealFrom;
16
+ /** Per-piece tween duration (ms). Omitted = spring (the default). */
17
+ duration?: number;
18
+ stiffness?: number;
19
+ damping?: number;
20
+ a11y?: SplitA11y;
21
+ locale?: string;
22
+ scheduler?: Scheduler;
23
+ }
24
+ export interface Reveal {
25
+ /** The underlying split, for the pieces and revert(). */
26
+ readonly split: Split;
27
+ /** Resolves when every piece has settled (or on stop). */
28
+ readonly finished: Promise<void>;
29
+ stop(): void;
30
+ /** Stop, release the per-piece animation state, and restore the original DOM. */
31
+ revert(): void;
32
+ }
33
+ /**
34
+ * Split an element and stagger its pieces in on springs - overshoot and all,
35
+ * because the reveal is a real spring, not an eased curve. Accessible (the text
36
+ * is read whole) and reduced-motion safe (it shows immediately, no per-piece
37
+ * motion, under prefers-reduced-motion).
38
+ */
39
+ export declare function reveal(element: HTMLElement, options?: RevealOptions): Reveal;
40
+ //# sourceMappingURL=reveal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reveal.d.ts","sourceRoot":"","sources":["../src/reveal.ts"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAS,KAAK,KAAK,EAAE,KAAK,SAAS,EAAqB,KAAK,SAAS,EAAE,MAAM,SAAS,CAAA;AAE9F,MAAM,WAAW,UAAU;IACzB,CAAC,CAAC,EAAE,MAAM,CAAA;IACV,CAAC,CAAC,EAAE,MAAM,CAAA;IACV,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,wDAAwD;IACxD,EAAE,CAAC,EAAE,SAAS,CAAA;IACd,qCAAqC;IACrC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8FAA8F;IAC9F,IAAI,CAAC,EAAE,UAAU,CAAA;IACjB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB;AAED,MAAM,WAAW,MAAM;IACrB,yDAAyD;IACzD,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;IAChC,IAAI,IAAI,IAAI,CAAA;IACZ,iFAAiF;IACjF,MAAM,IAAI,IAAI,CAAA;CACf;AAMD;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,CA+DhF"}
@@ -0,0 +1,17 @@
1
+ import { type Scheduler } from '@underlying/core';
2
+ import { type TextEffect } from './effect';
3
+ export interface ScrambleOptions {
4
+ /** Total time (ms). Default 1400. */
5
+ duration?: number;
6
+ /** Pool of glyphs to cycle through for not-yet-decoded positions. */
7
+ chars?: string;
8
+ locale?: string;
9
+ scheduler?: Scheduler;
10
+ }
11
+ /**
12
+ * Decode `text` into `element`: positions reveal left to right while the rest
13
+ * cycle random glyphs, settling on the target. The final text is the accessible
14
+ * name throughout (the scrambling is aria-hidden).
15
+ */
16
+ export declare function scramble(element: HTMLElement, text: string, options?: ScrambleOptions): TextEffect;
17
+ //# sourceMappingURL=scramble.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scramble.d.ts","sourceRoot":"","sources":["../src/scramble.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAErE,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,UAAU,CAAA;AAEzD,MAAM,WAAW,eAAe;IAC9B,qCAAqC;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB;AAID;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,UAAU,CAgBtG"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Split into grapheme clusters. Emoji, flags, ZWJ sequences (family/profession)
3
+ * and combining marks / skin tones stay ONE piece - unlike [...string], which
4
+ * fixes surrogate pairs but still shatters those into broken fragments.
5
+ * Falls back to code-point iteration where Intl.Segmenter is unavailable.
6
+ */
7
+ export declare function graphemes(text: string, locale?: string): string[];
8
+ //# sourceMappingURL=segment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"segment.d.ts","sourceRoot":"","sources":["../src/segment.ts"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAMjE"}
@@ -0,0 +1,33 @@
1
+ export type SplitType = 'chars' | 'words' | 'lines';
2
+ export type SplitA11y = 'copy' | 'label' | 'off';
3
+ export interface SplitOptions {
4
+ /** What to expose. Words are always built (as the structural unit); 'chars' and 'lines' opt in. Default ['words']. */
5
+ type?: SplitType[];
6
+ /**
7
+ * How the original text stays readable. 'copy' (default): a visually-hidden real-text copy is the only
8
+ * thing screen readers + copy/paste see, and the animated pieces are aria-hidden. 'label': aria-label on
9
+ * the element. 'off': no accessibility handling (you own it).
10
+ */
11
+ a11y?: SplitA11y;
12
+ /** Locale for grapheme segmentation. */
13
+ locale?: string;
14
+ /** Re-split lines on a width change (lines invalidate on reflow). Default true when 'lines' is requested. */
15
+ resize?: boolean;
16
+ }
17
+ export interface Split {
18
+ /** Per-grapheme spans (empty unless 'chars' requested). */
19
+ readonly chars: HTMLElement[];
20
+ /** Per-word spans. */
21
+ readonly words: HTMLElement[];
22
+ /** Per-line spans (empty unless 'lines' requested). Re-measured on font load and width resize. */
23
+ readonly lines: HTMLElement[];
24
+ /** Restore the element to its exact pre-split state and stop observing. */
25
+ revert(): void;
26
+ }
27
+ /**
28
+ * Split an element's text into animatable chars / words / lines WITHOUT breaking
29
+ * accessibility: a screen reader still reads the whole text (once), copy/paste
30
+ * is intact, and emoji stay whole. Feed the returned arrays to core's stagger().
31
+ */
32
+ export declare function split(element: HTMLElement, options?: SplitOptions): Split;
33
+ //# sourceMappingURL=split.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"split.d.ts","sourceRoot":"","sources":["../src/split.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAA;AACnD,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAA;AAEhD,MAAM,WAAW,YAAY;IAC3B,sHAAsH;IACtH,IAAI,CAAC,EAAE,SAAS,EAAE,CAAA;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,wCAAwC;IACxC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,6GAA6G;IAC7G,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAED,MAAM,WAAW,KAAK;IACpB,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,CAAA;IAC7B,sBAAsB;IACtB,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,CAAA;IAC7B,kGAAkG;IAClG,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,CAAA;IAC7B,2EAA2E;IAC3E,MAAM,IAAI,IAAI,CAAA;CACf;AAMD;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,OAAO,EAAE,WAAW,EAAE,OAAO,GAAE,YAAiB,GAAG,KAAK,CA8I7E"}
@@ -0,0 +1,15 @@
1
+ import { type Scheduler } from '@underlying/core';
2
+ import { type TextEffect } from './effect';
3
+ export interface TypewriterOptions {
4
+ /** Total time (ms). Default scales with length (~55 ms/char, min 400). */
5
+ duration?: number;
6
+ locale?: string;
7
+ scheduler?: Scheduler;
8
+ }
9
+ /**
10
+ * Type `text` into `element` one grapheme at a time. The full text is the
11
+ * accessible name throughout (the partial text is aria-hidden), so a screen
12
+ * reader reads it once, not character by character.
13
+ */
14
+ export declare function typewriter(element: HTMLElement, text: string, options?: TypewriterOptions): TextEffect;
15
+ //# sourceMappingURL=typewriter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typewriter.d.ts","sourceRoot":"","sources":["../src/typewriter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAErE,OAAO,EAAiB,KAAK,UAAU,EAAE,MAAM,UAAU,CAAA;AAEzD,MAAM,WAAW,iBAAiB;IAChC,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,UAAU,CAQ1G"}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@underlying/text",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Accessible text splitting and physics-first reveal for @underlying/core: split into chars, words, lines without breaking screen readers, then reveal, scramble or type it in.",
5
+ "license": "MIT",
6
+ "author": "underlyi.ng <contact@underlyi.ng>",
7
+ "keywords": [
8
+ "animation",
9
+ "text",
10
+ "splittext",
11
+ "split",
12
+ "reveal",
13
+ "scramble",
14
+ "typewriter",
15
+ "accessibility",
16
+ "physics"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "provenance": false
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/underlyingjs/underlying.git",
25
+ "directory": "packages/text"
26
+ },
27
+ "homepage": "https://github.com/underlyingjs/underlying#readme",
28
+ "bugs": "https://github.com/underlyingjs/underlying/issues",
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "main": "./dist/index.cjs",
32
+ "module": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/index.js",
38
+ "require": "./dist/index.cjs"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist"
43
+ ],
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "dependencies": {
48
+ "@underlying/core": "0.1.0-beta.2"
49
+ },
50
+ "devDependencies": {
51
+ "esbuild": "^0.25.0",
52
+ "jsdom": "^26.1.0",
53
+ "typescript": "^5.8.3",
54
+ "vite": "^6.3.5",
55
+ "vitest": "^3.2.4"
56
+ },
57
+ "scripts": {
58
+ "test": "vitest run",
59
+ "test:watch": "vitest",
60
+ "build": "vite build && tsc -p tsconfig.build.json",
61
+ "typecheck": "tsc --noEmit",
62
+ "size": "node scripts/check-size.mjs"
63
+ }
64
+ }