08Lyrics Scroll Whose Highlight Moves From a Timeline Value
The current highlighted lyric line is found by comparing a simulated playback-timeline value against each line preset start time, and the list scroll offset is recomputed alongside it so that line stays centered.
Best forKaraoke-style lyric displays and podcast transcripts
activeIndexOfseekToLineLINE_TIMES

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-play" viewBox="0 0 256 256"><g fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></symbol>
<symbol id="ico-pause" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="lyr is-demo is-playing" id="lyr">
<p class="lyr__hint"><span>Press play and the timeline value advances, finding that line and moving the highlight to the center.</span></p>
<div class="lyr__panel">
<button class="lyr__play" id="play" type="button" aria-label="Play">
<svg class="lyr__ico lyr__ico--play" aria-hidden="true"><use href="#ico-play"></use></svg>
<svg class="lyr__ico lyr__ico--pause" aria-hidden="true"><use href="#ico-pause"></use></svg>
</button>
<div class="lyr__viewport" id="viewport" tabindex="0" role="listbox" aria-label="Lyric line, move with arrow keys">
<ul class="lyr__list" id="list"></ul>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(타임라인 값 → 활성 줄·간격 보간)은 index.html 의 render() 에 있다. 여기는 무대·레이아웃만.
// transition 을 두지 않는다 — 오프셋·투명도는 전부 render() 의 관성 lerp 로 매 프레임 계산해 인라인 스타일로 찍는다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.lyr {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.lyr__hint {
margin: 0;
color: rgba($subject-ink, .72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.lyr__panel {
width: 100%;
border-radius: $r-card;
background: $subject-cream;
box-shadow: 0 0 0 1px rgba($subject-ink, .12);
padding: $sp-3;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.lyr__play {
flex: 0 0 auto;
width: $sp-8;
height: $sp-8;
border-radius: $r-pill;
border: none;
background: $color;
color: $subject-cream;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform $duration $easing;
}
.lyr__play:hover { transform: scale(1.06); }
.lyr__play:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.lyr__play:active { transform: scale(.96); }
.lyr__ico { width: 16px; height: 16px; }
.lyr__ico--pause { display: none; }
.is-playing .lyr__ico--play { display: none; }
.is-playing .lyr__ico--pause { display: block; }
.lyr__viewport {
width: 100%;
overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 22%, #000 78%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, #000 22%, #000 78%, transparent 100%);
}
.lyr__viewport:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: -2px;
}
.lyr__list {
list-style: none;
margin: 0;
padding: 0;
}
.lyr__line {
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: rgba($subject-ink, .5);
cursor: pointer;
}
.lyr__line.is-active {
color: $subject-blue-deep;
font-size: 15px;
}
@media (max-width: 340px) {
.lyr__panel { padding: 4px; gap: 4px; }
.lyr__play { width: 28px; height: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.lyr__play { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lyr {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.lyr__hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.lyr__panel {
width: 100%;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.12);
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.lyr__play {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 999px;
border: none;
background: #2f6df6;
color: #fff7e6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 300ms ease-out;
}
.lyr__play:hover {
transform: scale(1.06);
}
.lyr__play:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.lyr__play:active {
transform: scale(0.96);
}
.lyr__ico {
width: 16px;
height: 16px;
}
.lyr__ico--pause {
display: none;
}
.is-playing .lyr__ico--play {
display: none;
}
.is-playing .lyr__ico--pause {
display: block;
}
.lyr__viewport {
width: 100%;
overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 22%, #000 78%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, #000 22%, #000 78%, transparent 100%);
}
.lyr__viewport:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: -2px;
}
.lyr__list {
list-style: none;
margin: 0;
padding: 0;
}
.lyr__line {
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: rgba(23, 20, 26, 0.5);
cursor: pointer;
}
.lyr__line.is-active {
color: #0a4fe8;
font-size: 15px;
}
@media (max-width: 340px) {
.lyr__panel {
padding: 4px;
gap: 4px;
}
.lyr__play {
width: 28px;
height: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.lyr__play {
transition: none;
}
}(function () {
var root = document.getElementById('lyr');
var playBtn = document.getElementById('play');
var viewport = document.getElementById('viewport');
var list = document.getElementById('list');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
var LINE_COUNT = 10;
var LINE_H = 22; // 한 줄의 세로 간격
var VIEWPORT_H = 88;
var PLAYBACK_LENGTH_S = 40; // 합성 타임라인 총 길이(초) — 실제 곡 길이가 아니라 시연용 값
var SPEED = 8; // 시연 배속 — 실제 시간(Date)이 아니라 타임라인 값을 빨리 흘리는 시연 전용 배율
var LINE_TIMES = [];
for (var i = 0; i < LINE_COUNT; i++) LINE_TIMES.push(i * 4);
var lines = [];
for (var i = 0; i < LINE_COUNT; i++) {
var li = document.createElement('li');
li.className = 'lyr__line';
li.setAttribute('role', 'option');
li.setAttribute('data-i', String(i));
li.tabIndex = -1;
li.textContent = 'Line ' + (i < 9 ? '0' + (i + 1) : i + 1);
list.appendChild(li);
lines.push(li);
}
viewport.style.height = VIEWPORT_H + 'px';
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var playedMs = 0, playing = true;
function activeIndexOf(progressS) {
var idx = 0;
for (var i = 0; i < LINE_TIMES.length; i++) { if (LINE_TIMES[i] <= progressS) idx = i; }
return idx;
}
function seekToLine(i) {
i = Math.max(0, Math.min(LINE_COUNT - 1, i));
playedMs = (LINE_TIMES[i] / SPEED) * 1000;
}
function render() {
var progressS = (playedMs / 1000) * SPEED % PLAYBACK_LENGTH_S;
var activeIndex = activeIndexOf(progressS);
var target = activeIndex * LINE_H - (VIEWPORT_H / 2 - LINE_H / 2);
var maxOffset = Math.max(0, LINE_COUNT * LINE_H - VIEWPORT_H);
target = Math.max(0, Math.min(maxOffset, target));
if (currentOffset == null) currentOffset = target;
currentOffset += (target - currentOffset) * 0.22; // 타임라인이 옮겨간 자리로 관성 있게 다가간다
list.style.transform = 'translateY(' + (-currentOffset).toFixed(2) + 'px)';
for (var i = 0; i < lines.length; i++) {
var dist = Math.abs(i - activeIndex);
var isActive = i === activeIndex;
lines[i].classList.toggle('is-active', isActive);
lines[i].setAttribute('aria-selected', String(isActive));
lines[i].style.opacity = String(isActive ? 1 : Math.max(0.28, 1 - dist * 0.22));
lines[i].style.transform = 'scale(' + (isActive ? 1 : 0.94) + ')';
}
}
var currentOffset = null;
function setPlaying(next) { playing = next; root.classList.toggle('is-playing', next); }
function onPlayClick() { human(); setPlaying(!playing); }
function onPlayKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); setPlaying(!playing); }
}
function onLineClick(i) { human(); seekToLine(i); render(); }
function onViewportKeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); human();
var progressS = (playedMs / 1000) * SPEED % PLAYBACK_LENGTH_S;
var cur = activeIndexOf(progressS);
seekToLine(cur + (e.key === 'ArrowDown' ? 1 : -1));
render();
}
}
lines.forEach(function (li, i) { li.addEventListener('click', function () { onLineClick(i); }); });
viewport.addEventListener('keydown', onViewportKeydown);
var lastNow = 0;
function frame(now) {
if (!lastNow) lastNow = now;
var dt = now - lastNow;
lastNow = now;
var effPlaying = isDemo ? true : playing;
if (effPlaying) playedMs += dt;
render();
requestAnimationFrame(frame);
}
if (reduce) {
root.classList.add('is-still');
render();
playBtn.addEventListener('click', function () { human(); setPlaying(!playing); render(); });
playBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); human(); setPlaying(!playing); render(); }
});
} else {
playBtn.addEventListener('click', onPlayClick);
playBtn.addEventListener('keydown', onPlayKeydown);
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./LyricsSyncScroll.module.scss";
const LINE_COUNT = 10;
const LINE_NAMES = Array.from({ length: LINE_COUNT }, (_, i) => "Line " + (i < 9 ? "0" + (i + 1) : String(i + 1)));
export default function LyricsSyncScroll() {
const rootRef = useRef<HTMLDivElement | null>(null);
const playRef = useRef<HTMLButtonElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const lineRefs = useRef<(HTMLLIElement | null)[]>([]);
useEffect(() => {
const root = rootRef.current;
const playBtn = playRef.current;
const viewport = viewportRef.current;
const list = listRef.current;
const lines = (lineRefs.current || []).filter((l): l is HTMLLIElement => !!l);
if (!root || !playBtn || !viewport || !list || lines.length !== LINE_COUNT) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
const LINE_H = 22; // 한 줄의 세로 간격
const VIEWPORT_H = 88;
const PLAYBACK_LENGTH_S = 40; // 합성 타임라인 총 길이(초) — 실제 곡 길이가 아니라 시연용 값
const SPEED = 8; // 시연 배속 — 실제 시간(Date)이 아니라 타임라인 값을 빨리 흘리는 시연 전용 배율
const LINE_TIMES: number[] = [];
for (let i = 0; i < LINE_COUNT; i++) LINE_TIMES.push(i * 4);
viewport.style.height = VIEWPORT_H + "px";
function human() { if (isDemo) { root!.classList.remove(styles["is-demo"]); isDemo = false; } }
let playedMs = 0, playing = true;
function activeIndexOf(progressS: number) {
let idx = 0;
for (let i = 0; i < LINE_TIMES.length; i++) { if (LINE_TIMES[i] <= progressS) idx = i; }
return idx;
}
function seekToLine(i: number) {
i = Math.max(0, Math.min(LINE_COUNT - 1, i));
playedMs = (LINE_TIMES[i] / SPEED) * 1000;
}
let currentOffset: number | null = null;
function render() {
const progressS = ((playedMs / 1000) * SPEED) % PLAYBACK_LENGTH_S;
const activeIndex = activeIndexOf(progressS);
let target = activeIndex * LINE_H - (VIEWPORT_H / 2 - LINE_H / 2);
const maxOffset = Math.max(0, LINE_COUNT * LINE_H - VIEWPORT_H);
target = Math.max(0, Math.min(maxOffset, target));
if (currentOffset == null) currentOffset = target;
currentOffset += (target - currentOffset) * 0.22; // 타임라인이 옮겨간 자리로 관성 있게 다가간다
list!.style.transform = "translateY(" + (-currentOffset).toFixed(2) + "px)";
for (let i = 0; i < lines.length; i++) {
const dist = Math.abs(i - activeIndex);
const isActive = i === activeIndex;
lines[i].classList.toggle(styles["is-active"], isActive);
lines[i].setAttribute("aria-selected", String(isActive));
lines[i].style.opacity = String(isActive ? 1 : Math.max(0.28, 1 - dist * 0.22));
lines[i].style.transform = "scale(" + (isActive ? 1 : 0.94) + ")";
}
}
function setPlaying(next: boolean) { playing = next; root!.classList.toggle(styles["is-playing"], next); }
function onPlayClick() { human(); setPlaying(!playing); }
function onPlayKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); setPlaying(!playing); }
}
function onLineClick(i: number) { human(); seekToLine(i); render(); }
function onViewportKeydown(e: KeyboardEvent) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); human();
const progressS = ((playedMs / 1000) * SPEED) % PLAYBACK_LENGTH_S;
const cur = activeIndexOf(progressS);
seekToLine(cur + (e.key === "ArrowDown" ? 1 : -1));
render();
}
}
const lineClickHandlers = lines.map((_, i) => () => onLineClick(i));
lines.forEach((li, i) => li.addEventListener("click", lineClickHandlers[i]));
viewport.addEventListener("keydown", onViewportKeydown);
let lastNow = 0;
let raf = 0;
function frame(now: number) {
if (!lastNow) lastNow = now;
const dt = now - lastNow;
lastNow = now;
const effPlaying = isDemo ? true : playing;
if (effPlaying) playedMs += dt;
render();
raf = requestAnimationFrame(frame);
}
function onReduceClick() { human(); setPlaying(!playing); render(); }
function onReduceKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); human(); setPlaying(!playing); render(); }
}
if (reduce) {
root.classList.add(styles["is-still"]);
render();
playBtn.addEventListener("click", onReduceClick);
playBtn.addEventListener("keydown", onReduceKeydown);
} else {
playBtn.addEventListener("click", onPlayClick);
playBtn.addEventListener("keydown", onPlayKeydown);
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
lines.forEach((li, i) => li.removeEventListener("click", lineClickHandlers[i]));
viewport.removeEventListener("keydown", onViewportKeydown);
if (reduce) {
playBtn.removeEventListener("click", onReduceClick);
playBtn.removeEventListener("keydown", onReduceKeydown);
} else {
playBtn.removeEventListener("click", onPlayClick);
playBtn.removeEventListener("keydown", onPlayKeydown);
}
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.lyr + " " + styles["is-demo"] + " " + styles["is-playing"]} ref={rootRef}>
<p className={styles.hint}><span>재생하면 타임라인 값이 흘러 그 줄을 찾아 화면 가운데로 하이라이트가 옮겨갑니다</span></p>
<div className={styles.panel}>
<button className={styles.play} ref={playRef} type="button" aria-label="재생">
<svg className={`${styles.ico} ${styles.icoPlay}`} aria-hidden="true"><use href="#ico-play" /></svg>
<svg className={`${styles.ico} ${styles.icoPause}`} aria-hidden="true"><use href="#ico-pause" /></svg>
</button>
<div className={styles.viewport} ref={viewportRef} tabIndex={0} role="listbox" aria-label="가사 줄, 화살표 키로 이동">
<ul className={styles.list} ref={listRef}>
{LINE_NAMES.map((name, i) => (
<li
key={name}
className={styles.line}
ref={(el: HTMLLIElement | null) => { if (lineRefs.current) lineRefs.current[i] = el; }}
role="option"
data-i={i}
tabIndex={-1}
>
{name}
</li>
))}
</ul>
</div>
</div>
</div>
</main>
);
}08 Lyrics Scroll Whose Highlight Moves From a Timeline Value — Measured area 1.891% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Custom Audio Player CSS — 9 Computed UI Patterns
The post this demo lives in
Custom Audio Player CSS — 9 Computed UI PatternsAmong nine custom audio player css demos, one precomputes bar heights from sine waves, one decays angular velocity so th See all demos in this post
If you like this effect - similar demos
Creative UI02 Strip Automaton Whose Next Row Pattern Follows a Single Rule-Number ValueConway Game of Life in Canvas, 9 Patterns
Creative UI02 Cells Whose Boundary-width Value Cracks Like Dry EarthVoronoi Diagram Generator — 9 Patterns
Creative UI08 Paper Doll Whose Joint Chain Swings by a Linked ValuePage Turn Animation CSS — 9 Paper Curl Patterns