06Picker That Fills Waveform Bars Up to the Scrubbed Position
The horizontal position is converted into an index in the bar array, repainting bars before that index in a filled color and the rest in a faint one, showing where the value sits along the range.
Best forAudio range selection and playback-position scrubbing
BAR_WHEIGHTSGAP

<main class="stage">
<figure class="wf is-demo" id="wf">
<div class="wf__head">
<span class="wf__title">Waveform playback position</span>
<span class="wf__stat" aria-hidden="true"><b class="wf__n" id="wf-val">50</b>%</span>
</div>
<div class="wf__zone">
<svg class="wf__chart" id="chart" viewBox="0 0 480 200" preserveAspectRatio="none" role="img" aria-label="A waveform drawn with 40 bars; bars before the scrubbed position repaint in a filled color and those after in a faint one.">
<line class="wf__base" x1="0" y1="200" x2="480" y2="200"></line>
<g id="bars"></g>
<line class="wf__head-line" id="headline" x1="0" y1="0" x2="0" y2="200"></line>
</svg>
</div>
<div class="wf__track" id="track">
<span class="wf__rail" aria-hidden="true"></span>
<button class="wf__grab" id="grab" type="button" role="slider" aria-orientation="horizontal"
aria-label="Choose playback position" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="50" aria-valuetext="Playback position 50%">
<svg class="wf__ic" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M96 64v128a8 8 0 0 1-16 0V64a8 8 0 0 1 16 0m88 0v128a8 8 0 0 1-16 0V64a8 8 0 0 1 16 0" opacity=".2"/><path d="M188 32a8 8 0 0 0-8 8v176a8 8 0 0 0 16 0V40a8 8 0 0 0-8-8M76 32a8 8 0 0 0-8 8v176a8 8 0 0 0 16 0V40a8 8 0 0 0-8-8"/></g></svg>
</button>
</div>
<p class="wf__hint">Drag on the bars or press the arrow keys and the bars repaint up to that position.</p>
<div class="sr">
<span aria-live="polite" id="wf-live"></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 파형 스크럽 피커 — 재생 위치(0~100)를 드래그·키보드로 정하면 그 값을 인덱스로 바꿔
// 인덱스 이전 막대와 이후 막대를 서로 다른 색으로 매 프레임 다시 칠하고, 재생선(x 좌표)도
// 같은 값으로 다시 그린다. 값은 관성 lerp 로 목표를 뒤따른다.
// CSS @keyframes 는 0개(재생 위치가 매 프레임 실수로 바뀌어 선언형으로 못 담는다,
// 398-01~05 와 같은 이유).
// picker-graph-waveform-bar-count(JS, N)가 막대 개수를 정한다.
$duration: $dur-loop;
$easing: $ease-lerp-settle;
$color: $subject-blue;
@include stage($stage-yellow);
.sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
.wf {
box-sizing: border-box;
width: min(424px, 92vw);
margin: 0; padding: $sp-3;
display: flex; flex-direction: column; gap: $sp-2;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
color: $subject-ink;
}
.wf__head { display: flex; align-items: baseline; justify-content: flex-end; gap: $sp-2; padding: 0 $sp-1; }
.wf__title { font-size: 13px; line-height: 18px; font-weight: 800; letter-spacing: .02em; margin-right: auto; }
.wf__stat { font-size: 12px; line-height: 18px; font-weight: 800; font-variant-numeric: tabular-nums; color: rgba($subject-ink, .62); }
.wf__n { color: $subject-blue-deep; }
.wf__zone { padding: 0 $sp-1; }
.wf__chart { display: block; width: 100%; height: 116px; overflow: visible; }
.wf__base { stroke: rgba($subject-ink, .16); stroke-width: 2; }
.wf__head-line { stroke: $subject-blue-deep; stroke-width: 2; }
.wf__bar { fill: mix($subject-blue, $stage-paper, 20%); transition: none; }
.wf__bar.is-past { fill: $subject-blue-deep; }
.wf__track {
position: relative;
height: 24px;
margin: 0 $sp-1;
border-radius: $r-pill;
background: mix($subject-blue, $stage-paper, 14%);
}
.wf__rail {
position: absolute; left: $sp-3; right: $sp-3; top: 50%;
height: 3px; transform: translateY(-50%);
border-radius: $r-pill;
background: rgba($subject-ink, .18);
}
.wf__grab {
position: absolute; top: 50%; left: 50%;
width: 26px; height: 26px;
margin: 0; padding: 0; border: 0; border-radius: 50%;
display: grid; place-items: center;
background: $subject-blue-deep;
color: $stage-paper;
transform: translate(-50%, -50%);
cursor: ew-resize;
touch-action: none;
}
.wf__grab:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.wf__ic { width: 13px; height: 13px; }
.wf__hint { margin: 0; padding: 0 $sp-1; font-size: 11px; line-height: 16px; color: rgba($subject-ink, .62); }
@media (max-width: 340px) {
.wf { padding: $sp-2; gap: $sp-1; }
.wf__hint { display: none; }
.wf__title { font-size: 12px; line-height: 16px; }
.wf__stat { font-size: 11px; line-height: 16px; }
.wf__chart { height: 108px; }
.wf__grab { width: 22px; height: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.wf.is-still .wf__bar { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.wf {
box-sizing: border-box;
width: min(424px, 92vw);
margin: 0;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #17141a;
}
.wf__head {
display: flex;
align-items: baseline;
justify-content: flex-end;
gap: 8px;
padding: 0 4px;
}
.wf__title {
font-size: 13px;
line-height: 18px;
font-weight: 800;
letter-spacing: 0.02em;
margin-right: auto;
}
.wf__stat {
font-size: 12px;
line-height: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: rgba(23, 20, 26, 0.62);
}
.wf__n {
color: #0a4fe8;
}
.wf__zone {
padding: 0 4px;
}
.wf__chart {
display: block;
width: 100%;
height: 116px;
overflow: visible;
}
.wf__base {
stroke: rgba(23, 20, 26, 0.16);
stroke-width: 2;
}
.wf__head-line {
stroke: #0a4fe8;
stroke-width: 2;
}
.wf__bar {
fill: rgb(83.6862745098%, 88.5490196078%, 99.2941176471%);
transition: none;
}
.wf__bar.is-past {
fill: #0a4fe8;
}
.wf__track {
position: relative;
height: 24px;
margin: 0 4px;
border-radius: 999px;
background: rgb(88.5803921569%, 91.9843137255%, 99.5058823529%);
}
.wf__rail {
position: absolute;
left: 12px;
right: 12px;
top: 50%;
height: 3px;
transform: translateY(-50%);
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.wf__grab {
position: absolute;
top: 50%;
left: 50%;
width: 26px;
height: 26px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: #0a4fe8;
color: #ffffff;
transform: translate(-50%, -50%);
cursor: ew-resize;
touch-action: none;
}
.wf__grab:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.wf__ic {
width: 13px;
height: 13px;
}
.wf__hint {
margin: 0;
padding: 0 4px;
font-size: 11px;
line-height: 16px;
color: rgba(23, 20, 26, 0.62);
}
@media (max-width: 340px) {
.wf {
padding: 8px;
gap: 4px;
}
.wf__hint {
display: none;
}
.wf__title {
font-size: 12px;
line-height: 16px;
}
.wf__stat {
font-size: 11px;
line-height: 16px;
}
.wf__chart {
height: 108px;
}
.wf__grab {
width: 22px;
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.wf.is-still .wf__bar {
transition: none !important;
}
}// 실제 동작: pointerdown/move/up 과 keydown 이 Playback position(0~100)를 바꾸면, 그 값을 막대
// 배열의 인덱스로 바꿔 인덱스 이전 막대(fill class)와 이후 막대(pale)를 매 프레임 다시
// 칠하고, 재생선(x 좌표) degrees 같은 값으로 다시 그린다. 위치는 관성 lerp 로 목표를 뒤따른다.
// CSS @keyframes 없음(재생 위치가 매 프레임 실수로 바뀌어 선언형으로 못 담는다,
// 398-01~05 와 같은 이유).
var root = document.getElementById('wf');
var track = document.getElementById('track');
var grab = document.getElementById('grab');
var barsG = document.getElementById('bars');
var headline = document.getElementById('headline');
var valEl = document.getElementById('wf-val');
var live = document.getElementById('wf-live');
var N = 40; // picker-graph-waveform-bar-count — 막대 개수
var CHART_H = 190;
var LERP_RATE = 0.24;
var AUTO_PERIOD = 2.2;
var STEP = 5;
var BAR_W = 480 / N;
var GAP = 3;
var HEIGHTS = [];
for (var hi = 0; hi < N; hi++) {
var v = 0.30 + 0.32 * Math.abs(Math.sin(hi * 0.7)) + 0.24 * Math.abs(Math.cos(hi * 0.31 + 1.1));
HEIGHTS.push(Math.min(0.96, v));
}
var value = 50;
var current = 50;
var manual = false;
var autoT = 0;
var lastTs = 0;
var trackRect = null;
var rafId = 0;
var barEls = [];
function reduced() {
return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function buildBars() {
var html = '';
for (var i = 0; i < N; i++) {
var h = HEIGHTS[i] * CHART_H;
var x = i * BAR_W + GAP / 2;
var w = Math.max(1, BAR_W - GAP);
html += '<rect class="wf__bar" id="wb' + i + '" x="' + x + '" y="' + (CHART_H - h) + '" width="' + w + '" height="' + h + '"></rect>';
}
barsG.innerHTML = html;
for (var j = 0; j < N; j++) barEls.push(document.getElementById('wb' + j));
}
function paint() {
var activeIdx = Math.round((current / 100) * N);
for (var i = 0; i < N; i++) barEls[i].classList.toggle('is-past', i < activeIdx);
var x = (current / 100) * 480;
headline.setAttribute('x1', String(x));
headline.setAttribute('x2', String(x));
var rv = Math.round(current);
valEl.textContent = String(rv);
grab.style.left = value + '%';
grab.setAttribute('aria-valuenow', String(Math.round(value)));
grab.setAttribute('aria-valuetext', 'Playback position ' + Math.round(value) + '%');
}
function announce() {
live.textContent = 'Playback position ' + Math.round(current) + '% recomputed';
}
function setValue(v, isManual) {
value = Math.max(0, Math.min(100, v));
if (isManual) { manual = true; root.classList.remove('is-demo'); }
}
function fromPointer(clientX) {
if (!trackRect) trackRect = track.getBoundingClientRect();
var ratio = (clientX - trackRect.left) / trackRect.width;
setValue(ratio * 100, true);
}
function reflectNow() { current = value; paint(); announce(); }
function onDown(e) {
trackRect = track.getBoundingClientRect();
if (grab.setPointerCapture) { try { grab.setPointerCapture(e.pointerId); } catch (err) {} }
fromPointer(e.clientX);
if (reduced()) reflectNow();
}
function onMove(e) { fromPointer(e.clientX); if (reduced()) reflectNow(); }
function onUp() { announce(); }
function onTrackDown(e) { trackRect = track.getBoundingClientRect(); fromPointer(e.clientX); if (reduced()) reflectNow(); }
function onKey(e) {
var map = { ArrowLeft: -STEP, ArrowDown: -STEP, ArrowRight: STEP, ArrowUp: STEP };
if (e.key === 'Home') { setValue(0, true); e.preventDefault(); }
else if (e.key === 'End') { setValue(100, true); e.preventDefault(); }
else if (map[e.key] !== undefined) { setValue(value + map[e.key], true); e.preventDefault(); }
else { return; }
if (reduced()) reflectNow();
}
function onResize() { trackRect = track.getBoundingClientRect(); if (reduced()) paint(); }
function autoStep(dt) {
autoT += dt;
var omega = 2 * Math.PI / AUTO_PERIOD;
value = 50 + 48 * Math.sin(autoT * omega);
}
function frame(ts) {
if (!lastTs) lastTs = ts;
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (!manual) autoStep(dt);
current += (value - current) * LERP_RATE;
paint();
rafId = requestAnimationFrame(frame);
}
buildBars();
grab.addEventListener('pointerdown', onDown);
grab.addEventListener('pointermove', onMove);
grab.addEventListener('pointerup', onUp);
grab.addEventListener('keydown', onKey);
track.addEventListener('pointerdown', onTrackDown);
window.addEventListener('resize', onResize);
if (reduced()) {
root.classList.add('is-still');
current = value;
paint();
} else {
paint();
rafId = requestAnimationFrame(frame);
}// WaveformBarScrub — vanilla 판(run/398/demo/06-picker-graph-waveform-bar-scrub/index.html) 의 React 포트.
// 재생 위치(0~100)를 드래그·키보드로 정하면 그 값을 인덱스로 바꿔 인덱스 이전 막대와
// 이후 막대를 서로 다른 색으로 매 프레임 다시 칠하고, 재생선도 같은 값으로 다시 그린다.
import { useEffect, useRef } from "react";
import styles from "./WaveformBarScrub.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number; pointerId: number };
const N = 40; // picker-graph-waveform-bar-count — 막대 개수
const CHART_H = 190;
const LERP_RATE = 0.24;
const AUTO_PERIOD = 2.2;
const STEP = 5;
const BAR_W = 480 / N;
const GAP = 3;
const HEIGHTS: number[] = [];
for (let hi = 0; hi < N; hi++) {
const v = 0.3 + 0.32 * Math.abs(Math.sin(hi * 0.7)) + 0.24 * Math.abs(Math.cos(hi * 0.31 + 1.1));
HEIGHTS.push(Math.min(0.96, v));
}
export default function WaveformBarScrub() {
const rootRef = useRef<HTMLElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const grabRef = useRef<HTMLButtonElement | null>(null);
const headlineRef = useRef<SVGLineElement | null>(null);
const valRef = useRef<HTMLElement | null>(null);
const liveRef = useRef<HTMLSpanElement | null>(null);
const barRefs = useRef(new Array(N).fill(null)) as Box<(SVGRectElement | null)[]>;
const value = useRef(50) as Box<number>;
const current = useRef(50) as Box<number>;
const manual = useRef(false) as Box<boolean>;
const autoT = useRef(0) as Box<number>;
const lastTs = useRef(0) as Box<number>;
const trackRect = useRef<DOMRect | null>(null) as Box<DOMRect | null>;
const rafId = useRef(0) as Box<number>;
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function paint(): void {
const activeIdx = Math.round((current.current / 100) * N);
const bars = barRefs.current;
for (let i = 0; i < bars.length; i++) bars[i]?.classList.toggle("is-past", i < activeIdx);
const x = (current.current / 100) * 480;
headlineRef.current?.setAttribute("x1", String(x));
headlineRef.current?.setAttribute("x2", String(x));
const rv = Math.round(current.current);
if (valRef.current) valRef.current.textContent = String(rv);
const grab = grabRef.current;
if (grab) {
grab.style.left = value.current + "%";
grab.setAttribute("aria-valuenow", String(Math.round(value.current)));
grab.setAttribute("aria-valuetext", "재생 위치 " + Math.round(value.current) + "%");
}
}
function announce(): void {
if (liveRef.current) liveRef.current.textContent = "재생 위치 " + Math.round(current.current) + "%로 다시 계산됨";
}
function setValue(v: number, isManual: boolean): void {
value.current = Math.max(0, Math.min(100, v));
if (isManual) {
manual.current = true;
rootRef.current?.classList.remove("is-demo");
}
}
function fromPointer(clientX: number): void {
if (!trackRect.current && trackRef.current) trackRect.current = trackRef.current.getBoundingClientRect();
const rect = trackRect.current;
if (!rect) return;
const ratio = (clientX - rect.left) / rect.width;
setValue(ratio * 100, true);
}
function reflectNow(): void {
current.current = value.current;
paint();
announce();
}
function onDown(e: PtrEvt): void {
if (trackRef.current) trackRect.current = trackRef.current.getBoundingClientRect();
const grab = grabRef.current;
if (grab && typeof grab.setPointerCapture === "function") {
try { grab.setPointerCapture(e.pointerId); } catch { /* 캡처 실패는 치명적이지 않다 */ }
}
fromPointer(e.clientX);
if (reduced()) reflectNow();
}
function onMove(e: PtrEvt): void {
fromPointer(e.clientX);
if (reduced()) reflectNow();
}
function onUp(): void {
announce();
}
function onTrackDown(e: PtrEvt): void {
if (trackRef.current) trackRect.current = trackRef.current.getBoundingClientRect();
fromPointer(e.clientX);
if (reduced()) reflectNow();
}
function onKey(e: KeyEvt): void {
const map: Record<string, number> = { ArrowLeft: -STEP, ArrowDown: -STEP, ArrowRight: STEP, ArrowUp: STEP };
if (e.key === "Home") { setValue(0, true); e.preventDefault(); }
else if (e.key === "End") { setValue(100, true); e.preventDefault(); }
else if (map[e.key] !== undefined) { setValue(value.current + map[e.key], true); e.preventDefault(); }
else { return; }
if (reduced()) reflectNow();
}
function onResize(): void {
if (trackRef.current) trackRect.current = trackRef.current.getBoundingClientRect();
if (reduced()) paint();
}
function autoStep(dt: number): void {
autoT.current += dt;
const omega = (2 * Math.PI) / AUTO_PERIOD;
value.current = 50 + 48 * Math.sin(autoT.current * omega);
}
function frame(ts: number): void {
if (!lastTs.current) lastTs.current = ts;
const dt = Math.min(0.05, (ts - lastTs.current) / 1000);
lastTs.current = ts;
if (!manual.current) autoStep(dt);
current.current += (value.current - current.current) * LERP_RATE;
paint();
rafId.current = requestAnimationFrame(frame);
}
useEffect(() => {
window.addEventListener("resize", onResize);
if (reduced()) {
rootRef.current?.classList.add("is-still");
reflectNow();
} else {
paint();
rafId.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafId.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<figure className={styles.wf + " is-demo"} ref={rootRef}>
<div className={styles.head}>
<span className={styles.title}>파형 재생 위치</span>
<span className={styles.stat} aria-hidden="true"><b className={styles.n} ref={valRef}>50</b>%</span>
</div>
<div className={styles.zone}>
<svg
className={styles.chart}
viewBox="0 0 480 200"
preserveAspectRatio="none"
role="img"
aria-label="40개 막대로 그린 파형, 스크럽 위치 이전 막대는 채운 색 이후 막대는 옅은 색으로 다시 칠해진다"
>
<line className={styles.base} x1={0} y1={200} x2={480} y2={200} />
<g>
{HEIGHTS.map((h, i) => {
const height = h * CHART_H;
const x = i * BAR_W + GAP / 2;
const w = Math.max(1, BAR_W - GAP);
return (
<rect
key={i}
className={styles.bar}
ref={(el: SVGRectElement | null) => { barRefs.current[i] = el; }}
x={x}
y={CHART_H - height}
width={w}
height={height}
/>
);
})}
</g>
<line className={styles.headline} ref={headlineRef} x1={0} y1={0} x2={0} y2={200} />
</svg>
</div>
<div className={styles.track} ref={trackRef} onPointerDown={onTrackDown}>
<span className={styles.rail} aria-hidden="true" />
<button
className={styles.grab}
ref={grabRef}
type="button"
role="slider"
aria-orientation="horizontal"
aria-label="재생 위치 고르기"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={50}
aria-valuetext="재생 위치 50%"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onKeyDown={onKey}
>
<svg className={styles.ic} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M96 64v128a8 8 0 0 1-16 0V64a8 8 0 0 1 16 0m88 0v128a8 8 0 0 1-16 0V64a8 8 0 0 1 16 0" opacity=".2" /><path d="M188 32a8 8 0 0 0-8 8v176a8 8 0 0 0 16 0V40a8 8 0 0 0-8-8M76 32a8 8 0 0 0-8 8v176a8 8 0 0 0 16 0V40a8 8 0 0 0-8-8" /></g></svg>
</button>
</div>
<p className={styles.hint}>막대 위를 드래그하거나 화살표키를 누르면 그 위치까지 막대 색이 다시 칠해집니다</p>
<div className={styles.sr}>
<span aria-live="polite" ref={liveRef} />
</div>
</figure>
</main>
);
}06 Picker That Fills Waveform Bars Up to the Scrubbed Position — Measured area 23.116% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Graph Animation CSS Pickers With Live Recompute
The post this demo lives in
9 Graph Animation CSS Pickers With Live RecomputeMove a bar by pixel and the graph drifts from its value. So nine pickers recompute: bar height from a bucket value, a ga See all demos in this post
If you like this effect - similar demos
Pickers02 Hue wheel that reads the angle you pressCustom Color Picker: 9 JS Parts, No Library
Pickers09 Touch drag scrub — the filled value updates in real time following the finger's horizontal position valueStar Rating UI: 9 Interactive Rating Patterns
Pickers07 Sheet-swap value toggle — as the screen-width value narrows, a popover value changes into a bottom-sheet value that rises from belowDate Picker UI: 9 Popover & Calendar Patterns