The problem
A voice note is opaque. You cannot skim it, cannot search it, and cannot tell whether it is worth forty seconds until you have already spent them — and you cannot play it at all on a quiet train or in an open office.
Sweeping the transcript in time with playback fixes all of that at once. You can read ahead of the audio, skip on the strength of the first line, or read the note instead of hearing it. The sweep is what ties the two together: text alone would be a wall, and audio alone is unskimmable.
Use cases
- Voice messages in a chat thread
- Recorded meeting notes or memos with a stored transcript
- Any short audio where the words matter more than the voice
Not for: long recordings — a 300-word bubble is a wall of text, and this has no scrubbing. Not for music or ambience, where there is nothing to transcribe. Not as a substitute for real captions on video.
Installation
npm install motion lucide-reactThen copy VoiceTranscript.tsx. It depends only on @/lib/cn.
Usage
import VoiceTranscript from "@/patterns/voice-transcript/VoiceTranscript";
<VoiceTranscript
transcript="Hey! Just brewed a fresh cup of coffee…"
durationSeconds={4}
/>;Playback is simulated on a timer. Wiring it to a real HTMLAudioElement means
replacing the one interval with timeupdate and driving progress from
currentTime / duration — everything else already derives from progress.
API
Deliberately small: the content and its length, and the shape of the bars.
| Prop | Type | Default | Description |
|---|---|---|---|
transcript | string | a sample note | Split on spaces; each word reveals in turn. |
durationSeconds | number | 4 | Length of the note. |
waveform | readonly number[] | 36 bars | Bar heights in px. Any length — the sweep is proportional. |
className | string | — | Merged onto the root. |
Keyboard
| Key | Action |
|---|---|
Tab | Move between the transcript toggle and play/pause |
Enter / Space | Activate the focused control |
Both controls are buttons, so this is the browser's behaviour rather than anything this component adds. There is no scrubbing to operate — a real implementation would need a slider on the waveform.
Accessibility
The transcript ran away from the audio. The word reveal was a second interval keyed only on whether the bubble was open, so pausing playback left the words marching on and the transcript ended up describing audio that was not playing. The visible word count is now derived from playback progress, which makes the desync unrepresentable rather than merely fixed — and removes an effect that closed over the count it was incrementing.
The countdown is a role="progressbar" reporting seconds elapsed with an
aria-valuetext of "3 seconds remaining". The waveform is aria-hidden:
reading out thirty-six bars is noise, and the progressbar already carries the
position.
The transcript toggle carries aria-expanded and aria-controls. Unrevealed
words sit at low opacity rather than being absent from the DOM, so a screen
reader gets the whole transcript the moment the bubble opens instead of having
it dribbled out a word at a time — which is the right trade: someone reading
rather than listening wants the text, not the performance.
Every colour was hardcoded. The icon, the waveform bars, the play glyph
and the bubble's tail were all fixed hex values, so in dark mode the icon
turned near-invisible and the tail detached from the bubble it belonged to.
All of it is tokens or currentColor now.
Under prefers-reduced-motion the bubble cross-fades without rising or
scaling, words appear without the blur burning off, the icons cross-fade in
place, and the bars change colour instantly. Unrevealed words sit at 0.25
rather than 0.07, because without the motion cue the contrast has to do more
of the work.
Performance
One interval, at 50ms. Everything else — the visible word count, the seconds
remaining, which bars are lit — is derived from progress during render, so
there is a single source of truth and no second clock to fall out of step.
The bars are CSS transition-colors, not thirty-six Motion values. Motion
animating background-color on each bar meant thirty-six animation instances
for a property CSS transitions on its own.
The bubble is absolutely positioned, so opening it does not move the player. Unrevealed words hold their space at low opacity, which keeps the bubble at its final height from the first frame — otherwise it would grow line by line and drag its own tail up the screen.
Permanent will-change on four elements is gone.
Source
The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Pause, Play } from "lucide-react";
import { cn } from "@/lib/cn";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export interface VoiceTranscriptProps extends React.ComponentPropsWithoutRef<"div"> {
/** The words revealed in time with playback. */
transcript?: string;
/** Length of the note in seconds. @default 4 */
durationSeconds?: number;
/** Bar heights in px. Any length — the sweep is proportional. */
waveform?: readonly number[];
}
/* -------------------------------------------------------------------------- */
/* Motion — kept verbatim from the original */
/* -------------------------------------------------------------------------- */
const softSpring = {
type: "spring" as const,
stiffness: 290,
damping: 30,
mass: 1,
};
const layoutSpring = {
type: "spring" as const,
stiffness: 320,
damping: 28,
mass: 1,
};
const blurTransition = { duration: 0.22, ease: "easeOut" as const };
/** The reveal's ease. A strong out-curve, so each word lands rather than drifts. */
const WORD_EASE = [0.22, 1, 0.36, 1] as const;
const TICK_MS = 50;
const DEFAULT_WAVEFORM = [
3, 5, 9, 14, 20, 26, 18, 10, 22, 30, 24, 14, 8, 18, 28, 34, 26, 16, 10, 20,
30, 22, 12, 6, 16, 24, 18, 10, 4, 8, 14, 20, 26, 18, 8, 4,
];
const DEFAULT_TRANSCRIPT =
"Hey! Just brewed a fresh cup of coffee. Would you like to have some? I'm sure you'd love it ☕";
/* -------------------------------------------------------------------------- */
/* Icon */
/* -------------------------------------------------------------------------- */
/** Speech bubble with transcript lines. `currentColor` throughout, so it themes. */
function TranscribeIcon() {
return (
<svg
width="21"
height="21"
viewBox="0 0 21 21"
fill="none"
aria-hidden="true"
className="text-content"
>
<path
d="M2 3.5C2 2.67 2.67 2 3.5 2h14C18.33 2 19 2.67 19 3.5v10c0 .83-.67 1.5-1.5 1.5H7.5L4 19v-4H3.5C2.67 15 2 14.33 2 13.5V3.5Z"
fill="currentColor"
fillOpacity={0.09}
stroke="currentColor"
strokeOpacity={0.55}
strokeWidth="1.4"
strokeLinejoin="round"
/>
{[6.5, 9, 11.5].map((y, i) => (
<line
key={y}
x1="5.5"
y1={y}
x2={[15.5, 13, 11][i]}
y2={y}
stroke="currentColor"
strokeOpacity={0.75}
strokeWidth="1.4"
strokeLinecap="round"
/>
))}
</svg>
);
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
/**
* A voice note that reads itself out in text as it plays.
*
* A voice note is opaque: you cannot skim it, cannot search it, and cannot tell
* whether it is worth forty seconds until you have spent them. Sweeping the
* transcript in time with playback lets you read ahead, skip, or read instead
* of listening — and makes the note usable somewhere you cannot play audio.
*/
export function VoiceTranscript({
transcript = DEFAULT_TRANSCRIPT,
durationSeconds = 4,
waveform = DEFAULT_WAVEFORM,
className,
...rest
}: VoiceTranscriptProps) {
const reduce = useReducedMotion();
const uid = useId();
const bubbleId = `${uid}-transcript`;
const words = transcript.split(" ");
const [showTranscript, setShowTranscript] = useState(false);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
/*
* One clock. The original ran a second interval for the word reveal, keyed
* only on whether the transcript was open — so pausing the audio left the
* words marching on, and the transcript drifted out of sync with the sound
* it was supposed to be transcribing. Deriving the count from progress makes
* that desync unrepresentable, and removes an effect with a stale closure
* over the count it was incrementing.
*/
const visibleWords = Math.floor(progress * words.length);
const secondsLeft = Math.max(0, Math.ceil(durationSeconds * (1 - progress)));
useEffect(() => {
if (!playing) return;
timer.current = setInterval(() => {
setProgress((p) => {
const next = p + TICK_MS / (durationSeconds * 1000);
if (next >= 1) {
setPlaying(false);
return 1;
}
return next;
});
}, TICK_MS);
return () => {
if (timer.current) clearInterval(timer.current);
};
}, [playing, durationSeconds]);
/*
* Read the current values and set all three, rather than nesting the other
* two setters inside a `setShowTranscript` updater. An updater has to be a
* pure function of the previous state: React is free to call it more than
* once, and the setters smuggled inside it fire on every one of those calls.
*/
const toggleTranscript = useCallback(() => {
const next = !showTranscript;
setShowTranscript(next);
setProgress(0);
setPlaying(next);
}, [showTranscript]);
const togglePlay = useCallback(() => {
// Finished notes replay from the top rather than refusing to move.
if (progress >= 1) setProgress(0);
setPlaying((p) => !p);
}, [progress]);
return (
<div
className={cn(
"flex w-full min-w-0 items-center justify-center px-4 pt-8",
className,
)}
{...rest}
>
<div className="relative flex min-w-0 items-center gap-2.5">
<AnimatePresence>
{showTranscript && (
<motion.div
key="bubble"
id={bubbleId}
initial={
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.86, y: 8 }
}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: 6 }}
transition={
reduce
? { duration: 0.01 }
: { ...softSpring, opacity: blurTransition }
}
className="absolute bottom-full left-0 mb-4 w-[300px] max-w-[calc(100vw-3rem)]"
style={{ transformOrigin: "bottom left" }}
>
<div className="relative rounded-[28px] rounded-bl-[8px] bg-surface-subtle px-5 py-4 shadow-floating">
{/*
A bezier teardrop rather than a bordered triangle, so the tail
reads as part of the bubble. `currentColor` keeps it matched
to the bubble's fill in both themes — a hardcoded hex here
detaches the moment the theme flips.
*/}
<svg
className="absolute -bottom-[13px] left-[10px] text-surface-subtle"
width="24"
height="14"
viewBox="0 0 24 14"
fill="none"
aria-hidden="true"
>
<path
d="M0 0 C2 5 5 11 9 13 C11 9 16 4 24 0 Z"
fill="currentColor"
/>
</svg>
<p className="text-[15px] leading-[1.6] text-content select-none">
{words.map((word, i) => (
<motion.span
key={`${word}-${i}`}
/*
* Unrevealed words sit at 0.07 opacity rather than being
* absent, so the bubble is its final height from the
* first word and the tail never moves. The blur burning
* off is what makes it read as ink arriving rather than
* as a row of opacity fades.
*/
animate={
reduce
? { opacity: i < visibleWords ? 1 : 0.25 }
: {
opacity: i < visibleWords ? 1 : 0.07,
filter:
i < visibleWords ? "blur(0px)" : "blur(3px)",
}
}
transition={
reduce
? { duration: 0.01 }
: { duration: 0.28, ease: WORD_EASE }
}
className="mr-[0.28em] inline-block"
>
{word}
</motion.span>
))}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
<motion.button
type="button"
onClick={toggleTranscript}
aria-label={showTranscript ? "Hide transcript" : "Show transcript"}
aria-expanded={showTranscript}
aria-controls={showTranscript ? bubbleId : undefined}
whileHover={reduce ? undefined : { scale: 1.06 }}
whileTap={reduce ? undefined : { scale: 0.93 }}
className={cn(
"focus-ring grid size-[52px] shrink-0 cursor-pointer place-items-center",
"rounded-full bg-surface-subtle shadow-resting",
)}
>
<TranscribeIcon />
</motion.button>
<motion.div
layout
transition={reduce ? { duration: 0.01 } : layoutSpring}
className="flex min-w-0 items-center gap-2.5 rounded-full bg-surface-subtle px-3.5 py-3 shadow-resting"
>
<motion.button
type="button"
onClick={togglePlay}
aria-label={playing ? "Pause" : "Play"}
whileHover={reduce ? undefined : { scale: 1.1 }}
whileTap={reduce ? undefined : { scale: 0.88 }}
className={cn(
"focus-ring relative grid size-7 shrink-0 cursor-pointer",
"place-items-center rounded-full",
// 28px visually, 44px to a thumb. Growing the button itself
// would push the waveform out of the pill.
"before:absolute before:-inset-2 before:content-['']",
)}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={playing ? "pause" : "play"}
aria-hidden="true"
initial={
reduce
? { opacity: 0 }
: { scale: 0.5, opacity: 0, filter: "blur(6px)" }
}
animate={
reduce
? { opacity: 1 }
: { scale: 1, opacity: 1, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: { scale: 0.5, opacity: 0, filter: "blur(6px)" }
}
transition={
reduce
? { duration: 0.01 }
: {
...softSpring,
filter: blurTransition,
opacity: blurTransition,
}
}
className="grid place-items-center text-content"
>
{playing ? (
<Pause className="size-5 fill-current" strokeWidth={2.5} />
) : (
<Play className="size-5 fill-current" strokeWidth={2.5} />
)}
</motion.span>
</AnimatePresence>
</motion.button>
{/*
Decorative. The progressbar below carries the position, and reading
out thirty-six bars would be noise rather than information.
*/}
<div
aria-hidden="true"
className="flex items-center gap-[3px]"
style={{ height: 38 }}
>
{waveform.map((height, i) => (
<span
key={i}
style={{ height }}
className={cn(
"w-[3px] shrink-0 rounded-full",
"transition-colors duration-[280ms] ease-out",
i / waveform.length < progress
? "bg-content"
: "bg-border-strong",
)}
/>
))}
</div>
<div
role="progressbar"
aria-label="Playback position"
aria-valuemin={0}
aria-valuemax={durationSeconds}
aria-valuenow={Math.round(durationSeconds * progress)}
aria-valuetext={`${secondsLeft} seconds remaining`}
className="w-6 shrink-0 text-center"
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={secondsLeft}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 5 }}
transition={
reduce
? { duration: 0.01 }
: { duration: 0.18, ease: "easeOut" }
}
className="block text-[13.5px] font-semibold tabular-nums text-content-subtle select-none"
>
{secondsLeft}s
</motion.span>
</AnimatePresence>
</div>
</motion.div>
</div>
</div>
);
}
export default VoiceTranscript;
Technologies
| Framework | React 19 |
| Motion | Motion 12 (motion/react) |
| Styling | Tailwind CSS v4 |
| Icons | lucide-react |
| Types | TypeScript 5.9, strict |
Credits
Original design by
nitishkmrk. Renamed from
Voice Note Transcription. Rebuilt with a props API, a single clock, a
progressbar contract, themed colours and reduced-motion handling. The 290/30
bubble spring, the 320/28 layout spring, the 280ms word reveal on its
[0.22, 1, 0.36, 1] curve and the blurred icon swap are unchanged.