Media
Lyrics Sync
Karaoke-style synced lyrics — an injected playhead lights each line, word by word where word timings exist, keeps it on the anchor until you scroll away, and turns every line into a seek target.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/lyrics-sync.jsonPrompt
Build a React + TypeScript + Tailwind "LyricsSync" component (lucide-react for icons, a cn()
class-merge helper, no other dependencies). It is a karaoke stage for timed lyrics. It owns no
<audio> element and no clock of its own: every instant it renders comes from a prop.
Contract
- export interface LyricWord { startMs: number; endMs?: number; text: string }
- export interface LyricLine { id: string; startMs: number; endMs?: number; text?: string;
words?: LyricWord[]; translation?: string }
`text` may be omitted when `words` is present — join the words for it. `translation` renders as
a second, muted line under the lyric (translation, romanisation, stage note).
- export const LyricsSync = forwardRef<HTMLDivElement, LyricsSyncProps>(...), props spread onto the
root, `className` merged with cn().
- LyricsSyncProps extends React.HTMLAttributes<HTMLDivElement>:
lines: LyricLine[] // playback order; never sorted, never mutated
currentTimeMs?: number // audio.currentTime * 1000; undefined = static sheet
onSeek?: (ms, line) => void // a PLAYER time, offset already removed
offsetMs?: number // controlled timing correction, added to the playhead
defaultOffsetMs?: number // uncontrolled seed, default 0
onOffsetChange?: (ms: number) => void
offsetStepMs?: number // default 100
maxOffsetMs?: number // nudge clamp, ± this, default 5000
showOffset?: boolean // default true
autoScroll?: boolean // default true
followAnchor?: number // 0 top … 1 bottom, default 0.5
highlight?: "word" | "line" // default "word"
height?: number | string // DEFINITE length, default "18rem"
label?: string // accessible name, default "Lyrics"
emptyState?: ReactNode
Behavior
- Effective instant: t = currentTimeMs + offsetMs. Positive offset fires the lines EARLIER,
negative later. Every reading below uses t, never the raw prop.
- Resolution: the active line is the last line whose startMs <= t. A line's end is its own endMs,
else the next line's start, trimmed so declared ends can never overlap the next line. If
t >= that end, no line is active — the playhead sits in a gap.
- Gap: the NEXT line becomes the follow target and, when the silence is >= 2.5s, carries a
countdown rail from the previous end to its own start. Nothing is marked current in a gap; the
intro before the first line behaves the same way.
- Word wipe: for the active line only, each word's end is its endMs, else the next word's start,
else the line's end; fill = clamp((t - start) / (end - start)) * 100. Sung words are primary,
unsung words are foreground, the in-progress word is two copies of the same text — an unsung base
and a primary overlay clipped to `width: fill%`. The overlay is aria-hidden AND user-select:none,
so copying a lyric does not paste every word twice. Round the fill to one decimal. Only the clipped
copy suppresses wrapping; the base wraps like any text, so a pathological 60-character "word"
breaks instead of pushing the stage sideways.
- highlight="line", or a line with no word timings: the whole line lights and a thin rail under it
shows progress from the line's start to its end. An open-ended last line has no rail.
- currentTimeMs undefined: no line is current, nothing follows, no rail — a static, still clickable,
still copyable sheet.
- Auto-follow: on ACTIVE-LINE CHANGE ONLY (never on every playhead tick), glide the viewport so the
followed line rests on followAnchor. Blank spacers of anchor*100% and (1-anchor)*100% top and
bottom let the first and last lines reach the anchor like any other. The first placement after
mount is a jump, not a glide.
- Takeover: record every scrollTop you write yourself as `pinned`. A scroll event more than 2px from
min(pinned, maxScrollTop) is the listener moving the viewport → cancel the glide, stop following,
show a "Back to the current line" pill. Wheel and touchmove flag the takeover on arrival, before
their scroll lands, since a glide overwrites scrollTop every frame; ignore them at the end stop,
where nothing can move. Comparing against the CLAMPED pin is what keeps a shorter `lines` array
from silently switching following off.
- Return: the pill, Escape, or picking any line re-arms following and re-baselines the pin.
Escape stays silent when there is nothing to undo, so a surrounding dialog still closes on it.
- Seek: click or Enter on a line calls onSeek(max(0, line.startMs - offsetMs), line) — the offset is
taken back out so `audio.currentTime = ms / 1000` lands the vocal on the line's first word. A pointer
click that ends a drag-selection (event.detail > 0 and a non-empty window.getSelection()) is someone
copying the lyric, not asking to jump: swallow it. Keyboard activation reports detail 0 and always seeks.
- Offset nudge: −/+/reset. A nudge clamps to ±maxOffsetMs; a nudge that changes nothing REFUSES out
loud ("The offset is already at its +5.00 s limit.") instead of silently doing nothing. The
buttons go aria-disabled at the limits, never natively disabled — the browser blurs a node the
instant it is disabled, and the last nudge is the one that reaches the limit.
- Keyboard: one tab stop for the whole stage (roving tabindex). ↑/↓ walk the lines, Home/End jump to
the first/last, Enter/Space seek to the focused line, Escape returns to the current line. Walking
the lines counts as a takeover — otherwise the next sung line yanks the viewport off what is being
read. Programmatic focus uses { preventScroll: true }, then the component's own glide moves and
pins the viewport; the browser's scroll-into-view would read as a takeover.
- Focus: whichever line the browser focused owns the tab stop, read off the DOM via a data attribute
on focusin. When focus leaves the stage, hand the tab stop back to the current line. The pill
unmounts when pressed, so it focuses the current line FIRST — focus never falls to <body>. Same for
a `lines` swap (the next track): the focused row is gone and browsers do not agree on whether
removing a focused node fires focusout, so detect the loss (activeElement is <body> or null) in a
layout effect and focus whichever line now owns the tab stop.
- Cleanup: cancel the glide rAF and the feedback timeout on unmount; disconnect the ResizeObserver
(it watches both the viewport and the list, so a font swap or a narrower card re-places the stage).
- Reduced motion: the glide becomes an instant jump and the sub-word sweep snaps — a word lights
whole the instant it is sung. Highlighting, scrolling, seeking and the rails all keep working.
Rendering & styling
- Semantic tokens only: bg-card, bg-muted, bg-primary, bg-primary/5, bg-popover, text-foreground,
text-muted-foreground, text-primary, text-destructive, text-popover-foreground, border,
border-primary, ring, accent. Top/bottom fades are from-card to-transparent. No hex, no oklch.
- Rows: border-l-2 always present (transparent when idle) so nothing reflows; text size and weight
stay constant across states so a line never re-wraps as it becomes current — colour and the wipe
carry the state. select-text on the row buttons keeps lyrics copyable.
- ARIA: root role="group" with the label; an <ol role="list"> of rows; the sung row's button carries
aria-current="true"; the offset cluster is its own labelled group. A polite, atomic role="status"
announces ONLY offsets, refusals, seeks and follow changes — the lyrics themselves are deliberately
not a live region, because a line lands every few seconds and would interrupt a screen reader out
of usefulness. A visible status line mirrors the same sentence, destructive-coloured for refusals.
Customization levers
- Density and shape: `height` (any definite length), `followAnchor` (0.5 centres it like a karaoke
stage, 0.3 reads more like a teleprompter), the row padding, and the fade height.
- Which sub-blocks exist: `showOffset={false}` drops the whole nudge cluster; drop the fades for a
flat panel; drop the `translation` line for single-language lyrics; replace `emptyState` outright.
- Highlight strength: `highlight="line"` for a calmer sing-along; swap the wipe colour from
text-primary to var(--chart-N) for a per-track accent; make the sung tail text-muted-foreground
instead of text-primary to invert the karaoke reading.
- Timing feel: FOLLOW_MS (glide duration), TAKEOVER_PX (how sensitive takeover is), INTERLUDE_MS
(how long a silence has to be before it earns a countdown), FEEDBACK_MS (how long a report stays).
- Behaviour: `autoScroll={false}` makes it a scroll-it-yourself sheet that still highlights;
`offsetStepMs` / `maxOffsetMs` size the nudge; omit `onSeek` to make the lines inert labels.Concepts
- Injected playhead — the component reads time from a prop and never owns an
<audio>element, so the same stage works over an audio tag, a video, a WebAudio graph, or a scrubbing timeline; nothing renders from a clock read at render time. - Word wipe — the karaoke sweep is two copies of the same word, an unsung base and a sung overlay clipped to a percentage width; the overlay is hidden from assistive tech and from the selection, so the line still copies as one clean string.
- Follow, then takeover — every scroll position the component writes is pinned; a scroll event that disagrees with the pin can only be the listener, which stops the auto-centring and raises a return pill instead of fighting for the viewport.
- Return pill as the way back — takeover is sticky on purpose: following resumes only on an explicit act (the pill, Escape, or picking a line), and the pill hands focus to the current line before it unmounts itself.
- Timing offset — one signed number added to the playhead before matching, which is how a lyric file cut against a different master is dragged into sync; nudges clamp and say so out loud rather than stalling silently.
- Interlude — a silence long enough to deserve its own state: nothing is current, the next line is pre-centred, and a rail counts the gap down so the stage never looks broken between verses.
Equalizer
A multi-band graphic equalizer — rotated native faders, a Catmull-Rom response curve through the handles, presets that glide, and a typed gain map out.
Scroll Scrubbed Video
A pinned section whose scroll distance drives the video playhead — rAF-coalesced seeks, readyState-guarded, collapsing to a poster and a play control when motion or seeking is off the table.