01再生進行率の値で描かれるカスタムコントロールバー
再生開始からの経過時間を値に換算し、シークハンドルの位置と残り時間表示を毎フレーム再計算して描くカスタムオーディオコントロールだ。
使いどころ音楽サイト・ポッドキャストアプリのプレイヤー
PROGRESS_UPDATE_MSseekTofmt

<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="cp is-demo is-playing" id="cp">
<p class="cp__hint"><span>再生を押すと経過時間の値からハンドル位置と残り時間が再計算されます</span></p>
<div class="cp__panel">
<div class="cp__title" id="title">Untitled Session</div>
<div class="cp__row">
<button class="cp__play" id="play" type="button" aria-label="再生">
<svg class="cp__ico cp__ico--play" aria-hidden="true"><use href="#ico-play"></use></svg>
<svg class="cp__ico cp__ico--pause" aria-hidden="true"><use href="#ico-pause"></use></svg>
</button>
<div class="cp__seek" id="seek" tabindex="0" role="slider" aria-label="シークバー" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="cp__track" id="track">
<div class="cp__fill" id="fill"></div>
<div class="cp__handle" id="handle"></div>
</div>
</div>
</div>
<div class="cp__time">
<span id="elapsed">0:00</span>
<span id="remain">-3:34</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(진행률 → 손잡이·채움·시간 텍스트)은 index.html 의 render()/frame() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.cp {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.cp__hint {
margin: 0;
color: rgba($subject-cream, .78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.cp__panel {
width: 100%;
border-radius: $r-card;
background: rgba($subject-cream, .06);
box-shadow: 0 0 0 1px rgba($subject-cream, .14);
padding: $sp-6 $sp-5;
display: flex;
flex-direction: column;
gap: $sp-4;
}
.cp__title {
color: $subject-cream;
font-size: 13px;
font-weight: 700;
letter-spacing: .01em;
}
.cp__row {
display: flex;
align-items: center;
gap: $sp-4;
}
.cp__play {
flex: 0 0 auto;
width: $sp-10;
height: $sp-10;
border-radius: $r-pill;
border: none;
background: $color;
color: $stage-ink;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform $duration $easing;
}
.cp__play:hover { transform: scale(1.06); }
.cp__play:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.cp__play:active { transform: scale(.96); }
.cp__ico { width: 18px; height: 18px; }
.cp__ico--pause { display: none; }
.is-playing .cp__ico--play { display: none; }
.is-playing .cp__ico--pause { display: block; }
.cp__seek {
flex: 1 1 auto;
padding: $sp-2 0;
cursor: pointer;
}
.cp__seek:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 4px;
}
.cp__track {
position: relative;
height: 4px;
border-radius: $r-xs;
background: rgba($subject-cream, .18);
}
.cp__fill {
position: absolute;
inset: 0;
border-radius: $r-xs;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 채움 막대는 왼쪽 기준으로 자라난다 — 진행률의 실제 기준점
}
.cp__handle {
position: absolute;
top: 50%;
left: 0;
width: $sp-4;
height: $sp-4;
margin-top: -$sp-2;
margin-left: -$sp-2;
border-radius: $r-pill;
background: $subject-cream;
box-shadow: 0 0 0 3px rgba($stage-ink, .5);
}
.cp__time {
display: flex;
justify-content: space-between;
color: rgba($subject-cream, .72);
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.cp__panel { padding: $sp-4 $sp-3; }
.cp__play { width: $sp-8; height: $sp-8; }
.cp__ico { width: 15px; height: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.cp__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cp {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.cp__hint {
margin: 0;
color: rgba(255, 247, 230, 0.78);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.cp__panel {
width: 100%;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
box-shadow: 0 0 0 1px rgba(255, 247, 230, 0.14);
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.cp__title {
color: #fff7e6;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.01em;
}
.cp__row {
display: flex;
align-items: center;
gap: 16px;
}
.cp__play {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 999px;
border: none;
background: #6ec8ff;
color: #17141a;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 300ms ease-out;
}
.cp__play:hover {
transform: scale(1.06);
}
.cp__play:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cp__play:active {
transform: scale(0.96);
}
.cp__ico {
width: 18px;
height: 18px;
}
.cp__ico--pause {
display: none;
}
.is-playing .cp__ico--play {
display: none;
}
.is-playing .cp__ico--pause {
display: block;
}
.cp__seek {
flex: 1 1 auto;
padding: 8px 0;
cursor: pointer;
}
.cp__seek:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 4px;
}
.cp__track {
position: relative;
height: 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.18);
}
.cp__fill {
position: absolute;
inset: 0;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
transform-origin: left center;
}
.cp__handle {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
margin-top: -8px;
margin-left: -8px;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 0 0 3px rgba(23, 20, 26, 0.5);
}
.cp__time {
display: flex;
justify-content: space-between;
color: rgba(255, 247, 230, 0.72);
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.cp__panel {
padding: 16px 12px;
}
.cp__play {
width: 32px;
height: 32px;
}
.cp__ico {
width: 15px;
height: 15px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp__play {
transition: none;
}
}(function () {
var root = document.getElementById('cp');
var playBtn = document.getElementById('play');
var seek = document.getElementById('seek');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var handle = document.getElementById('handle');
var elapsedEl = document.getElementById('elapsed');
var remainEl = document.getElementById('remain');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
var TRACK_LENGTH_S = 214; // 3:34 — 실 오디오 파일 없음, 합성 트랙 길이 상수
var SEEK_STEP_S = 5; // 방향키 한 번의 탐색 단위(초)
var PROGRESS_UPDATE_MS = 200; // 진행률 재계산 주기 — 변형: 이 값을 줄이면 더 잦게 다시 계산해 매끈해진다
var trackW = 0;
function fit() {
var r = track.getBoundingClientRect();
var hr = handle.getBoundingClientRect();
trackW = Math.max(1, r.width - hr.width);
}
function fmt(sec) {
sec = Math.max(0, Math.round(sec));
var m = Math.floor(sec / 60), s = sec % 60;
return m + ':' + (s < 10 ? '0' : '') + s;
}
function render(elapsedS) {
var p = Math.min(1, Math.max(0, elapsedS / TRACK_LENGTH_S));
fill.style.transform = 'scaleX(' + p + ')';
handle.style.transform = 'translateX(' + (p * trackW) + 'px)';
elapsedEl.textContent = fmt(elapsedS);
remainEl.textContent = '-' + fmt(TRACK_LENGTH_S - elapsedS);
seek.setAttribute('aria-valuenow', String(Math.round(p * 100)));
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var state = { elapsedS: 0, playing: false, playStart: 0, lastRenderMs: -1 };
function setPlaying(next) {
state.playing = next;
root.classList.toggle('is-playing', next);
if (next) state.playStart = performance.now() - state.elapsedS * 1000;
}
function seekTo(elapsedS) {
state.elapsedS = Math.min(TRACK_LENGTH_S, Math.max(0, elapsedS));
if (state.playing) state.playStart = performance.now() - state.elapsedS * 1000;
render(state.elapsedS);
}
function onTrackClick(e) {
human();
var r = track.getBoundingClientRect();
var ratio = (e.clientX - r.left) / r.width;
seekTo(ratio * TRACK_LENGTH_S);
}
function onSeekKeydown(e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
human();
seekTo(state.elapsedS + (e.key === 'ArrowRight' ? SEEK_STEP_S : -SEEK_STEP_S));
}
}
function onPlayClick() {
human();
setPlaying(!state.playing);
}
var driveStart = 0;
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) {
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
if (now - state.lastRenderMs >= PROGRESS_UPDATE_MS) {
render(frac * TRACK_LENGTH_S);
state.lastRenderMs = now;
}
}
requestAnimationFrame(driveTick);
}
function frame(now) {
if (!isDemo) {
if (state.playing) {
var elapsedS = (now - state.playStart) / 1000;
if (elapsedS >= TRACK_LENGTH_S) { state.playStart = now; elapsedS = 0; }
state.elapsedS = elapsedS;
}
if (now - state.lastRenderMs >= PROGRESS_UPDATE_MS) {
render(state.elapsedS);
state.lastRenderMs = now;
}
}
requestAnimationFrame(frame);
}
window.addEventListener('resize', fit);
fit();
if (reduce) {
root.classList.add('is-still');
render(0);
playBtn.addEventListener('click', function () { human(); setPlaying(!state.playing); render(state.elapsedS); });
track.addEventListener('click', function (e) { onTrackClick(e); });
seek.addEventListener('keydown', onSeekKeydown);
} else {
playBtn.addEventListener('click', onPlayClick);
track.addEventListener('click', onTrackClick);
seek.addEventListener('keydown', onSeekKeydown);
requestAnimationFrame(frame);
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./CustomPlayer.module.scss";
export default function CustomPlayer() {
const rootRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const fillRef = useRef<HTMLDivElement | null>(null);
const handleRef = useRef<HTMLDivElement | null>(null);
const seekRef = useRef<HTMLDivElement | null>(null);
const playRef = useRef<HTMLButtonElement | null>(null);
const elapsedRef = useRef<HTMLSpanElement | null>(null);
const remainRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const handle = handleRef.current;
const seek = seekRef.current;
const playBtn = playRef.current;
const elapsedEl = elapsedRef.current;
const remainEl = remainRef.current;
if (!root || !track || !fill || !handle || !seek || !playBtn || !elapsedEl || !remainEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
const TRACK_LENGTH_S = 214; // 3:34 — 실 오디오 파일 없음, 합성 트랙 길이 상수
const SEEK_STEP_S = 5; // 방향키 한 번의 탐색 단위(초)
const PROGRESS_UPDATE_MS = 200; // 진행률 재계산 주기 — 변형: 이 값을 줄이면 더 잦게 다시 계산해 매끈해진다
let trackW = 0;
function fit() {
const r = track!.getBoundingClientRect();
const hr = handle!.getBoundingClientRect();
trackW = Math.max(1, r.width - hr.width);
}
function fmt(sec: number) {
sec = Math.max(0, Math.round(sec));
const m = Math.floor(sec / 60), s = sec % 60;
return m + ":" + (s < 10 ? "0" : "") + s;
}
function render(elapsedS: number) {
const p = Math.min(1, Math.max(0, elapsedS / TRACK_LENGTH_S));
fill!.style.transform = "scaleX(" + p + ")";
handle!.style.transform = "translateX(" + p * trackW + "px)";
elapsedEl!.textContent = fmt(elapsedS);
remainEl!.textContent = "-" + fmt(TRACK_LENGTH_S - elapsedS);
seek!.setAttribute("aria-valuenow", String(Math.round(p * 100)));
}
function human() {
if (isDemo) { root!.classList.remove(styles["is-demo"]); isDemo = false; }
}
const state = { elapsedS: 0, playing: false, playStart: 0, lastRenderMs: -1 };
function setPlaying(next: boolean) {
state.playing = next;
root!.classList.toggle(styles["is-playing"], next);
if (next) state.playStart = performance.now() - state.elapsedS * 1000;
}
function seekTo(elapsedS: number) {
state.elapsedS = Math.min(TRACK_LENGTH_S, Math.max(0, elapsedS));
if (state.playing) state.playStart = performance.now() - state.elapsedS * 1000;
render(state.elapsedS);
}
function onTrackClick(e: MouseEvent) {
human();
const r = track!.getBoundingClientRect();
const ratio = (e.clientX - r.left) / r.width;
seekTo(ratio * TRACK_LENGTH_S);
}
function onSeekKeydown(e: KeyboardEvent) {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
human();
seekTo(state.elapsedS + (e.key === "ArrowRight" ? SEEK_STEP_S : -SEEK_STEP_S));
}
}
function onPlayClick() { human(); setPlaying(!state.playing); }
let driveStart = 0;
let rafDrive = 0;
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
if (now - state.lastRenderMs >= PROGRESS_UPDATE_MS) {
render(frac * TRACK_LENGTH_S);
state.lastRenderMs = now;
}
}
rafDrive = requestAnimationFrame(driveTick);
}
let rafFrame = 0;
function frame(now: number) {
if (!isDemo) {
if (state.playing) {
let elapsedS = (now - state.playStart) / 1000;
if (elapsedS >= TRACK_LENGTH_S) { state.playStart = now; elapsedS = 0; }
state.elapsedS = elapsedS;
}
if (now - state.lastRenderMs >= PROGRESS_UPDATE_MS) {
render(state.elapsedS);
state.lastRenderMs = now;
}
}
rafFrame = requestAnimationFrame(frame);
}
function onReduceClick() { human(); setPlaying(!state.playing); render(state.elapsedS); }
window.addEventListener("resize", fit);
fit();
if (reduce) {
root.classList.add(styles["is-still"]);
render(0);
playBtn.addEventListener("click", onReduceClick);
track.addEventListener("click", onTrackClick);
seek.addEventListener("keydown", onSeekKeydown);
} else {
playBtn.addEventListener("click", onPlayClick);
track.addEventListener("click", onTrackClick);
seek.addEventListener("keydown", onSeekKeydown);
rafFrame = requestAnimationFrame(frame);
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", fit);
cancelAnimationFrame(rafFrame);
cancelAnimationFrame(rafDrive);
if (reduce) {
playBtn.removeEventListener("click", onReduceClick);
} else {
playBtn.removeEventListener("click", onPlayClick);
}
track.removeEventListener("click", onTrackClick);
seek.removeEventListener("keydown", onSeekKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cp + ' ' + styles['is-demo'] + ' ' + styles['is-playing']} ref={rootRef}>
<p className={styles.hint}>
<span>재생을 누르면 경과 시간 값으로 손잡이 위치와 남은 시간이 다시 계산됩니다</span>
</p>
<div className={styles.panel}>
<div className={styles.title}>Untitled Session</div>
<div className={styles.row}>
<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.seek}
ref={seekRef}
tabIndex={0}
role="slider"
aria-label="탐색 막대"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
>
<div className={styles.track} ref={trackRef}>
<div className={styles.fill} ref={fillRef} />
<div className={styles.handle} ref={handleRef} />
</div>
</div>
</div>
<div className={styles.time}>
<span ref={elapsedRef}>0:00</span>
<span ref={remainRef}>-3:34</span>
</div>
</div>
</div>
</main>
);
}01 再生進行率の値で描かれるカスタムコントロールバー — 計測 累積面積 2.546% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · 音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI
このデモが属する記事
音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI音楽 プレイヤー デザイン 9種のうち、1つは複数のサイン波を重ねてバーの高さを先に計算しイコライザーのように揺らし、1つは角速度の値を徐々に落としてレコード盤を慣性で止め、1つはタイムライン値で歌詞のハイライト行を探して中央へスクロールし この記事のデモをすべて見る


