08タイムライン値でハイライトが移動する歌詞スクロール
疑似再生タイムライン値を基準に各歌詞行にあらかじめ設定した開始時刻と比較して今ハイライトすべき行を探し、その行が常に画面中央に来るようリストのスクロール位置も一緒に計算して動かす。
使いどころカラオケ式歌詞表示・ポッドキャストの書き起こし
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>再生するとタイムラインの値が進み、その行を見つけてハイライトが画面中央へ移動します</span></p>
<div class="lyr__panel">
<button class="lyr__play" id="play" type="button" aria-label="再生">
<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="歌詞の行、矢印キーで移動">
<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 タイムライン値でハイライトが移動する歌詞スクロール — 計測 累積面積 1.891% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · 音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI
このデモが属する記事
音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI音楽 プレイヤー デザイン 9種のうち、1つは複数のサイン波を重ねてバーの高さを先に計算しイコライザーのように揺らし、1つは角速度の値を徐々に落としてレコード盤を慣性で止め、1つはタイムライン値で歌詞のハイライト行を探して中央へスクロールし この記事のデモをすべて見る


