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 원작 · zip 에 SCSS·React 포함 · css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UI
이 데모가 속한 글
css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UIcss 오디오 플레이어 디자인 아홉 가지 가운데 하나는 여러 사인 곡선을 겹쳐 막대 높이를 미리 계산해 이퀄라이저처럼 흔들고, 하나는 각속도 값을 서서히 줄여 앨범판이 관성으로 멈추게 하며, 하나는 타임라인 값으로 이 글의 데모 전부 보기


