Components & interactionsLive dictation transcript
Dictation draft
Provisional words revise themselves before settling into an editable serif draft.

Preview, pause and reset the incoming words, then edit the finished draft.
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import * as React from "react"
function useReduced() {
const [reduced, setReduced] = React.useState(true)
React.useEffect(() => {
const media = matchMedia("(prefers-reduced-motion: reduce)"),
update = () => setReduced(media.matches)
update()
media.addEventListener("change", update)
return () => media.removeEventListener("change", update)
}, [])
return reduced
}
const FRAMES = [
["", "The city"],
["", "The city is a quiet"],
["The city is", "a choir"],
["The city is a choir.", "Every window"],
["The city is a choir.", "Every window holds a note."],
["The city is a choir. Every window holds a note.", ""],
]
export default function Example({ active = true }: { active?: boolean }) {
const [step, setStep] = React.useState(0),
[running, setRunning] = React.useState(false),
[draft, setDraft] = React.useState("")
const reduced = useReduced(),
clock = React.useRef(0)
React.useEffect(() => {
if (!active || !running) return
let id = 0,
last = performance.now()
const tick = (now: number) => {
clock.current += now - last
last = now
if (clock.current > 850) {
clock.current = 0
setStep((s) => {
if (s >= FRAMES.length - 2) {
setRunning(false)
setDraft(FRAMES[5][0])
return 5
}
return s + 1
})
}
id = requestAnimationFrame(tick)
}
id = requestAnimationFrame(tick)
return () => cancelAnimationFrame(id)
}, [active, running])
return (
<div className="sf-dictation-draft" data-paused={!active || reduced}>
<section className="note">
<header>
<span>Draft</span>
<span className="mode">{step === 5 ? "Editable" : "Dictation preview"}</span>
</header>
{step === 5 ? (
<textarea
aria-label="Edit transcribed draft"
value={draft}
disabled={!active}
onChange={(e) => setDraft(e.target.value)}
/>
) : (
<p className="words">
<span>{FRAMES[step][0]} </span>
<span className="partial">
{FRAMES[step][1]}
<i aria-hidden />
</span>
</p>
)}
<footer>
<button
disabled={!active}
className="record"
onClick={() => {
if (running) {
setRunning(false)
return
}
if (step === 5) {
setStep(0)
setDraft("")
}
clock.current = 0
setRunning(true)
}}
>
<span aria-hidden>{running ? "Ⅱ" : "▷"}</span>
{running ? "Pause" : "Preview"}
</button>
<button
disabled={!active || (!step && !running)}
className="clear"
onClick={() => {
setRunning(false)
setStep(0)
setDraft("")
clock.current = 0
}}
>
Reset
</button>
</footer>
</section>
<style>{`.sf-dictation-draft{width:100%;height:100%;min-height:260px;box-sizing:border-box;display:grid;grid-template-columns:minmax(0,1fr);place-items:center;padding:16px;background:#f4f4f3;color:#191a1b;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;isolation:isolate;overflow:hidden;container-type:inline-size}.sf-dictation-draft *{box-sizing:border-box}.sf-dictation-draft button,.sf-dictation-draft input,.sf-dictation-draft select,.sf-dictation-draft textarea{font:inherit;color:inherit}.sf-dictation-draft button{cursor:pointer;touch-action:manipulation}.sf-dictation-draft button:disabled{cursor:default;opacity:.45}.sf-dictation-draft :focus-visible{outline:2px solid #2767dc;outline-offset:3px}.sf-dictation-draft input[type=range]{min-height:44px;accent-color:currentColor;min-width:0}.sf-dictation-draft .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.sf-dictation-draft[data-paused=true] *{animation-play-state:paused!important}@media(prefers-reduced-motion:reduce){.sf-dictation-draft *{animation:none!important;transition:none!important;scroll-behavior:auto!important}}.sf-dictation-draft{background:#d1b595;color:#35271c;padding:24px}.sf-dictation-draft .note{width:min(100%,420px);background:#f3e9d7;padding:24px 26px 15px;box-shadow:0 5px 13px #5d361920;position:relative}.sf-dictation-draft .note:before{content:'';position:absolute;left:0;right:0;top:0;height:4px;background:#ff5c19}.sf-dictation-draft header{display:flex;justify-content:space-between;font-size:11px;color:#7b6d5d}.sf-dictation-draft .mode{font-size:10px}.sf-dictation-draft .words,.sf-dictation-draft textarea{font:29px/1.25 Georgia,serif;letter-spacing:-.75px;height:123px;margin:27px 0 14px;width:100%;resize:none;border:0;background:transparent;padding:0}.sf-dictation-draft .partial{color:#8e715a;text-decoration:underline;text-decoration-color:#ee5c23;text-underline-offset:6px;text-decoration-thickness:1px}.sf-dictation-draft .partial i{display:inline-block;width:1px;height:25px;vertical-align:-3px;background:#ee5c23;margin-left:4px;animation:sf-dictate-caret 1s step-end infinite}.sf-dictation-draft footer{display:flex;justify-content:space-between;align-items:center}.sf-dictation-draft button{height:44px;border:0;font-size:12px;background:none}.sf-dictation-draft .record{display:flex;align-items:center;gap:9px}.sf-dictation-draft .record span{display:grid;place-items:center;background:#e75a22;color:#ffead0;border-radius:50%;width:27px;height:27px}.sf-dictation-draft .clear{color:#8d7761}@keyframes sf-dictate-caret{50%{opacity:0}}@container(max-width:330px){.sf-dictation-draft .note{padding:20px 18px 12px}.sf-dictation-draft .words,.sf-dictation-draft textarea{font-size:25px;height:118px;margin:20px 0 8px}}`}</style>
</div>
)
}
Where it works well
Avoid treating unconfirmed speech recognition as final text.
Reduced motion
The caret stops blinking; discrete text updates and draft editing remain available.
Things to know
Scripted partial and final transcription. No microphone or speech-recognition service.