Read Along Text for React and Tailwind CSS (shadcn/ui): a paragraph whose words light up in time with speech, the current word lifted on a soft highlight with a caret under it and a breath after punctuation. It is built to show listeners where the voice is, so a transcript can be followed and tapped. It owns no clock: drive it with progress or currentTime, or use playing with wordsPerSecond. Click or Enter on a word calls onSeek, arrow keys move between words, reduced motion keeps colors only. Use it for AI voice answers, transcripts and read-aloud modes. The included demo plays a fake voice reply and makes no requests. Requires React only, with no animation library.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Read Along Text – Word Highlight Synced to Speech or Audio Progress to your project and fits it to your theme.
Code
How Read Along Text – Word Highlight Synced to Speech or Audio Progress is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { AudioLines, Pause, Play, RotateCcw } from "lucide-react";
import { ReadAlongText, getReadAlongSchedule, useReducedMotion } from "@/components/ui/read-along-text";
import type { ReadAlongSize, ReadAlongTone } from "@/components/ui/read-along-text";
const TEXT = "Your workspace is ready. We moved twelve files into Website redesign, tagged four of them for review, and scheduled the handoff for Thursday morning.";
const LEAD_IN = 0.3;
const LOOP_HOLD = 1000;
const TONES: ReadAlongTone[] = ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"];
const SIZES: ReadAlongSize[] = ["md", "lg"];
const BAR = { "chart-1": "bg-chart-1", "chart-2": "bg-chart-2", "chart-3": "bg-chart-3", "chart-4": "bg-chart-4", "chart-5": "bg-chart-5" } as const;
function readParams() {
if (typeof window === "undefined") return new URLSearchParams();
return new URLSearchParams(window.location.search);
}
function formatClock(seconds: number) {
const whole = Math.max(0, Math.floor(seconds));
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
}
function SpeakingBars({ active, tone, reduced }: { active: boolean; tone: ReadAlongTone; reduced: boolean }) {
const refs = React.useRef([] as (HTMLSpanElement | null)[]);
React.useEffect(() => {
if (!active || reduced) return;
const animations = refs.current.map((node, i) =>
node?.animate([{ transform: "scaleY(0.35)" }, { transform: "scaleY(1)" }, { transform: "scaleY(0.35)" }], { duration: 520 + i * 90, delay: i * 60, iterations: Number.POSITIVE_INFINITY, easing: "ease-in-out" }) ?? null,
);
return () => animations.forEach((animation) => animation?.cancel());
}, [active, reduced]);
return (
<span data-speaking={active ? "true" : "false"} aria-hidden="true" className="flex h-4 shrink-0 items-center gap-[3px]">
{[10, 16, 12].map((height, i) => (
<span
key={i}
ref={(node) => {
refs.current[i] = node;
}}
className={`w-[3px] origin-center rounded-full transition-opacity duration-300 motion-reduce:transition-none ${BAR[tone]} ${active ? "opacity-100" : "opacity-40"}`}
style={{ height, transform: "scaleY(0.35)" }}
/>
))}
</span>
);
}
export default function Demo({ autoplay = true }: { autoplay?: boolean }) {
const params = React.useMemo(readParams, []);
const clockMode = params.get("mode") === "clock";
const fixedProgress = params.has("progress") ? Number(params.get("progress")) : null;
const wordsPerSecond = Number(params.get("wps")) || 2.6;
const punctuationPause = params.has("pause") ? Number(params.get("pause")) : 240;
const tone = TONES.find((value) => value === params.get("tone")) ?? "chart-1";
const size = SIZES.find((value) => value === params.get("size")) ?? "lg";
const dimUpcoming = params.get("dim") !== "0";
const caret = params.get("caret") !== "0";
const reduced = useReducedMotion();
const schedule = React.useMemo(() => getReadAlongSchedule(TEXT, { wordsPerSecond, punctuationPause, leadIn: LEAD_IN }), [wordsPerSecond, punctuationPause]);
const duration = schedule.duration;
const [time, setTime] = React.useState(0);
const [playing, setPlaying] = React.useState(autoplay && fixedProgress === null);
const [seeks, setSeeks] = React.useState(0);
const [lastSeek, setLastSeek] = React.useState("");
const [ends, setEnds] = React.useState(0);
const [lastWord, setLastWord] = React.useState(-1);
const timeRef = React.useRef(0);
const anchor = React.useRef({ base: 0, startedAt: 0 });
const interacted = React.useRef(false);
const loopTimer = React.useRef(0);
const finish = React.useCallback(() => {
setPlaying(false);
setEnds((count) => count + 1);
if (interacted.current || !autoplay) return;
window.clearTimeout(loopTimer.current);
loopTimer.current = window.setTimeout(() => {
timeRef.current = 0;
setTime(0);
setPlaying(true);
}, LOOP_HOLD);
}, [autoplay]);
React.useEffect(() => {
if (clockMode || fixedProgress !== null || !playing) return;
if (timeRef.current >= duration) timeRef.current = 0;
anchor.current = { base: timeRef.current, startedAt: performance.now() };
let frame = 0;
const tick = (now: number) => {
const next = Math.min(duration, anchor.current.base + (now - anchor.current.startedAt) / 1000);
timeRef.current = next;
setTime(next);
if (next >= duration) {
finish();
return;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [clockMode, fixedProgress, playing, duration, finish]);
React.useEffect(() => () => window.clearTimeout(loopTimer.current), []);
const markInteraction = () => {
interacted.current = true;
window.clearTimeout(loopTimer.current);
};
const seekTo = (next: number) => {
const clamped = Math.min(duration, Math.max(0, next));
timeRef.current = clamped;
anchor.current = { base: clamped, startedAt: performance.now() };
setTime(clamped);
if (!playing) setPlaying(true);
};
const toggle = () => {
markInteraction();
if (playing) {
setPlaying(false);
return;
}
if (timeRef.current >= duration) {
timeRef.current = 0;
setTime(0);
}
setPlaying(true);
};
const ended = !playing && time >= duration && duration > 0;
const percent = duration > 0 ? (time / duration) * 100 : 0;
const onTrackKeyDown = (event: React.KeyboardEvent) => {
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowUp") next = timeRef.current + 1;
else if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = timeRef.current - 1;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = duration;
if (next === null) return;
event.preventDefault();
markInteraction();
seekTo(next);
};
const onCardKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== " ") return;
const target = event.target as HTMLElement;
if (target.closest("button")) return;
event.preventDefault();
toggle();
};
const timeProps = clockMode
? {
playing,
onProgressChange: (_: number, at: number) => {
timeRef.current = at;
setTime(at);
},
onEnd: finish,
}
: fixedProgress !== null
? { progress: fixedProgress }
: { currentTime: time, duration };
return (
<main
data-demo-root
data-time={time.toFixed(2)}
data-duration={duration.toFixed(2)}
data-playing={playing ? "true" : "false"}
data-seeks={seeks}
data-last-seek={lastSeek}
data-ends={ends}
data-last-word={lastWord}
className="flex min-h-[min(100svh,760px)] w-full items-center justify-center bg-background px-5 py-8 text-foreground sm:px-8"
>
<section
aria-label="Alder voice reply"
onKeyDown={onCardKeyDown}
className="w-full max-w-[680px] rounded-2xl bg-card p-6 text-card-foreground shadow-sm ring-1 ring-border sm:p-7"
>
<header className="flex items-center gap-2.5 text-sm">
<span className="flex size-6 items-center justify-center rounded-md bg-foreground text-background">
<AudioLines className="size-3.5" aria-hidden="true" />
</span>
<span className="font-medium">Alder</span>
<span className="text-muted-foreground">Voice reply</span>
</header>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
data-play
aria-label={ended ? "Replay" : playing ? "Pause" : "Play"}
onClick={toggle}
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-foreground text-background outline-none transition-[background-color,transform] duration-200 motion-reduce:transition-none hover:bg-foreground/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card active:scale-95"
>
{ended ? <RotateCcw className="size-4" aria-hidden="true" /> : playing ? <Pause className="size-4" fill="currentColor" aria-hidden="true" /> : <Play className="size-4 translate-x-px" fill="currentColor" aria-hidden="true" />}
</button>
<div
role="slider"
tabIndex={0}
aria-label="Playback position"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(time)}
aria-valuetext={`${formatClock(time)} of ${formatClock(duration)}`}
data-track
onPointerDown={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
markInteraction();
seekTo(((event.clientX - rect.left) / rect.width) * duration);
}}
onKeyDown={onTrackKeyDown}
className="group relative h-6 flex-1 cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
<span className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 overflow-hidden rounded-full bg-muted">
<span data-fill className="block h-full rounded-full bg-foreground" style={{ width: `${percent}%` }} />
</span>
<span
data-knob
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground opacity-0 transition-opacity duration-200 motion-reduce:transition-none group-hover:opacity-100 group-focus-visible:opacity-100"
style={{ left: `${percent}%` }}
/>
</div>
<span data-clock className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
{formatClock(time)} / {formatClock(duration)}
</span>
<SpeakingBars active={playing} tone={tone} reduced={reduced} />
</div>
<div className="mt-5">
<ReadAlongText
text={TEXT}
{...timeProps}
wordsPerSecond={wordsPerSecond}
punctuationPause={punctuationPause}
leadIn={LEAD_IN}
tone={tone}
size={size}
dimUpcoming={dimUpcoming}
caret={caret}
onWord={setLastWord}
onSeek={(index, at) => {
markInteraction();
setSeeks((count) => count + 1);
setLastSeek(`${index}:${at.toFixed(2)}`);
if (!clockMode && fixedProgress === null) seekTo(at);
else if (clockMode && !playing) setPlaying(true);
}}
/>
</div>
</section>
</main>
);
}
















