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…

"use client"

import * as React from "react"
import { LocateFixed, Minus, Music4, Plus, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"

/* --------------------------------------------------------------------------
 * Thresholds of the follow → takeover → return machine.
 * ----------------------------------------------------------------------- */

/**
 * Every scroll position this component writes itself is recorded in `pinnedTop`.
 * A scroll event reporting anything further than this from that value can only
 * be the listener moving the viewport (wheel, touch, scrollbar, find-in-page,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/lyrics-sync.json

Prompt

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.

On This Page