@overpunch/speechtype 1.0.11 → 1.1.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/README.md CHANGED
@@ -1,258 +1,271 @@
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.
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 (`@overpunch/speechtype/core` without React)
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 { useEffect, useRef } from 'react'
77
+ import { startSpeechType, removeSpeechType } from '@overpunch/speechtype/core'
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
+ // Stop speaking and restore the paragraph when the component unmounts.
98
+ useEffect(() => {
99
+ const el = ref.current
100
+ return () => {
101
+ stopRef.current?.()
102
+ if (el) removeSpeechType(el)
103
+ }
104
+ }, [])
105
+
106
+ return (
107
+ <>
108
+ <p ref={ref}>The quick brown fox jumps over the lazy dog.</p>
109
+ <button onClick={handleSpeak}>Speak</button>
110
+ <button onClick={handleStop}>Stop</button>
111
+ </>
112
+ )
113
+ }
114
+ ```
115
+
116
+ ### React hook
117
+
118
+ `useSpeechType` is the low-level hook behind `SpeechTypeText`. Use it when you need the controlled pattern but want to render your own element:
119
+
120
+ ```tsx
121
+ "use client"
122
+ import { useSpeechType } from '@overpunch/speechtype'
123
+ import { useRef, useState, useCallback } from 'react'
124
+
125
+ export default function Demo() {
126
+ const ref = useRef<HTMLParagraphElement>(null)
127
+ const [activeWordIndex, setActiveWordIndex] = useState(-1)
128
+
129
+ useSpeechType(ref, activeWordIndex, { activeWeight: 700 })
130
+
131
+ return <p ref={ref}>The quick brown fox jumps over the lazy dog.</p>
132
+ }
133
+ ```
134
+
135
+ ### Vanilla JS
136
+
137
+ `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.
138
+
139
+ ```ts
140
+ import { startSpeechType, removeSpeechType } from '@overpunch/speechtype/core'
141
+
142
+ const el = document.querySelector('p')
143
+ const stop = startSpeechType(el, {
144
+ activeWeight: 700,
145
+ activeTracking: 0.06,
146
+ rate: 0.9,
147
+ })
148
+
149
+ // Later — stop speech and restore original HTML:
150
+ stop()
151
+ removeSpeechType(el)
152
+ ```
153
+
154
+ For more control, use the lower-level functions:
155
+
156
+ ```ts
157
+ import { prepareSpeechType, applySpeechType, removeSpeechType } from '@overpunch/speechtype/core'
158
+
159
+ const el = document.querySelector('p')
160
+ prepareSpeechType(el) // wraps each word in a span
161
+
162
+ applySpeechType(el, 3) // emphasise word at index 3
163
+ applySpeechType(el, -1) // clear emphasis
164
+
165
+ removeSpeechType(el) // restore original HTML
166
+ ```
167
+
168
+ ### TypeScript
169
+
170
+ ```ts
171
+ import type { SpeechTypeOptions } from '@overpunch/speechtype'
172
+
173
+ const opts: SpeechTypeOptions = {
174
+ activeTracking: 0.08,
175
+ activeWeight: 800,
176
+ inactiveOpacity: 0.3,
177
+ rate: 0.85,
178
+ }
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Options
184
+
185
+ Visual options apply everywhere; speech options are only read by `startSpeechType` (see the note under [React component](#react-component-controlled)).
186
+
187
+ | Option | Type | Default | Scope | Description |
188
+ |--------|------|---------|-------|-------------|
189
+ | `activeTracking` | `number` | `0.06` | visual | Letter-spacing on the active (currently spoken) word, in em |
190
+ | `activeWeight` | `number` | +300 | visual | `wght` axis value on the active word. Unset, the word's own weight plus 300 (400 → 700, 700 → 1000). Must sit within the font's `wght` axis range |
191
+ | `activeOpsz` | `number` | ×1.5 | visual | `opsz` axis value on the active word. Unset, 1.5× the word's own optical size (its font size in px, so 16px text → 24). Must sit within the font's `opsz` axis range |
192
+ | `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 |
193
+ | `transitionMs` | `number` | `80` | visual | CSS transition duration in ms for style changes |
194
+ | `rate` | `number` | `0.9` | speech | Speech rate (0.1–10). Passed to `SpeechSynthesisUtterance` |
195
+ | `pitch` | `number` | `1` | speech | Speech pitch (0–2). Passed to `SpeechSynthesisUtterance` |
196
+ | `volume` | `number` | `1` | speech | Speech volume (0–1). Passed to `SpeechSynthesisUtterance` |
197
+ | `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) |
198
+ | `onError` | `(e: SpeechSynthesisErrorEvent) => void` | — | speech | Called on a real speech error. Cancellations (`"interrupted"`, and `"canceled"` caused by speechType) are filtered out for you |
199
+ | `onEnd` | `() => void` | — | speech | Called when a run ends: finished, stopped, replaced by a newer run, or failed |
200
+ | `lang` | `string` | element's `lang` | speech | Language of the speech (BCP 47). Default: the element's own `lang` (nearest ancestor), else the document's; a voice for it is picked when the browser has one |
201
+ | `voice` | `SpeechSynthesisVoice \| string` | — | speech | A voice, or a voice name / voiceURI |
202
+
203
+ ---
204
+
205
+ ## How it works
206
+
207
+ `prepareSpeechType` wraps each visible word of the element in a `<span class="st-word">`, in place: links, `<br>`, images, form fields, ids and event listeners are kept, and hidden text (`hidden`, `aria-hidden="true"`, `display: none`), styles, scripts, text areas and SVG are left alone and not spoken. Text stays text (escaped content is never turned into HTML). `applySpeechType` then writes `font-variation-settings`, `letter-spacing`, and `opacity` as inline styles on each span. The active word gets wider tracking, heavier weight, and larger optical size around its own values (its other axes and italics are kept); inactive words get reduced opacity. A word split by markup (`Split<em>ting</em>`) is one spoken word. Under `prefers-reduced-motion` the transitions are off.
208
+
209
+ `startSpeechType` wires a `SpeechSynthesisUtterance` (in the element's language) to the browser's Web Speech API, listens for `boundary` events, maps the character offset to a word, and emphasises it. It returns a `stop` function that stops this run and removes the emphasis. speechType only cancels speech it started: a stale `stop()`, removing an idle element or unmounting never cancels other speech on the page. In Chrome it keeps long text going past Chrome's ~15-second cutoff, and if speech stops without an end event the emphasis is cleared.
210
+
211
+ **Layout:** the emphasis makes the active word wider, so in a narrow column a line can rewrap while it is spoken (in a 240px column, 10 of 34 words moved a line break). Set `activeTracking: 0`, or use more width, if that matters.
212
+
213
+ **Browser support:** Web Speech API is supported in Chrome, Edge, Safari and Firefox (voices depend on the system). 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:
214
+
215
+ ```ts
216
+ startSpeechType(el, {
217
+ onUnsupported: () => showManualStepper(), // no Web Speech API here
218
+ onError: (e) => console.warn('Speech failed', e.error),
219
+ })
220
+ ```
221
+
222
+ ---
223
+
224
+ ## Accessibility
225
+
226
+ speechType is built for read-along contexts, so it ships screen-reader support rather than leaving it to you:
227
+
228
+ - The text stays exactly as it was for screen readers: words are wrapped in plain spans (nothing is hidden, nothing is announced on top of the speech), links and headings keep their names, and the active word gets `aria-current="true"`.
229
+ - All emphasis is plain CSS (`font-variation-settings`, `letter-spacing`, `opacity`) — no content is duplicated or reordered.
230
+
231
+ Two trade-offs to design around:
232
+
233
+ - **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.
234
+ - **The speech engine is shared.** A page has one `speechSynthesis`; starting speechType stops speech already playing.
235
+
236
+ ---
237
+
238
+ ## API reference
239
+
240
+ | Export | Description |
241
+ |--------|-------------|
242
+ | `prepareSpeechType(el, options?)` | Wraps each word in a span. Call once before `applySpeechType`. |
243
+ | `applySpeechType(el, activeIndex, options?)` | Emphasises word at `activeIndex`. Pass `-1` to clear. |
244
+ | `startSpeechType(el, options?)` | All-in-one: prepares spans, starts Web Speech API, returns `stop()`. |
245
+ | `removeSpeechType(el)` | Stops this element's speech (if it is speaking) and puts the original text back. |
246
+ | `getCleanHTML(el)` | Returns the element's original HTML. |
247
+ | `useSpeechType` | React hook: `(ref, activeWordIndex, options?)` |
248
+ | `SpeechTypeText` | React component. Controlled via `activeWordIndex` prop. Forwards ref. |
249
+ | `SpeechTypeOptions` | TypeScript interface for all options. |
250
+ | `SPEECH_CLASSES` | CSS class names injected by the algorithm (`st-word`). |
251
+
252
+ ---
253
+
254
+ ## Next.js
255
+
256
+ `SpeechTypeText`, `useSpeechType`, and `startSpeechType` all require a browser environment. Add `"use client"` to any component that imports them:
257
+
258
+ ```tsx
259
+ "use client"
260
+ import { SpeechTypeText } from '@overpunch/speechtype'
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Dev notes
266
+
267
+ ### `next` in root devDependencies
268
+
269
+ `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.
270
+
271
+ The package itself has zero runtime dependencies. Do not remove this entry.
package/dist/core.cjs ADDED
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const L={word:"st-word"},k=new Set(["SCRIPT","STYLE","TEXTAREA","NOSCRIPT","TEMPLATE","SVG","MATH","SELECT","OPTION","CANVAS","IFRAME","OBJECT","VIDEO","AUDIO","INPUT","BUTTON"]),R=new Set(["BR","HR","P","DIV","LI","UL","OL","DL","DT","DD","H1","H2","H3","H4","H5","H6","BLOCKQUOTE","PRE","SECTION","ARTICLE","ASIDE","HEADER","FOOTER","NAV","FIGURE","FIGCAPTION","TABLE","TR","TD","TH","CAPTION","IMG","ADDRESS","MAIN","DETAILS","SUMMARY"]),U=/[฀-໿က-႟ក-៿぀-ヿ㐀-䶿一-鿿豈-﫿]/,F=80,V=1e4,_=1e3,D=typeof Intl<"u"&&"Segmenter"in Intl?new Intl.Segmenter(void 0,{granularity:"word"}):null,P=new Set;function b(t){P.has(t)||(P.add(t),console.warn(t))}function E(t,e,r,n,s){return t===void 0?e:typeof t=="number"&&Number.isFinite(t)?Math.min(n,Math.max(r,t)):(b(`[speechType] ${s} must be a finite number; got ${String(t)}, using ${e}`),e)}function z(){var t,e;return typeof window<"u"&&!!((e=(t=window.matchMedia)==null?void 0:t.call(window,"(prefers-reduced-motion: reduce)"))!=null&&e.matches)}const A=new WeakMap;function B(t){if(t.getAttribute("aria-hidden")==="true"||t.hasAttribute("hidden"))return!0;if(!t.isConnected)return!1;const e=getComputedStyle(t);return e.display==="none"||e.visibility==="hidden"}function x(t){for(const e of t.wrapped){const r=e.produced.find(n=>n.parentNode);r!=null&&r.parentNode&&r.parentNode.insertBefore(e.original,r),e.produced.forEach(n=>{var s;return(s=n.parentNode)==null?void 0:s.removeChild(n)})}}function $(t,e={}){var y;if(typeof window>"u"||!t)return[];const r=A.get(t);r&&((y=r.run)==null||y.stop(),x(r),A.delete(t));const n=t.innerHTML,s=[],o=[],u=[],i=[];let a="",p=-1,h=!0;const g=c=>{Array.from(c.childNodes).forEach(f=>{if(f.nodeType===Node.TEXT_NODE){O(f);return}if(f.nodeType!==Node.ELEMENT_NODE)return;const d=f,l=d.nodeName.toUpperCase();R.has(l)&&(h=!0),!(k.has(l)||d.isContentEditable||B(d))&&(g(d),R.has(l)&&(h=!0))})},O=c=>{const f=c.data;if(!f||!c.parentNode)return;if(!/\S/.test(f)){h=!0;return}const d=[];for(const m of f.split(/(\s+)/)){if(!m)continue;if(/^\s+$/.test(m)){d.push(document.createTextNode(m)),h=!0;continue}(D&&U.test(m)?Array.from(D.segment(m),C=>C.segment):[m]).forEach((C,w)=>{(h||w>0)&&(a&&(a+=" "),p++,i[p]=a.length),h=!1;const T=document.createElement("span");T.className=L.word,T.textContent=C,d.push(T),o.push(T),u.push(p),a+=C})}const l=document.createDocumentFragment();d.forEach(m=>l.appendChild(m)),c.parentNode.replaceChild(l,c),s.push({original:c,produced:d})};g(t);const S=E(e.transitionMs,80,0,1e4,"transitionMs");return S>0&&!z()&&o.forEach(c=>{c.style.transition=`font-variation-settings ${S}ms ease, letter-spacing ${S}ms ease, opacity ${S}ms ease`}),A.set(t,{originalHTML:n,wrapped:s,wordSpans:o,wordOf:u,text:a,wordStart:i,run:null,activeIndex:-1}),o}function G(t){if(!t||t==="normal")return[];const e=[];for(const r of t.matchAll(/["']([^"']{4})["']\s+(-?[\d.]+(?:e[+-]?\d+)?)/gi))e.push([r[1],parseFloat(r[2])]);return e}function W(t,e){var y;const r=t.parentElement??t,n=getComputedStyle(r),s=G(((y=n.getPropertyValue)==null?void 0:y.call(n,"font-variation-settings"))||n.fontVariationSettings||""),o=c=>{var f;return(f=s.find(([d])=>d===c))==null?void 0:f[1]},u=parseFloat(n.fontSize)||16,i=o("wght")??(parseFloat(n.fontWeight)||400),a=o("opsz")??u,p=E(e.activeWeight,Math.min(1e3,i+300),1,1e3,"activeWeight"),h=E(e.activeOpsz,a*1.5,1,1e3,"activeOpsz"),g=E(e.activeTracking,.06,-1,1,"activeTracking"),O=s.filter(([c])=>c!=="wght"&&c!=="opsz").map(([c,f])=>`"${c}" ${f}`),S=parseFloat(n.letterSpacing)||0;return{fvs:[...O,`"wght" ${+p.toFixed(1)}`,`"opsz" ${+h.toFixed(1)}`].join(", "),ls:S?`calc(${S}px + ${g}em)`:`${g}em`}}function M(t,e,r={}){if(typeof window>"u")return;const n=A.get(t);if(!n)return;const s=Number.isInteger(e)&&e>=0&&e<n.wordSpans.length?e:-1,o=E(r.inactiveOpacity,.45,0,1,"inactiveOpacity"),u=s>=0?n.wordOf[s]:-1;n.activeIndex=s,n.wordSpans.forEach((i,a)=>{if(u>=0&&n.wordOf[a]===u){const p=W(i,r);i.setAttribute("aria-current","true"),i.style.fontVariationSettings=p.fvs,i.style.letterSpacing=p.ls,i.style.opacity="1"}else i.removeAttribute("aria-current"),i.style.fontVariationSettings="",i.style.letterSpacing="",i.style.opacity=u===-1?"1":String(o)})}let I=null;function K(t,e){var s,o;const r=((o=(s=window.speechSynthesis).getVoices)==null?void 0:o.call(s))??[];if(e&&typeof e=="object")return e;if(typeof e=="string"){const u=r.find(i=>i.name===e||i.voiceURI===e);if(u)return u;b(`[speechType] no voice named ${JSON.stringify(e)}; using the language's default`)}if(!t)return null;const n=t.toLowerCase();return r.find(u=>u.lang.toLowerCase()===n)??r.find(u=>u.lang.toLowerCase().split("-")[0]===n.split("-")[0])??null}function j(t,e={}){var v,C;if(typeof window>"u"||!t||!("speechSynthesis"in window)||typeof SpeechSynthesisUtterance>"u")return(v=e.onUnsupported)==null||v.call(e),()=>{};const r=E(e.rate,.9,.1,10,"rate"),n=E(e.pitch,1,0,2,"pitch"),s=E(e.volume,1,0,1,"volume"),o=window.speechSynthesis,u=o.speaking||o.pending;$(t,e);const i=A.get(t);if(!i||!i.text)return()=>{};const a=new SpeechSynthesisUtterance(i.text);a.rate=r,a.pitch=n,a.volume=s;const p=e.lang??((C=t.closest("[lang]"))==null?void 0:C.lang)??document.documentElement.lang??"";p&&(a.lang=p);const h=K(p,e.voice);h&&(a.voice=h);let g=!1,O=!1,S=null,y=null,c=null,f=!1;const d=()=>{var w;g||(g=!0,S&&clearInterval(S),y&&clearInterval(y),c&&clearTimeout(c),I===l&&(I=null),i.run===l&&(i.run=null),M(t,-1,e),(w=e.onEnd)==null||w.call(e))};a.onstart=()=>{f=!0},a.onboundary=w=>{if(g||w.name!=="word")return;f=!0;let T=-1;for(let N=0;N<i.wordStart.length&&i.wordStart[N]<=w.charIndex;N++)T=N;const H=i.wordOf.indexOf(T);H!==-1&&M(t,H,e)},a.onend=()=>d(),a.onerror=w=>{var T;w.error!=="interrupted"&&!(O&&w.error==="canceled")&&((T=e.onError)==null||T.call(e,w)),d()};const l={utterance:a,stop:()=>{g||(I===l&&(O=!0,o.cancel()),d())}};I?I.stop():u&&o.cancel(),I=l,i.run=l;const m=()=>{c=null,!g&&(o.speak(a),/Chrome\//.test(navigator.userAgent)&&(S=setInterval(()=>{o.speaking&&!o.paused&&(o.pause(),o.resume())},V)),y=setInterval(()=>{f&&!o.speaking&&!o.pending&&d()},_))};return u?c=setTimeout(m,F):m(),l.stop}function Y(t){var r;const e=A.get(t);e&&((r=e.run)==null||r.stop(),M(t,-1),x(e),A.delete(t))}function q(t){const e=A.get(t);if(e)return e.originalHTML;const r=t.cloneNode(!0);return r.querySelectorAll(`.${L.word}`).forEach(n=>{const s=n.parentNode;if(s){for(;n.firstChild;)s.insertBefore(n.firstChild,n);s.removeChild(n)}}),r.querySelectorAll("[data-st-live]").forEach(n=>n.remove()),r.normalize(),r.innerHTML}exports.SPEECH_CLASSES=L;exports.applySpeechType=M;exports.getCleanHTML=q;exports.prepareSpeechType=$;exports.removeSpeechType=Y;exports.startSpeechType=j;