01Custom Control Bar Drawn From a Playback Progress Value
A custom audio control bar recomputes the seek handle position and remaining-time label every frame from an elapsed-time value counted since playback started.
Best forMusic sites and podcast app players
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>Press play and the elapsed-time value recalculates the handle position and remaining time.</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="Play">
<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="Seek bar" 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 Custom Control Bar Drawn From a Playback Progress Value — Measured area 2.546% · moving frames 13/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 UI06 Circular gauge bar — an angle that fills in proportion to a valueCSS Radial Menu: 9 Circular Control Patterns
Creative UI01 Bar spectrumAudio Visualizer JavaScript: 9 Parts, No Library
Creative UI01 Rotating globe drawn from latitude/longitude lines3D Globe Website: 9 Canvas Orbit Scenes