07Scrub Bar's Ghost Buffer Preview
Dragging the scrub track converts the pointer's position — measured live against the track's actual width via getBoundingClientRect — into the ghost marker's position and time label, while the readout keeps showing both playback and buffer time.
Best forVideo seek bars, podcast scrubbing
pointermove--ghost-pctgetBoundingClientRect

<main class="stage">
<div class="scrubWrap is-demo" id="scrubWrap">
<div class="track" id="track" role="slider" aria-label="Scrub bar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
<div class="bufferFill" id="bufferFill"></div>
<div class="playFill" id="playFill"></div>
<div class="ghost" id="ghost">
<span class="ghostTip" id="ghostTip">0:00</span>
</div>
<div class="handle" id="handle"></div>
</div>
<p class="readout" id="readout" aria-live="polite">Playback 0:00 · Buffer 0:00</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-lilac;
// 계산 겹: index.html 의 fracFromPointer() 가 getBoundingClientRect 로 트랙 폭을 실측해
// 포인터 X 좌표를 0~1 비율로 바꾸고, 그 비율이 유령 미리보기 위치·시간 라벨을 만든다.
@include stage($stage-ink);
.scrubWrap {
display: flex;
flex-direction: column;
gap: $sp-3;
width: 260px;
}
.track {
position: relative;
width: 100%;
height: 8px;
border-radius: $r-pill;
background: rgba($subject-cream, .14);
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
}
.bufferFill {
--buffer-pct: 0%;
position: absolute;
inset: 0;
width: var(--buffer-pct);
border-radius: $r-pill;
background: rgba($subject-cream, .3);
}
.playFill {
--play-pct: 0%;
position: absolute;
inset: 0;
width: var(--play-pct);
border-radius: $r-pill;
background: $color;
}
.handle {
--play-pct: 0%;
position: absolute;
top: 50%;
left: var(--play-pct);
width: 12px;
height: 12px;
border-radius: 50%;
background: $subject-cream;
transform: translate(-50%, -50%);
}
.ghost {
--ghost-pct: 0%;
position: absolute;
top: -28px;
left: var(--ghost-pct);
transform: translateX(-50%);
pointer-events: none;
}
.ghostTip {
display: inline-block;
padding: 4px $sp-2;
border-radius: $r-xs;
font-size: 10px;
font-weight: 600;
color: $subject-ink;
background: $subject-cream;
white-space: nowrap;
}
.readout {
margin: 0;
font-size: 11px;
color: rgba($subject-cream, .7);
text-align: center;
}
@media (max-width: 340px) {
.scrubWrap { width: 200px; }
.readout { font-size: 10px; }
}
// reduce: 실사용 경로 적용 — 자동 이동에서만 쓰던 매끄러운 값 갱신을 멈춘다. 위치 값 자체는 그대로 정확해야 한다
@media (prefers-reduced-motion: reduce) {
.bufferFill, .playFill, .handle { 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);
}
.scrubWrap {
display: flex;
flex-direction: column;
gap: 12px;
width: 260px;
}
.track {
position: relative;
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.14);
cursor: pointer;
}
.track:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.bufferFill {
--buffer-pct: 0%;
position: absolute;
inset: 0;
width: var(--buffer-pct);
border-radius: 999px;
background: rgba(255, 247, 230, 0.3);
}
.playFill {
--play-pct: 0%;
position: absolute;
inset: 0;
width: var(--play-pct);
border-radius: 999px;
background: #b9a0ff;
}
.handle {
--play-pct: 0%;
position: absolute;
top: 50%;
left: var(--play-pct);
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
transform: translate(-50%, -50%);
}
.ghost {
--ghost-pct: 0%;
position: absolute;
top: -28px;
left: var(--ghost-pct);
transform: translateX(-50%);
pointer-events: none;
}
.ghostTip {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: #17141a;
background: #fff7e6;
white-space: nowrap;
}
.readout {
margin: 0;
font-size: 11px;
color: rgba(255, 247, 230, 0.7);
text-align: center;
}
@media (max-width: 340px) {
.scrubWrap {
width: 200px;
}
.readout {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bufferFill, .playFill, .handle {
transition: none;
}
}(function () {
var TOTAL_SEC = 185; // 총 길이(가상)
var LOOP_MS = 4200;
var TICK_MS = 60;
var BUFFER_LEAD = 0.28; // 버퍼가 재생보다 앞서는 고정 여유(비율)
var wrap = document.getElementById('scrubWrap');
var track = document.getElementById('track');
var bufferFill = document.getElementById('bufferFill');
var playFill = document.getElementById('playFill');
var ghost = document.getElementById('ghost');
var ghostTip = document.getElementById('ghostTip');
var handle = document.getElementById('handle');
var readout = document.getElementById('readout');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function fmt(sec) {
var s = Math.max(0, Math.round(sec));
var m = Math.floor(s / 60);
var r = s % 60;
return m + ':' + (r < 10 ? '0' : '') + r;
}
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
var playFrac = 0.18;
var manualPlayFrac = null;
function setGhost(frac) {
var pct = (frac * 100).toFixed(1) + '%';
ghost.style.setProperty('--ghost-pct', pct);
ghostTip.textContent = fmt(frac * TOTAL_SEC);
}
function paint(playF, ghostF) {
var bufF = clamp(playF + BUFFER_LEAD, 0, 1);
bufferFill.style.setProperty('--buffer-pct', (bufF * 100).toFixed(1) + '%');
playFill.style.setProperty('--play-pct', (playF * 100).toFixed(1) + '%');
handle.style.setProperty('--play-pct', (playF * 100).toFixed(1) + '%');
track.setAttribute('aria-valuenow', String(Math.round(playF * 100)));
setGhost(ghostF);
readout.textContent = 'Playback ' + fmt(playF * TOTAL_SEC) + ' · Buffer ' + fmt(bufF * TOTAL_SEC);
}
function tick(elapsedMs) {
var t = (elapsedMs % LOOP_MS) / LOOP_MS;
playFrac = manualPlayFrac !== null ? manualPlayFrac : 0.05 + 0.55 * t;
// 유령(미리보기) 위치는 실제 포인터 X 좌표를 트랙 폭 기준 비율로 바꾼 값 — 여기선 같은 방식을 합성파로 재현
var pointerFrac = clamp(playFrac + 0.22 + 0.06 * Math.sin(elapsedMs / 260), 0, 1);
paint(playFrac, pointerFrac);
}
var timer = null;
var start = 0;
function loop() { tick(performance.now() - start); }
function human() {
if (wrap.classList.contains('is-demo')) {
wrap.classList.remove('is-demo');
window.clearInterval(timer);
timer = null;
}
}
// 실제 포인터 좌표 → 트랙 안 비율로 바꾸는 계산 (getBoundingClientRect 실측)
function fracFromPointer(clientX) {
var rect = track.getBoundingClientRect();
return clamp((clientX - rect.left) / rect.width, 0, 1);
}
var dragging = false;
track.addEventListener('pointermove', function (ev) {
human();
var f = fracFromPointer(ev.clientX);
setGhost(f);
if (dragging) {
manualPlayFrac = f;
paint(f, f);
}
});
track.addEventListener('pointerdown', function (ev) {
human();
dragging = true;
var f = fracFromPointer(ev.clientX);
manualPlayFrac = f;
paint(f, f);
});
window.addEventListener('pointerup', function () { dragging = false; });
track.addEventListener('keydown', function (ev) {
human();
var step = 0.02;
if (ev.key === 'ArrowRight') { manualPlayFrac = clamp((manualPlayFrac !== null ? manualPlayFrac : playFrac) + step, 0, 1); paint(manualPlayFrac, manualPlayFrac); }
else if (ev.key === 'ArrowLeft') { manualPlayFrac = clamp((manualPlayFrac !== null ? manualPlayFrac : playFrac) - step, 0, 1); paint(manualPlayFrac, manualPlayFrac); }
});
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 자동 이동은 멈추고 Playback·Buffer·유령 위치를 한 장면으로 고정한다
paint(0.3, 0.5);
}
})();import { useEffect, useRef } from 'react';
import styles from './BufferingScrubPreviewGhost.module.scss';
const TOTAL_SEC = 185; // 총 길이(가상)
const LOOP_MS = 4200;
const TICK_MS = 60;
const BUFFER_LEAD = 0.28; // 버퍼가 재생보다 앞서는 고정 여유(비율)
function fmt(sec: number) {
const s = Math.max(0, Math.round(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return m + ':' + (r < 10 ? '0' : '') + r;
}
function clamp(v: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, v));
}
export default function BufferingScrubPreviewGhost() {
const wrapRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const bufferFillRef = useRef<HTMLDivElement>(null);
const playFillRef = useRef<HTMLDivElement>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const ghostTipRef = useRef<HTMLSpanElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const readoutRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
const track = trackRef.current;
const bufferFill = bufferFillRef.current;
const playFill = playFillRef.current;
const ghost = ghostRef.current;
const ghostTip = ghostTipRef.current;
const handle = handleRef.current;
const readout = readoutRef.current;
if (!wrap || !track || !bufferFill || !playFill || !ghost || !ghostTip || !handle || !readout) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let playFrac = 0.18;
let manualPlayFrac: number | null = null;
function setGhost(frac: number) {
if (!ghost || !ghostTip) return;
const pct = (frac * 100).toFixed(1) + '%';
ghost.style.setProperty('--ghost-pct', pct);
ghostTip.textContent = fmt(frac * TOTAL_SEC);
}
function paint(playF: number, ghostF: number) {
if (!bufferFill || !playFill || !handle || !track || !readout) return;
const bufF = clamp(playF + BUFFER_LEAD, 0, 1);
bufferFill.style.setProperty('--buffer-pct', (bufF * 100).toFixed(1) + '%');
playFill.style.setProperty('--play-pct', (playF * 100).toFixed(1) + '%');
handle.style.setProperty('--play-pct', (playF * 100).toFixed(1) + '%');
track.setAttribute('aria-valuenow', String(Math.round(playF * 100)));
setGhost(ghostF);
readout.textContent = '재생 ' + fmt(playF * TOTAL_SEC) + ' · 버퍼 ' + fmt(bufF * TOTAL_SEC);
}
function tick(elapsedMs: number) {
const t = (elapsedMs % LOOP_MS) / LOOP_MS;
playFrac = manualPlayFrac !== null ? manualPlayFrac : 0.05 + 0.55 * t;
// 유령(미리보기) 위치는 실제 포인터 X 좌표를 트랙 폭 기준 비율로 바꾼 값 — 여기선 같은 방식을 합성파로 재현
const pointerFrac = clamp(playFrac + 0.22 + 0.06 * Math.sin(elapsedMs / 260), 0, 1);
paint(playFrac, pointerFrac);
}
let timer: ReturnType<typeof window.setInterval> | null = null;
let start = 0;
function loop() {
tick(performance.now() - start);
}
function human() {
if (wrap && wrap.classList.contains(styles['is-demo'])) {
wrap.classList.remove(styles['is-demo']);
if (timer !== null) window.clearInterval(timer);
timer = null;
}
}
// 실제 포인터 좌표 → 트랙 안 비율로 바꾸는 계산 (getBoundingClientRect 실측)
function fracFromPointer(clientX: number) {
if (!track) return 0;
const rect = track.getBoundingClientRect();
return clamp((clientX - rect.left) / rect.width, 0, 1);
}
let dragging = false;
function onPointerMove(ev: PointerEvent) {
human();
const f = fracFromPointer(ev.clientX);
setGhost(f);
if (dragging) {
manualPlayFrac = f;
paint(f, f);
}
}
function onPointerDown(ev: PointerEvent) {
human();
dragging = true;
const f = fracFromPointer(ev.clientX);
manualPlayFrac = f;
paint(f, f);
}
function onPointerUp() {
dragging = false;
}
function onKeyDown(ev: KeyboardEvent) {
human();
const step = 0.02;
if (ev.key === 'ArrowRight') {
manualPlayFrac = clamp((manualPlayFrac !== null ? manualPlayFrac : playFrac) + step, 0, 1);
paint(manualPlayFrac, manualPlayFrac);
} else if (ev.key === 'ArrowLeft') {
manualPlayFrac = clamp((manualPlayFrac !== null ? manualPlayFrac : playFrac) - step, 0, 1);
paint(manualPlayFrac, manualPlayFrac);
}
}
track.addEventListener('pointermove', onPointerMove);
track.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointerup', onPointerUp);
track.addEventListener('keydown', onKeyDown);
if (!reduce) {
start = performance.now();
tick(0);
timer = window.setInterval(loop, TICK_MS);
} else {
// reduce: 실사용 경로 적용 — 자동 이동은 멈추고 재생·버퍼·유령 위치를 한 장면으로 고정한다
paint(0.3, 0.5);
}
return () => {
if (timer !== null) window.clearInterval(timer);
track.removeEventListener('pointermove', onPointerMove);
track.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerUp);
track.removeEventListener('keydown', onKeyDown);
};
}, []);
return (
<div className={`${styles.scrubWrap} ${styles['is-demo']}`} ref={wrapRef}>
<div
className={styles.track}
ref={trackRef}
role="slider"
aria-label="탐색 막대"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
tabIndex={0}
>
<div className={styles.bufferFill} ref={bufferFillRef} />
<div className={styles.playFill} ref={playFillRef} />
<div className={styles.ghost} ref={ghostRef}>
<span className={styles.ghostTip} ref={ghostTipRef}>0:00</span>
</div>
<div className={styles.handle} ref={handleRef} />
</div>
<p className={styles.readout} ref={readoutRef} aria-live="polite">재생 0:00 · 버퍼 0:00</p>
</div>
);
}07 Scrub Bar's Ghost Buffer Preview — Measured area 3.027% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Buffering Animation CSS — 9 Loading Patterns
The post this demo lives in
Buffering Animation CSS — 9 Loading PatternsAmong nine buffering animation css ideas, one draws two stacked rings for playback and buffer, one fills a waveform only See all demos in this post
If you like this effect - similar demos
Loaders02 Circular percent ring computed from its own circumferenceFile Upload Progress Bar Loader: 9 UI Demos
Loaders03 Multi-user overlap — a row computing name-tag width from concurrent typers9 Typing Indicator CSS Patterns (Chat UI)
Loaders01 Bottom spinner fade — a loading indicator computing its opacity from the remaining scroll distanceInfinite Scroll Loading: 9 Essential UI Patterns