@overpunch/speechtype 1.0.11

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/README.md ADDED
@@ -0,0 +1,258 @@
1
+ # speechType
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40overpunch%2Fspeechtype.svg)](https://www.npmjs.com/package/@overpunch/speechtype) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![part of liiift type-tools](https://img.shields.io/badge/liiift-type--tools-blueviolet)](https://github.com/over-punch/type-tools)
4
+
5
+ Typography that follows your voice — per-word typographic emphasis synced to Web Speech API boundary events. Each spoken word gets wider tracking, heavier weight, and larger optical size; the rest of the text recedes. A read-along effect grounded in typographic logic, not arbitrary highlight colours.
6
+
7
+ ![speechType emphasising each word of a paragraph in turn as it is spoken aloud — the active word grows bolder, wider-tracked, and larger in optical size while the surrounding text fades back](https://raw.githubusercontent.com/over-punch/speechType/main/assets/speechtype-demo.gif?v=1)
8
+
9
+ **[speechtype.vercel.app](https://speechtype.vercel.app)** · [npm](https://www.npmjs.com/package/@overpunch/speechtype) · [GitHub](https://github.com/over-punch/speechType)
10
+
11
+ TypeScript · Zero dependencies · React + Vanilla JS
12
+
13
+ **Good for** read-along reading aids, language-learning apps, teleprompters, and any interface where a spoken voice and on-screen text need to stay visibly in sync.
14
+
15
+ > **Requires a variable font** with `wght` and `opsz` axes (e.g. Merriweather, Inter, Source Serif). The weight and optical-size emphasis are written via `font-variation-settings`; with a static font only the tracking and opacity changes apply.
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install @overpunch/speechtype
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Usage
28
+
29
+ > **Next.js App Router:** this library uses browser APIs. Add `"use client"` to any component file that imports from it.
30
+
31
+ ### React component (controlled)
32
+
33
+ `SpeechTypeText` is a controlled component — you manage a `SpeechSynthesisUtterance` yourself, track which word is active in state, and pass the index as a prop. This pattern gives you full control over voice, timing, and UI.
34
+
35
+ > **Note:** the controlled component and the `useSpeechType` hook only apply the *visual* options (`activeTracking`, `activeWeight`, `activeOpsz`, `inactiveOpacity`, `transitionMs`). The *speech* options (`rate`, `pitch`, `volume`, `onUnsupported`, `onError`) are only read by `startSpeechType`, since in the controlled pattern you own the `SpeechSynthesisUtterance`. `SpeechTypeText` also takes an `as` prop (default `"p"`) and forwards any `aria-*`, `data-*`, `role`, and `lang` attributes to the rendered element.
36
+
37
+ ```tsx
38
+ "use client"
39
+ import { SpeechTypeText } from '@overpunch/speechtype'
40
+ import { useState, useCallback } from 'react'
41
+
42
+ const TEXT = 'The quick brown fox jumps over the lazy dog.'
43
+
44
+ export default function Demo() {
45
+ const [activeWordIndex, setActiveWordIndex] = useState(-1)
46
+
47
+ const handleSpeak = useCallback(() => {
48
+ const utterance = new SpeechSynthesisUtterance(TEXT)
49
+ utterance.onboundary = (e) => {
50
+ if (e.name === 'word') {
51
+ const wordIndex = TEXT.slice(0, e.charIndex).trim().split(/\s+/).filter(Boolean).length
52
+ setActiveWordIndex(wordIndex)
53
+ }
54
+ }
55
+ utterance.onend = () => setActiveWordIndex(-1)
56
+ speechSynthesis.speak(utterance)
57
+ }, [])
58
+
59
+ return (
60
+ <>
61
+ <SpeechTypeText activeWordIndex={activeWordIndex} activeWeight={700} inactiveOpacity={0.45}>
62
+ {TEXT}
63
+ </SpeechTypeText>
64
+ <button onClick={handleSpeak}>Speak</button>
65
+ </>
66
+ )
67
+ }
68
+ ```
69
+
70
+ ### React — imperative (startSpeechType)
71
+
72
+ For a simpler setup, skip `SpeechTypeText` and let `startSpeechType` manage everything directly on a plain element ref:
73
+
74
+ ```tsx
75
+ "use client"
76
+ import { useRef } from 'react'
77
+ import { startSpeechType, removeSpeechType } from '@overpunch/speechtype'
78
+
79
+ export default function Demo() {
80
+ const ref = useRef<HTMLParagraphElement>(null)
81
+ // stop() cancels speech and resets emphasis but keeps spans in place.
82
+ // removeSpeechType() does a full teardown — cancels speech AND restores original HTML.
83
+ // Call stop() for pause/stop controls; call removeSpeechType() only on unmount or full reset.
84
+ const stopRef = useRef<(() => void) | null>(null)
85
+
86
+ function handleSpeak() {
87
+ if (!ref.current) return
88
+ stopRef.current?.() // cancel any in-progress speech first
89
+ stopRef.current = startSpeechType(ref.current, { activeWeight: 700, rate: 0.9 })
90
+ }
91
+
92
+ function handleStop() {
93
+ stopRef.current?.()
94
+ stopRef.current = null
95
+ }
96
+
97
+ return (
98
+ <>
99
+ <p ref={ref}>The quick brown fox jumps over the lazy dog.</p>
100
+ <button onClick={handleSpeak}>Speak</button>
101
+ <button onClick={handleStop}>Stop</button>
102
+ </>
103
+ )
104
+ }
105
+ ```
106
+
107
+ ### React hook
108
+
109
+ `useSpeechType` is the low-level hook behind `SpeechTypeText`. Use it when you need the controlled pattern but want to render your own element:
110
+
111
+ ```tsx
112
+ "use client"
113
+ import { useSpeechType } from '@overpunch/speechtype'
114
+ import { useRef, useState, useCallback } from 'react'
115
+
116
+ export default function Demo() {
117
+ const ref = useRef<HTMLParagraphElement>(null)
118
+ const [activeWordIndex, setActiveWordIndex] = useState(-1)
119
+
120
+ useSpeechType(ref, activeWordIndex, { activeWeight: 700 })
121
+
122
+ return <p ref={ref}>The quick brown fox jumps over the lazy dog.</p>
123
+ }
124
+ ```
125
+
126
+ ### Vanilla JS
127
+
128
+ `startSpeechType` is the all-in-one entry point for vanilla use. It wraps the words in spans, starts the Web Speech API, updates the emphasis on each boundary event, and returns a `stop` function.
129
+
130
+ ```ts
131
+ import { startSpeechType, removeSpeechType } from '@overpunch/speechtype'
132
+
133
+ const el = document.querySelector('p')
134
+ const stop = startSpeechType(el, {
135
+ activeWeight: 700,
136
+ activeTracking: 0.06,
137
+ rate: 0.9,
138
+ })
139
+
140
+ // Later — stop speech and restore original HTML:
141
+ stop()
142
+ removeSpeechType(el)
143
+ ```
144
+
145
+ For more control, use the lower-level functions:
146
+
147
+ ```ts
148
+ import { prepareSpeechType, applySpeechType, removeSpeechType } from '@overpunch/speechtype'
149
+
150
+ const el = document.querySelector('p')
151
+ prepareSpeechType(el) // wraps each word in a span
152
+
153
+ applySpeechType(el, 3) // emphasise word at index 3
154
+ applySpeechType(el, -1) // clear emphasis
155
+
156
+ removeSpeechType(el) // restore original HTML
157
+ ```
158
+
159
+ ### TypeScript
160
+
161
+ ```ts
162
+ import type { SpeechTypeOptions } from '@overpunch/speechtype'
163
+
164
+ const opts: SpeechTypeOptions = {
165
+ activeTracking: 0.08,
166
+ activeWeight: 800,
167
+ inactiveOpacity: 0.3,
168
+ rate: 0.85,
169
+ }
170
+ ```
171
+
172
+ ---
173
+
174
+ ## Options
175
+
176
+ Visual options apply everywhere; speech options are only read by `startSpeechType` (see the note under [React component](#react-component-controlled)).
177
+
178
+ | Option | Type | Default | Scope | Description |
179
+ |--------|------|---------|-------|-------------|
180
+ | `activeTracking` | `number` | `0.06` | visual | Letter-spacing on the active (currently spoken) word, in em |
181
+ | `activeWeight` | `number` | `700` | visual | `wght` axis value on the active word. Must sit within the font's `wght` axis range |
182
+ | `activeOpsz` | `number` | `24` | visual | `opsz` axis value on the active word. Must sit within the font's `opsz` axis range |
183
+ | `inactiveOpacity` | `number` | `0.45` | visual | Opacity of inactive (not currently spoken) words. Keep ≥ 0.3 for legibility — values below ~0.5 may drop contrast under WCAG AA depending on your colours |
184
+ | `transitionMs` | `number` | `80` | visual | CSS transition duration in ms for style changes |
185
+ | `rate` | `number` | `0.9` | speech | Speech rate (0.1–10). Passed to `SpeechSynthesisUtterance` |
186
+ | `pitch` | `number` | `1` | speech | Speech pitch (0–2). Passed to `SpeechSynthesisUtterance` |
187
+ | `volume` | `number` | `1` | speech | Speech volume (0–1). Passed to `SpeechSynthesisUtterance` |
188
+ | `onUnsupported` | `() => void` | — | speech | Called when the browser has no `speechSynthesis`. Use it to surface a fallback (e.g. show the text statically or a manual stepper) |
189
+ | `onError` | `(e: SpeechSynthesisErrorEvent) => void` | — | speech | Called on a real speech error. The normal `"interrupted"` cancellation is filtered out for you |
190
+
191
+ ---
192
+
193
+ ## How it works
194
+
195
+ `prepareSpeechType` reads the element's text content and wraps each word in a `<span class="st-word">` — without changing visual layout. Note: inline child elements (`<em>`, `<strong>`, `<a>`, etc.) are flattened to plain text during wrapping. `applySpeechType` then writes `font-variation-settings`, `letter-spacing`, and `opacity` as inline styles directly on each span (no CSS class toggles). The active span gets wider tracking, heavier weight, and larger optical size; inactive spans get reduced opacity. CSS transitions on those properties are set once by `prepareSpeechType`.
196
+
197
+ `startSpeechType` wires a `SpeechSynthesisUtterance` to the browser's Web Speech API, listens for `boundary` events, maps the character offset to a word index, and calls `applySpeechType` on each event. It returns a `stop` function that cancels synthesis and removes all emphasis.
198
+
199
+ **Browser support:** Web Speech API is supported in Chrome, Edge, and Safari. Firefox requires a flag. Note that Safari fires `boundary` events sparsely, so word-level sync is most reliable in Chromium-based browsers; where boundaries don't fire, the text simply stays un-emphasised. `startSpeechType` falls back silently in environments without `speechSynthesis` — pass `onUnsupported` to detect that case and render your own fallback:
200
+
201
+ ```ts
202
+ startSpeechType(el, {
203
+ onUnsupported: () => showManualStepper(), // no Web Speech API here
204
+ onError: (e) => console.warn('Speech failed', e.error),
205
+ })
206
+ ```
207
+
208
+ ---
209
+
210
+ ## Accessibility
211
+
212
+ speechType is built for read-along contexts, so it ships screen-reader support rather than leaving it to you:
213
+
214
+ - Each word span is marked `aria-hidden="true"` and the active word also gets `aria-current="true"`, so assistive tech reads continuous text instead of 27 separate spans.
215
+ - An off-screen `aria-live="polite"` region announces the active word as emphasis moves, keeping non-visual users in sync with the highlight.
216
+ - All emphasis is plain CSS (`font-variation-settings`, `letter-spacing`, `opacity`) — no content is duplicated or reordered.
217
+
218
+ Two trade-offs to design around:
219
+
220
+ - **Contrast.** Inactive words fade to `inactiveOpacity` (default `0.45`), which *reduces* contrast. Keep it at `0.3` or higher and verify the result still meets WCAG AA (4.5:1) against your background — or raise it toward `1` if your audience needs maximum legibility.
221
+ - **Inline markup is flattened.** `prepareSpeechType` reads `textContent`, so inline children (`<em>`, `<strong>`, `<a>`, …) inside the target element are replaced by plain text when words are wrapped. Apply speechType to elements whose formatting you don't need to preserve, and use `getCleanHTML(el)` to recover the unwrapped markup if needed.
222
+
223
+ ---
224
+
225
+ ## API reference
226
+
227
+ | Export | Description |
228
+ |--------|-------------|
229
+ | `prepareSpeechType(el, options?)` | Wraps each word in a span. Call once before `applySpeechType`. |
230
+ | `applySpeechType(el, activeIndex, options?)` | Emphasises word at `activeIndex`. Pass `-1` to clear. |
231
+ | `startSpeechType(el, options?)` | All-in-one: prepares spans, starts Web Speech API, returns `stop()`. |
232
+ | `removeSpeechType(el)` | Cancels synthesis and restores original HTML. |
233
+ | `getCleanHTML(el)` | Returns element HTML with all injected spans removed. |
234
+ | `useSpeechType` | React hook: `(ref, activeWordIndex, options?)` |
235
+ | `SpeechTypeText` | React component. Controlled via `activeWordIndex` prop. Forwards ref. |
236
+ | `SpeechTypeOptions` | TypeScript interface for all options. |
237
+ | `SPEECH_CLASSES` | CSS class names injected by the algorithm (`st-word`). |
238
+
239
+ ---
240
+
241
+ ## Next.js
242
+
243
+ `SpeechTypeText`, `useSpeechType`, and `startSpeechType` all require a browser environment. Add `"use client"` to any component that imports them:
244
+
245
+ ```tsx
246
+ "use client"
247
+ import { SpeechTypeText } from '@overpunch/speechtype'
248
+ ```
249
+
250
+ ---
251
+
252
+ ## Dev notes
253
+
254
+ ### `next` in root devDependencies
255
+
256
+ `package.json` at the repo root lists `next` as a devDependency. This is a **Vercel detection workaround** — not a real dependency of the npm package. Vercel's build system inspects the root `package.json` to detect the framework; without `next` present it falls back to a static build and skips the Next.js pipeline, breaking the `/site` subdirectory deploy.
257
+
258
+ The package itself has zero runtime dependencies. Do not remove this entry.
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const g=require("react"),b=require("react/jsx-runtime"),v={word:"st-word"},w=new WeakMap;function C(t,e={}){if(typeof window>"u")return[];const r=window.scrollY,n=w.get(t),l=(n==null?void 0:n.originalHTML)??t.innerHTML;t.innerHTML=l;const d=(t.textContent??"").split(/(\s+)/);t.innerHTML=d.map(i=>!i||/^\s+$/.test(i)?i:`<span class="${v.word}" aria-hidden="true">${i}</span>`).join("");const u=Array.from(t.querySelectorAll(`.${v.word}`)),o=e.transitionMs??80;u.forEach(i=>{i.style.display="inline",i.style.transition=[`font-variation-settings ${o}ms ease`,`letter-spacing ${o}ms ease`,`opacity ${o}ms ease`].join(", ")});let c=t.querySelector("[data-st-live]");return c||(c=document.createElement("span"),c.setAttribute("data-st-live",""),c.setAttribute("aria-live","polite"),c.setAttribute("aria-atomic","true"),c.style.cssText="position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0",t.appendChild(c)),w.set(t,{originalHTML:l,wordSpans:u,utterance:null,activeIndex:-1}),requestAnimationFrame(()=>{Math.abs(window.scrollY-r)>2&&window.scrollTo({top:r,behavior:"instant"})}),u}function y(t,e,r={}){var c;if(typeof window>"u")return;const n=w.get(t);if(!n)return;const l=r.activeTracking??.06,s=r.activeWeight??700,d=r.activeOpsz??24,u=r.inactiveOpacity??.45;n.activeIndex=e;const o=t.querySelector("[data-st-live]");o&&(o.textContent=e>=0?((c=n.wordSpans[e])==null?void 0:c.textContent)??"":""),n.wordSpans.forEach((i,S)=>{S===e?(i.setAttribute("aria-current","true"),i.style.fontVariationSettings=`"wght" ${s}, "opsz" ${d}`,i.style.letterSpacing=`${l}em`,i.style.opacity="1"):(i.removeAttribute("aria-current"),i.style.fontVariationSettings="",i.style.letterSpacing="",i.style.opacity=e===-1?"1":String(u))})}function L(t,e={}){var m;if(typeof window>"u"||!("speechSynthesis"in window))return(m=e.onUnsupported)==null||m.call(e),()=>{};const r=C(t,e),n=w.get(t);if(!n)return()=>{};window.speechSynthesis.cancel();const l=r.map(a=>a.textContent??"").join(" "),s=new SpeechSynthesisUtterance(l);s.rate=e.rate??.9,s.pitch=e.pitch??1,s.volume=e.volume??1;const d=e.activeTracking??.06,u=e.activeWeight??700,o=e.activeOpsz??24,c={...e,activeTracking:d,activeWeight:u,activeOpsz:o};let i=0;const S=r.map(a=>{var h;const p=i;return i+=(((h=a.textContent)==null?void 0:h.length)??0)+1,p});let f=!1;return s.onboundary=a=>{if(f||a.name!=="word")return;const p=S.findIndex((h,x)=>{const T=S[x+1]??1/0;return a.charIndex>=h&&a.charIndex<T});p!==-1&&y(t,p,c)},s.onend=()=>{f||(y(t,-1,c),n.utterance=null)},s.onerror=a=>{var p;a.error!=="interrupted"&&((p=e.onError)==null||p.call(e,a)),f||(y(t,-1,c),n.utterance=null)},n.utterance=s,window.speechSynthesis.speak(s),()=>{f||(f=!0,window.speechSynthesis.cancel(),y(t,-1,c),n.utterance=null)}}function M(t){const e=w.get(t);e&&(typeof window<"u"&&"speechSynthesis"in window&&window.speechSynthesis.cancel(),t.innerHTML=e.originalHTML,w.delete(t))}function A(t){const e=t.cloneNode(!0);return e.querySelectorAll(`.${v.word}`).forEach(r=>{const n=r.parentNode;if(n){for(;r.firstChild;)n.insertBefore(r.firstChild,r);n.removeChild(r)}}),e.innerHTML}function E(t,e,r){g.useEffect(()=>{const n=t.current;if(n)return C(n,r),()=>M(n)},[r==null?void 0:r.transitionMs]),g.useEffect(()=>{const n=t.current;n&&y(n,e,r)},[e])}const $=g.forwardRef(function({activeWordIndex:e,as:r="p",children:n,style:l,className:s,activeTracking:d,activeWeight:u,activeOpsz:o,inactiveOpacity:c,transitionMs:i,rate:S,pitch:f,volume:m,onUnsupported:a,onError:p,...h},x){const T=g.useRef(null);g.useImperativeHandle(x,()=>T.current),E(T,e,{activeTracking:d,activeWeight:u,activeOpsz:o,inactiveOpacity:c,transitionMs:i});const H=r;return b.jsx(H,{ref:T,style:l,className:s,...h,children:n})});exports.SPEECH_CLASSES=v;exports.SpeechTypeText=$;exports.applySpeechType=y;exports.getCleanHTML=A;exports.prepareSpeechType=C;exports.removeSpeechType=M;exports.startSpeechType=L;exports.useSpeechType=E;
@@ -0,0 +1,153 @@
1
+ import { ElementType } from 'react';
2
+ import { ForwardRefExoticComponent } from 'react';
3
+ import { RefAttributes } from 'react';
4
+ import { RefObject } from 'react';
5
+
6
+ /**
7
+ * Apply typographic emphasis to the word at activeIndex.
8
+ * All other words receive the inactive style. Pass -1 to reset all words to neutral.
9
+ *
10
+ * @param el - Element previously prepared by prepareSpeechType
11
+ * @param activeIndex - Index of the word to emphasise, -1 for none
12
+ * @param options - SpeechTypeOptions (merged with defaults)
13
+ */
14
+ export declare function applySpeechType(el: HTMLElement, activeIndex: number, options?: SpeechTypeOptions): void;
15
+
16
+ /**
17
+ * Return a clean copy of el's innerHTML with all speechType spans unwrapped.
18
+ * Does not modify el itself.
19
+ *
20
+ * @param el - Element to read clean HTML from
21
+ */
22
+ export declare function getCleanHTML(el: HTMLElement): string;
23
+
24
+ declare type HTMLForwardProps = Omit<React.HTMLAttributes<HTMLElement>, keyof SpeechTypeOptions | 'children' | 'style' | 'className'>;
25
+
26
+ /**
27
+ * Wrap the text content of el in per-word <span> elements with the SPEECH_CLASSES.word
28
+ * class. Saves the original innerHTML for cleanup. Idempotent — calling again re-wraps
29
+ * from the saved original. Returns the array of word span elements.
30
+ *
31
+ * @param el - Element whose text will be wrapped
32
+ * @param options - SpeechTypeOptions (merged with defaults)
33
+ */
34
+ export declare function prepareSpeechType(el: HTMLElement, options?: SpeechTypeOptions): HTMLElement[];
35
+
36
+ /**
37
+ * Remove speechType from el — cancel any active speech, restore original innerHTML,
38
+ * and delete all saved state. No-op if prepareSpeechType was never called.
39
+ *
40
+ * @param el - The element previously prepared by prepareSpeechType
41
+ */
42
+ export declare function removeSpeechType(el: HTMLElement): void;
43
+
44
+ /**
45
+ * CSS class names injected by speechType.
46
+ * Only `word` ('st-word') is used — there are no st-active or st-inactive classes.
47
+ * Emphasis is applied via inline styles (fontVariationSettings, letterSpacing, opacity),
48
+ * not via class toggles.
49
+ */
50
+ export declare const SPEECH_CLASSES: {
51
+ readonly word: "st-word";
52
+ };
53
+
54
+ /**
55
+ * Options controlling how speechType emphasises spoken words.
56
+ *
57
+ * @remarks
58
+ * Visual options (activeTracking, activeWeight, activeOpsz, inactiveOpacity, transitionMs)
59
+ * are used by prepareSpeechType and applySpeechType.
60
+ * Speech options (rate, pitch, volume) are only used by startSpeechType and are ignored
61
+ * by applySpeechType, useSpeechType, and SpeechTypeText.
62
+ *
63
+ * activeWeight and activeOpsz are written directly into font-variation-settings. Ensure
64
+ * the values are within the wght/opsz axis ranges supported by your font — out-of-range
65
+ * values may cause some engines to silently ignore the entire declaration.
66
+ *
67
+ * inactiveOpacity: ensure the resulting contrast ratio remains at least 4.5:1 against
68
+ * your background colour to meet WCAG AA. The default of 0.45 may fail this threshold
69
+ * depending on your foreground/background pairing.
70
+ */
71
+ export declare interface SpeechTypeOptions {
72
+ /** Letter-spacing on the active (currently spoken) word in em. Default: 0.06 */
73
+ activeTracking?: number;
74
+ /**
75
+ * wght axis value on the active word. Default: 700.
76
+ * Must be within the font's supported wght axis range (e.g. 100–900 for most variable fonts).
77
+ */
78
+ activeWeight?: number;
79
+ /**
80
+ * opsz axis value on the active word. Default: 24.
81
+ * Must be within the font's supported opsz axis range (e.g. 6–72 for many variable fonts).
82
+ */
83
+ activeOpsz?: number;
84
+ /**
85
+ * Opacity of inactive (not currently spoken) words. Default: 0.45.
86
+ * Values below ~0.5 may reduce contrast below WCAG AA (4.5:1) depending on your colours.
87
+ * Minimum recommended value: 0.3.
88
+ */
89
+ inactiveOpacity?: number;
90
+ /** CSS transition duration in ms for style changes. Default: 80 */
91
+ transitionMs?: number;
92
+ /** Speech rate (0.1–10). Used only by startSpeechType. Default: 0.9 */
93
+ rate?: number;
94
+ /** Speech pitch (0–2). Used only by startSpeechType. Default: 1 */
95
+ pitch?: number;
96
+ /** Speech volume (0–1). Used only by startSpeechType. Default: 1 */
97
+ volume?: number;
98
+ /**
99
+ * Called when speech synthesis is unavailable in the current browser.
100
+ * Used only by startSpeechType.
101
+ */
102
+ onUnsupported?: () => void;
103
+ /**
104
+ * Called when a real speech error occurs (any error code other than "interrupted",
105
+ * which is a normal cancellation). Receives the SpeechSynthesisErrorEvent.
106
+ * Used only by startSpeechType.
107
+ */
108
+ onError?: (event: SpeechSynthesisErrorEvent) => void;
109
+ }
110
+
111
+ /**
112
+ * Renders children inside the given tag, wraps words in spans, and emphasises
113
+ * the word at activeWordIndex with wider tracking, heavier weight, and larger opsz.
114
+ * Forwards a ref to the underlying DOM element for imperative startSpeechType access.
115
+ * All aria-*, data-*, role, lang, and other HTML attributes are forwarded to the DOM element.
116
+ */
117
+ export declare const SpeechTypeText: ForwardRefExoticComponent<SpeechTypeTextProps & RefAttributes<HTMLElement>>;
118
+
119
+ /** Props accepted by SpeechTypeText */
120
+ declare interface SpeechTypeTextProps extends SpeechTypeOptions, HTMLForwardProps {
121
+ /** Index of the word currently being spoken (-1 = no active word). Default: -1 */
122
+ activeWordIndex: number;
123
+ /** HTML element tag to render. Default: 'p' */
124
+ as?: ElementType;
125
+ /** React children (text content to highlight) */
126
+ children: React.ReactNode;
127
+ /** Inline styles forwarded to the rendered element */
128
+ style?: React.CSSProperties;
129
+ /** Class name forwarded to the rendered element */
130
+ className?: string;
131
+ }
132
+
133
+ /**
134
+ * Start speech synthesis on el's text content, syncing word emphasis to Web Speech API
135
+ * boundary events. Calls prepareSpeechType first. Cancels any existing speech.
136
+ * Returns a stop() function that cancels speech and resets all styles.
137
+ *
138
+ * @param el - Element to speak and highlight
139
+ * @param options - SpeechTypeOptions (merged with defaults)
140
+ */
141
+ export declare function startSpeechType(el: HTMLElement, options?: SpeechTypeOptions): () => void;
142
+
143
+ /**
144
+ * Prepare word spans on mount and apply emphasis when activeWordIndex changes.
145
+ * Cleans up by restoring original innerHTML on unmount.
146
+ *
147
+ * @param ref - Ref to the element containing text to highlight
148
+ * @param activeWordIndex - Index of the currently active word (-1 = none)
149
+ * @param options - SpeechTypeOptions (merged with defaults)
150
+ */
151
+ export declare function useSpeechType(ref: RefObject<HTMLElement | null>, activeWordIndex: number, options?: SpeechTypeOptions): void;
152
+
153
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ import { useEffect as x, forwardRef as C, useRef as E, useImperativeHandle as H } from "react";
2
+ import { jsx as $ } from "react/jsx-runtime";
3
+ const T = {
4
+ word: "st-word"
5
+ }, w = /* @__PURE__ */ new WeakMap();
6
+ function M(t, e = {}) {
7
+ if (typeof window > "u") return [];
8
+ const r = window.scrollY, n = w.get(t), l = (n == null ? void 0 : n.originalHTML) ?? t.innerHTML;
9
+ t.innerHTML = l;
10
+ const f = (t.textContent ?? "").split(/(\s+)/);
11
+ t.innerHTML = f.map((i) => !i || /^\s+$/.test(i) ? i : `<span class="${T.word}" aria-hidden="true">${i}</span>`).join("");
12
+ const u = Array.from(t.querySelectorAll(`.${T.word}`)), o = e.transitionMs ?? 80;
13
+ u.forEach((i) => {
14
+ i.style.display = "inline", i.style.transition = [
15
+ `font-variation-settings ${o}ms ease`,
16
+ `letter-spacing ${o}ms ease`,
17
+ `opacity ${o}ms ease`
18
+ ].join(", ");
19
+ });
20
+ let c = t.querySelector("[data-st-live]");
21
+ return c || (c = document.createElement("span"), c.setAttribute("data-st-live", ""), c.setAttribute("aria-live", "polite"), c.setAttribute("aria-atomic", "true"), c.style.cssText = "position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0", t.appendChild(c)), w.set(t, { originalHTML: l, wordSpans: u, utterance: null, activeIndex: -1 }), requestAnimationFrame(() => {
22
+ Math.abs(window.scrollY - r) > 2 && window.scrollTo({ top: r, behavior: "instant" });
23
+ }), u;
24
+ }
25
+ function v(t, e, r = {}) {
26
+ var c;
27
+ if (typeof window > "u") return;
28
+ const n = w.get(t);
29
+ if (!n) return;
30
+ const l = r.activeTracking ?? 0.06, s = r.activeWeight ?? 700, f = r.activeOpsz ?? 24, u = r.inactiveOpacity ?? 0.45;
31
+ n.activeIndex = e;
32
+ const o = t.querySelector("[data-st-live]");
33
+ o && (o.textContent = e >= 0 ? ((c = n.wordSpans[e]) == null ? void 0 : c.textContent) ?? "" : ""), n.wordSpans.forEach((i, y) => {
34
+ y === e ? (i.setAttribute("aria-current", "true"), i.style.fontVariationSettings = `"wght" ${s}, "opsz" ${f}`, i.style.letterSpacing = `${l}em`, i.style.opacity = "1") : (i.removeAttribute("aria-current"), i.style.fontVariationSettings = "", i.style.letterSpacing = "", i.style.opacity = e === -1 ? "1" : String(u));
35
+ });
36
+ }
37
+ function R(t, e = {}) {
38
+ var g;
39
+ if (typeof window > "u" || !("speechSynthesis" in window))
40
+ return (g = e.onUnsupported) == null || g.call(e), () => {
41
+ };
42
+ const r = M(t, e), n = w.get(t);
43
+ if (!n) return () => {
44
+ };
45
+ window.speechSynthesis.cancel();
46
+ const l = r.map((a) => a.textContent ?? "").join(" "), s = new SpeechSynthesisUtterance(l);
47
+ s.rate = e.rate ?? 0.9, s.pitch = e.pitch ?? 1, s.volume = e.volume ?? 1;
48
+ const f = e.activeTracking ?? 0.06, u = e.activeWeight ?? 700, o = e.activeOpsz ?? 24, c = { ...e, activeTracking: f, activeWeight: u, activeOpsz: o };
49
+ let i = 0;
50
+ const y = r.map((a) => {
51
+ var h;
52
+ const d = i;
53
+ return i += (((h = a.textContent) == null ? void 0 : h.length) ?? 0) + 1, d;
54
+ });
55
+ let p = !1;
56
+ return s.onboundary = (a) => {
57
+ if (p || a.name !== "word") return;
58
+ const d = y.findIndex((h, m) => {
59
+ const S = y[m + 1] ?? 1 / 0;
60
+ return a.charIndex >= h && a.charIndex < S;
61
+ });
62
+ d !== -1 && v(t, d, c);
63
+ }, s.onend = () => {
64
+ p || (v(t, -1, c), n.utterance = null);
65
+ }, s.onerror = (a) => {
66
+ var d;
67
+ a.error !== "interrupted" && ((d = e.onError) == null || d.call(e, a)), p || (v(t, -1, c), n.utterance = null);
68
+ }, n.utterance = s, window.speechSynthesis.speak(s), () => {
69
+ p || (p = !0, window.speechSynthesis.cancel(), v(t, -1, c), n.utterance = null);
70
+ };
71
+ }
72
+ function b(t) {
73
+ const e = w.get(t);
74
+ e && (typeof window < "u" && "speechSynthesis" in window && window.speechSynthesis.cancel(), t.innerHTML = e.originalHTML, w.delete(t));
75
+ }
76
+ function j(t) {
77
+ const e = t.cloneNode(!0);
78
+ return e.querySelectorAll(`.${T.word}`).forEach((r) => {
79
+ const n = r.parentNode;
80
+ if (n) {
81
+ for (; r.firstChild; ) n.insertBefore(r.firstChild, r);
82
+ n.removeChild(r);
83
+ }
84
+ }), e.innerHTML;
85
+ }
86
+ function A(t, e, r) {
87
+ x(() => {
88
+ const n = t.current;
89
+ if (n)
90
+ return M(n, r), () => b(n);
91
+ }, [r == null ? void 0 : r.transitionMs]), x(() => {
92
+ const n = t.current;
93
+ n && v(n, e, r);
94
+ }, [e]);
95
+ }
96
+ const k = C(
97
+ function({
98
+ activeWordIndex: e,
99
+ as: r = "p",
100
+ children: n,
101
+ style: l,
102
+ className: s,
103
+ // Extract SpeechTypeOptions fields explicitly so they are NOT forwarded to the DOM
104
+ activeTracking: f,
105
+ activeWeight: u,
106
+ activeOpsz: o,
107
+ inactiveOpacity: c,
108
+ transitionMs: i,
109
+ rate: y,
110
+ pitch: p,
111
+ volume: g,
112
+ onUnsupported: a,
113
+ onError: d,
114
+ // Remaining props (aria-*, data-*, role, lang, etc.) are forwarded to the DOM element
115
+ ...h
116
+ }, m) {
117
+ const S = E(null);
118
+ return H(m, () => S.current), A(S, e, {
119
+ activeTracking: f,
120
+ activeWeight: u,
121
+ activeOpsz: o,
122
+ inactiveOpacity: c,
123
+ transitionMs: i
124
+ }), /* @__PURE__ */ $(r, { ref: S, style: l, className: s, ...h, children: n });
125
+ }
126
+ );
127
+ export {
128
+ T as SPEECH_CLASSES,
129
+ k as SpeechTypeText,
130
+ v as applySpeechType,
131
+ j as getCleanHTML,
132
+ M as prepareSpeechType,
133
+ b as removeSpeechType,
134
+ R as startSpeechType,
135
+ A as useSpeechType
136
+ };
@@ -0,0 +1 @@
1
+ var SpeechType=(function(d){"use strict";const k={word:"st-word"},f=new WeakMap;function O(e,t={}){if(typeof window>"u")return[];const i=window.scrollY,n=f.get(e),p=(n==null?void 0:n.originalHTML)??e.innerHTML;e.innerHTML=p;const y=(e.textContent??"").split(/(\s+)/);e.innerHTML=y.map(s=>!s||/^\s+$/.test(s)?s:`<span class="${k.word}" aria-hidden="true">${s}</span>`).join("");const u=Array.from(e.querySelectorAll(`.${k.word}`)),o=t.transitionMs??80;u.forEach(s=>{s.style.display="inline",s.style.transition=[`font-variation-settings ${o}ms ease`,`letter-spacing ${o}ms ease`,`opacity ${o}ms ease`].join(", ")});let r=e.querySelector("[data-st-live]");return r||(r=document.createElement("span"),r.setAttribute("data-st-live",""),r.setAttribute("aria-live","polite"),r.setAttribute("aria-atomic","true"),r.style.cssText="position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0",e.appendChild(r)),f.set(e,{originalHTML:p,wordSpans:u,utterance:null,activeIndex:-1}),requestAnimationFrame(()=>{Math.abs(window.scrollY-i)>2&&window.scrollTo({top:i,behavior:"instant"})}),u}function w(e,t,i={}){var r;if(typeof window>"u")return;const n=f.get(e);if(!n)return;const p=i.activeTracking??.06,a=i.activeWeight??700,y=i.activeOpsz??24,u=i.inactiveOpacity??.45;n.activeIndex=t;const o=e.querySelector("[data-st-live]");o&&(o.textContent=t>=0?((r=n.wordSpans[t])==null?void 0:r.textContent)??"":""),n.wordSpans.forEach((s,S)=>{S===t?(s.setAttribute("aria-current","true"),s.style.fontVariationSettings=`"wght" ${a}, "opsz" ${y}`,s.style.letterSpacing=`${p}em`,s.style.opacity="1"):(s.removeAttribute("aria-current"),s.style.fontVariationSettings="",s.style.letterSpacing="",s.style.opacity=t===-1?"1":String(u))})}function C(e,t={}){var A;if(typeof window>"u"||!("speechSynthesis"in window))return(A=t.onUnsupported)==null||A.call(t),()=>{};const i=O(e,t),n=f.get(e);if(!n)return()=>{};window.speechSynthesis.cancel();const p=i.map(c=>c.textContent??"").join(" "),a=new SpeechSynthesisUtterance(p);a.rate=t.rate??.9,a.pitch=t.pitch??1,a.volume=t.volume??1;const y=t.activeTracking??.06,u=t.activeWeight??700,o=t.activeOpsz??24,r={...t,activeTracking:y,activeWeight:u,activeOpsz:o};let s=0;const S=i.map(c=>{var m;const l=s;return s+=(((m=c.textContent)==null?void 0:m.length)??0)+1,l});let v=!1;return a.onboundary=c=>{if(v||c.name!=="word")return;const l=S.findIndex((m,F)=>{const P=S[F+1]??1/0;return c.charIndex>=m&&c.charIndex<P});l!==-1&&w(e,l,r)},a.onend=()=>{v||(w(e,-1,r),n.utterance=null)},a.onerror=c=>{var l;c.error!=="interrupted"&&((l=t.onError)==null||l.call(t,c)),v||(w(e,-1,r),n.utterance=null)},n.utterance=a,window.speechSynthesis.speak(a),()=>{v||(v=!0,window.speechSynthesis.cancel(),w(e,-1,r),n.utterance=null)}}function E(e){const t=f.get(e);t&&(typeof window<"u"&&"speechSynthesis"in window&&window.speechSynthesis.cancel(),e.innerHTML=t.originalHTML,f.delete(e))}const L="data-speechtype",H="false",h=new WeakMap;function b(e){const t=e.dataset,i={};if(t.stTracking!==void 0){const n=parseFloat(t.stTracking);isNaN(n)||(i.activeTracking=n)}if(t.stWeight!==void 0){const n=parseFloat(t.stWeight);isNaN(n)||(i.activeWeight=n)}if(t.stOpsz!==void 0){const n=parseFloat(t.stOpsz);isNaN(n)||(i.activeOpsz=n)}if(t.stInactiveOpacity!==void 0){const n=parseFloat(t.stInactiveOpacity);isNaN(n)||(i.inactiveOpacity=n)}if(t.stTransition!==void 0){const n=parseFloat(t.stTransition);isNaN(n)||(i.transitionMs=n)}if(t.stRate!==void 0){const n=parseFloat(t.stRate);isNaN(n)||(i.rate=n)}if(t.stPitch!==void 0){const n=parseFloat(t.stPitch);isNaN(n)||(i.pitch=n)}if(t.stVolume!==void 0){const n=parseFloat(t.stVolume);isNaN(n)||(i.volume=n)}return i.onUnsupported=()=>{console.warn("SpeechType: this browser does not support the Web Speech API — nothing will be spoken.")},i}function T(e){const t=h.get(e);if(t){if(t.stop){N(e);return}t.stop=C(e,b(e))}}function N(e){const t=h.get(e);!t||!t.stop||(t.stop(),t.stop=null)}function $(e){N(e),T(e)}function I(e){M(e),O(e,b(e));let t=null;e.dataset.stClick!==H&&(t=()=>T(e),e.addEventListener("click",t),e.style.cursor="pointer"),h.set(e,{stop:null,clickHandler:t})}function M(e){const t=h.get(e);t&&(t.stop&&t.stop(),t.clickHandler&&(e.removeEventListener("click",t.clickHandler),e.style.cursor=""),E(e),h.delete(e))}function g(e=document){e.querySelectorAll(`[${L}]`).forEach(I)}function W(){const e=()=>{var t;(t=document.fonts)!=null&&t.ready?document.fonts.ready.then(()=>g()).catch(()=>g()):g()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}return W(),d.destroy=M,d.init=g,d.restart=$,d.speak=T,d.stop=N,Object.defineProperty(d,Symbol.toStringTag,{value:"Module"}),d})({});
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@overpunch/speechtype",
3
+ "version": "1.0.11",
4
+ "description": "Typography that follows your voice — per-word typographic emphasis synced to Web Speech API boundary events",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build": "vite build",
21
+ "build:webflow": "vite build --config vite.webflow.config.ts",
22
+ "test": "vitest run",
23
+ "typecheck": "tsc --noEmit",
24
+ "prepublishOnly": "npm run test && npm run build && npm run build:webflow"
25
+ },
26
+ "peerDependencies": {
27
+ "react": ">=17",
28
+ "react-dom": ">=17"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "react": {
32
+ "optional": true
33
+ },
34
+ "react-dom": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "devDependencies": {
39
+ "@testing-library/react": "^16.3.2",
40
+ "@testing-library/user-event": "^14.6.1",
41
+ "@types/react": "^19.0.0",
42
+ "@vitejs/plugin-react": "^4.0.0",
43
+ "happy-dom": "^20.10.6",
44
+ "next": "16.2.2",
45
+ "react": "^19.0.0",
46
+ "typescript": "^5.0.0",
47
+ "vite": "^6.0.0",
48
+ "vite-plugin-dts": "^4.0.0",
49
+ "vitest": "^3.0.0"
50
+ },
51
+ "keywords": [
52
+ "typography",
53
+ "web-typography",
54
+ "frontend",
55
+ "liiift-studio",
56
+ "speech-synthesis",
57
+ "web-speech-api",
58
+ "tts",
59
+ "text-to-speech",
60
+ "voice",
61
+ "karaoke",
62
+ "word-highlight",
63
+ "accessibility",
64
+ "a11y",
65
+ "language-learning",
66
+ "teleprompter",
67
+ "variable-font",
68
+ "font-variation-settings",
69
+ "wght",
70
+ "opsz",
71
+ "letter-spacing",
72
+ "zero-dependencies",
73
+ "react",
74
+ "typescript",
75
+ "css"
76
+ ],
77
+ "author": "Quinn Keaveney <quinn@liiift.studio>",
78
+ "license": "MIT",
79
+ "homepage": "https://speechtype.vercel.app",
80
+ "repository": {
81
+ "type": "git",
82
+ "url": "git+https://github.com/over-punch/speechType.git"
83
+ },
84
+ "bugs": {
85
+ "url": "https://github.com/over-punch/speechType/issues"
86
+ },
87
+ "sideEffects": false,
88
+ "publishConfig": {
89
+ "access": "public"
90
+ }
91
+ }