Synced Lyrics
Time-synced lyrics: the current line lifts and centres as it plays, past lines fade, and tapping a line seeks there.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/synced-lyrics.json'use client'
import { useEffect, useRef, useState } from 'react'
import './synced-lyrics.css'
export interface LyricLine { t: number; text: string }
export interface SyncedLyricsProps {
lines: LyricLine[]
duration: number
align?: 'left' | 'center'
fill?: 'dark' | 'light'
className?: string
}
/** Auto-scrolling, tap-to-seek synced lyrics. */
export function SyncedLyrics({ lines, duration, align = 'left', fill = 'dark', className }: SyncedLyricsProps) {
const [t, setT] = useState(0)
const [play, setPlay] = useState(false)
const [follow, setFollow] = useState(true)
const body = useRef<HTMLDivElement>(null)
const auto = useRef(false)
useEffect(() => {
if (!play) return
const i = setInterval(() => setT((x) => { if (x >= duration) { setPlay(false); return duration } return x + 0.1 }), 100)
return () => clearInterval(i)
}, [play, duration])
const cur = lines.reduce((a, l, i) => (t >= l.t ? i : a), -1)
useEffect(() => {
if (!follow || cur < 0) return
const box = body.current, el = box?.querySelectorAll<HTMLElement>('.sd-lyr__l')[cur]
if (!box || !el) return
auto.current = true
box.scrollTo({ top: el.offsetTop - box.clientHeight / 2 + el.offsetHeight / 2 })
setTimeout(() => (auto.current = false), 500)
}, [cur, follow])
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
return (
<div className={['sd-lyr', className].filter(Boolean).join(' ')} data-fill={fill} data-align={align}>
<div ref={body} className="sd-lyr__body" onScroll={() => { if (!auto.current && play) setFollow(false) }} onWheel={() => setFollow(false)} onTouchMove={() => setFollow(false)}>
{lines.map((l, i) => (
<button key={l.t} type="button" className="sd-lyr__l" aria-current={i === cur} data-past={i < cur || undefined} onClick={() => { setT(l.t); setFollow(true); setPlay(true) }}>{l.text}</button>
))}
</div>
{!follow && <button type="button" className="sd-lyr__now" onClick={() => setFollow(true)}>Back to now</button>}
<div className="sd-lyr__bar">
<button type="button" className="sd-lyr__pl" aria-label={play ? 'Pause' : 'Play'} aria-pressed={play} onClick={() => { if (t >= duration) setT(0); setPlay(!play) }}>
<svg viewBox="0 0 14 14" aria-hidden>{play ? <path d="M3 2h3v10H3zM8 2h3v10H8z" /> : <path d="M3.5 2v10l8.5-5z" />}</svg>
</button>
<span className="sd-lyr__t">{mmss(t)}</span>
<span className="sd-lyr__prog" aria-hidden><i style={{ width: `${(t / duration) * 100}%` }} /></span>
<span className="sd-lyr__t">{mmss(duration)}</span>
</div>
</div>
)
}.sd-lyr { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-lyr[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-lyr :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-lyr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-lyr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-lyr { position: relative; width: min(100%, 320px); border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-lyr__body { height: 300px; overflow-y: auto; padding: 120px 20px; scroll-behavior: smooth; scrollbar-width: none; }
.sd-lyr__body::-webkit-scrollbar { display: none; }
.sd-lyr[data-align='center'] .sd-lyr__body { text-align: center; }
.sd-lyr__l { display: block; width: 100%; padding: 6px 0; font-size: 20px; font-weight: 700; line-height: 1.25; letter-spacing: -0.01em; text-align: inherit; color: var(--mute) !important; opacity: 0.5; transform-origin: left center; transition: opacity 300ms, transform 400ms cubic-bezier(0.3, 1.2, 0.4, 1), color 300ms; }
.sd-lyr[data-align='center'] .sd-lyr__l { transform-origin: center; }
.sd-lyr__l[data-past] { opacity: 0.32; }
.sd-lyr__l[aria-current='true'] { color: var(--fg) !important; opacity: 1; transform: scale(1.04); }
.sd-lyr__bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-top: 1px solid var(--line); }
.sd-lyr__pl { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border-radius: 99px; background: var(--strong) !important; color: var(--on) !important; }
.sd-lyr__pl svg { width: 14px; height: 14px; fill: currentColor; }
.sd-lyr__prog { flex: 1; height: 4px; border-radius: 99px; background: var(--soft); overflow: hidden; }
.sd-lyr__prog i { display: block; height: 100%; background: var(--fg); }
.sd-lyr__t { font-size: 11.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-lyr__now { position: absolute; left: 50%; bottom: 74px; transform: translateX(-50%); padding: 6px 12px; border-radius: 99px; background: var(--strong) !important; color: var(--on) !important; font-size: 12px; font-weight: 600; animation: sd-lyr-up 200ms ease-out; }
@keyframes sd-lyr-up { from { opacity: 0; transform: translate(-50%, 6px); } }
@media (prefers-reduced-motion: reduce) { .sd-lyr *, .sd-lyr *::before, .sd-lyr *::after { animation: none !important; transition: none !important; } }Overview
Lines carry a start time; the active one scales up slightly and scrolls to the centre. Tapping any line jumps playback there.
If you scroll manually it stops following until you tap “Back to now”. Works for transcripts and captions too.
When to use
- Music players, podcasts with transcripts, language learning.
When not to use
- Untimed text: just show it.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/synced-lyrics.json$
npx shadcn@latest add https://sundaeui.dev/r/synced-lyrics.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/synced-lyrics.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/synced-lyrics.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy synced-lyrics.tsx and synced-lyrics.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SyncedLyrics } from '@/components/sundae/synced-lyrics'
<SyncedLyrics />Props
| Prop | Type | Default |
|---|---|---|
lines*Timed lines in seconds. | { t: number; text: string }[] | — |
duration*Track length. | number | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The current line is marked aria-current; lines are buttons that seek.
- Following pauses on manual scroll so reading isn’t interrupted.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-lyr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Synced Lyrics free?
- Yes. Synced Lyrics is free for personal and commercial projects, with full source on this page.
- How do I install Synced Lyrics?
- Run npx shadcn@latest add https://sundaeui.dev/r/synced-lyrics.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Synced Lyrics support keyboard and screen readers?
- The current line is marked aria-current; lines are buttons that seek. Following pauses on manual scroll so reading isn’t interrupted.
- Does Synced Lyrics respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Mixer Faders
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined Hold to Confirm
Components · Refined