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


