03Picker That Leaves Its Drag Path as a Sparkline Trail
Every value passed while dragging is pushed into a coordinate array and drawn as a line path, so the picker shows the trail the value traveled, not just where it stands now.
Best forValues like price or score history where the path matters
TRAIL_LENGTHCHART_WSTEP

<main class="stage">
<figure class="spk is-demo" id="spk">
<div class="spk__head">
<span class="spk__title">Trail of past values</span>
<span class="spk__stat" aria-hidden="true">Value <b class="spk__n" id="spk-val">50</b></span>
</div>
<div class="spk__zone">
<svg class="spk__chart" id="chart" viewBox="0 0 480 160" preserveAspectRatio="none" role="img" aria-label="A line graph connecting the last 48 values; each time the value changes, the oldest point shifts left and a new point is added on the right.">
<line class="spk__base" x1="0" y1="150" x2="480" y2="150"></line>
<path class="spk__area" id="area"></path>
<path class="spk__line" id="line"></path>
<circle class="spk__dot" id="dot" r="5"></circle>
</svg>
</div>
<div class="spk__track" id="track">
<span class="spk__rail" aria-hidden="true"></span>
<button class="spk__grab" id="grab" type="button" role="slider" aria-orientation="horizontal"
aria-label="Choose value" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="50" aria-valuetext="Value 50">
<svg class="spk__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="spk__hint">Drag on the bars or press the arrow keys and the values you pass become part of the trail.</p>
<div class="sr">
<span aria-live="polite" id="spk-live"></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스파크라인 궤적 피커 — 드래그·키보드로 값(0~100)을 정하면 매 프레임 관성 보간된
// 현재값을 좌표 배열(trail) 끝에 쌓고 맨 앞 값을 버려 고정 길이(48)를 유지한 채
// 그 배열 전체를 꺾은선 경로(d 속성)로 다시 그린다. CSS @keyframes 는 0개
// (궤적이 매 프레임 배열 연산으로 바뀌는 실수라 선언형으로 못 담는다, 398-01/02 와 같은 이유).
// picker-graph-sparkline-trail-length(JS, TRAIL_LENGTH)가 궤적 점 개수를 정한다 — 변형 03 의 조정 대상.
$duration: $dur-loop;
$easing: $ease-lerp-settle;
$color: $subject-blue;
@include stage($stage-orange);
.sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
.spk {
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;
}
.spk__head { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; padding: 0 $sp-1; }
.spk__title { font-size: 13px; line-height: 18px; font-weight: 800; letter-spacing: .02em; }
.spk__stat { font-size: 12px; line-height: 18px; font-weight: 800; font-variant-numeric: tabular-nums; color: rgba($subject-ink, .62); }
.spk__n { color: $subject-blue-deep; }
.spk__zone { padding: 0 $sp-1; }
.spk__chart { display: block; width: 100%; height: 116px; overflow: visible; }
.spk__base { stroke: rgba($subject-ink, .16); stroke-width: 2; }
.spk__area { fill: mix($subject-blue, $stage-paper, 88%); stroke: none; }
.spk__line { fill: none; stroke: $subject-blue-deep; stroke-width: 3; stroke-linejoin: round; stroke-linecap: round; }
.spk__dot { fill: $subject-blue-deep; stroke: $stage-paper; stroke-width: 2; }
.spk__track {
position: relative;
height: 24px;
margin: 0 $sp-1;
border-radius: $r-pill;
background: mix($subject-blue, $stage-paper, 14%);
}
.spk__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);
}
.spk__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;
}
.spk__grab:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.spk__ic { width: 13px; height: 13px; }
.spk__hint { margin: 0; padding: 0 $sp-1; font-size: 11px; line-height: 16px; color: rgba($subject-ink, .62); }
@media (max-width: 340px) {
.spk { padding: $sp-2; gap: $sp-1; }
.spk__hint { display: none; }
.spk__title { font-size: 12px; line-height: 16px; }
.spk__stat { font-size: 11px; line-height: 16px; }
.spk__chart { height: 108px; }
.spk__grab { width: 22px; height: 22px; }
}
// reduce — rAF 루프를 시작하지 않는다. 드래그·키 입력은 current=value 를 즉시 맞추고
// trail 에 한 번 push 한 뒤 paint() 를 그 자리에서 호출해 화면에 반영한다.
@media (prefers-reduced-motion: reduce) {
.spk.is-still .spk__line { 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: #ff4d1f;
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;
}
.spk {
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;
}
.spk__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 0 4px;
}
.spk__title {
font-size: 13px;
line-height: 18px;
font-weight: 800;
letter-spacing: 0.02em;
}
.spk__stat {
font-size: 12px;
line-height: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: rgba(23, 20, 26, 0.62);
}
.spk__n {
color: #0a4fe8;
}
.spk__zone {
padding: 0 4px;
}
.spk__chart {
display: block;
width: 100%;
height: 116px;
overflow: visible;
}
.spk__base {
stroke: rgba(23, 20, 26, 0.16);
stroke-width: 2;
}
.spk__area {
fill: rgb(28.2196078431%, 49.6156862745%, 96.8941176471%);
stroke: none;
}
.spk__line {
fill: none;
stroke: #0a4fe8;
stroke-width: 3;
stroke-linejoin: round;
stroke-linecap: round;
}
.spk__dot {
fill: #0a4fe8;
stroke: #ffffff;
stroke-width: 2;
}
.spk__track {
position: relative;
height: 24px;
margin: 0 4px;
border-radius: 999px;
background: rgb(88.5803921569%, 91.9843137255%, 99.5058823529%);
}
.spk__rail {
position: absolute;
left: 12px;
right: 12px;
top: 50%;
height: 3px;
transform: translateY(-50%);
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.spk__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;
}
.spk__grab:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.spk__ic {
width: 13px;
height: 13px;
}
.spk__hint {
margin: 0;
padding: 0 4px;
font-size: 11px;
line-height: 16px;
color: rgba(23, 20, 26, 0.62);
}
@media (max-width: 340px) {
.spk {
padding: 8px;
gap: 4px;
}
.spk__hint {
display: none;
}
.spk__title {
font-size: 12px;
line-height: 16px;
}
.spk__stat {
font-size: 11px;
line-height: 16px;
}
.spk__chart {
height: 108px;
}
.spk__grab {
width: 22px;
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.spk.is-still .spk__line {
transition: none !important;
}
}// 실제 동작: pointerdown/move/up 과 keydown(화살표·Home·End) 이 Value(0~100)을 바꾸면,
// 매 프레임 현재(관성 보간) 값을 좌표 배열(trail) 끝에 쌓고 맨 앞 값은 버려
// 고정 길이를 유지한 채 그 배열 전체를 꺾은선 경로(d 속성)로 다시 그린다.
// CSS @keyframes 없음(궤적이 매 프레임 배열 연산으로 바뀌는 실수라 선언형으로 못 담는다, 398-01/02 와 같은 이유).
var root = document.getElementById('spk');
var track = document.getElementById('track');
var grab = document.getElementById('grab');
var lineEl = document.getElementById('line');
var areaEl = document.getElementById('area');
var dotEl = document.getElementById('dot');
var valEl = document.getElementById('spk-val');
var live = document.getElementById('spk-live');
var TRAIL_LENGTH = 48; // picker-graph-sparkline-trail-length — 변형 03 의 조정 대상
var CHART_W = 480, CHART_H = 150;
var LERP_RATE = 0.24;
var AUTO_PERIOD = 1.3;
var STEP = 5;
var trail = [];
for (var i = 0; i < TRAIL_LENGTH; i++) trail.push(50);
var value = 50;
var current = 50;
var manual = false;
var autoT = 0;
var lastTs = 0;
var trackRect = null;
var rafId = 0;
function reduced() {
return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function yOf(v) { return CHART_H - (v / 100) * CHART_H; }
function buildPoints() {
var step = CHART_W / (TRAIL_LENGTH - 1);
var pts = [];
for (var i = 0; i < TRAIL_LENGTH; i++) pts.push([i * step, yOf(trail[i])]);
return pts;
}
function paint() {
var pts = buildPoints();
var d = 'M ' + pts[0][0] + ' ' + pts[0][1];
for (var i = 1; i < pts.length; i++) d += ' L ' + pts[i][0] + ' ' + pts[i][1];
lineEl.setAttribute('d', d);
areaEl.setAttribute('d', d + ' L ' + pts[pts.length - 1][0] + ' ' + CHART_H + ' L ' + pts[0][0] + ' ' + CHART_H + ' Z');
var last = pts[pts.length - 1];
dotEl.setAttribute('cx', String(last[0]));
dotEl.setAttribute('cy', String(last[1]));
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', 'Value ' + Math.round(value));
}
function announce() {
live.textContent = 'Value ' + Math.round(current) + ' added to the trail';
}
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 pushTrail() {
trail.shift();
trail.push(current);
}
function reflectNow() {
current = value;
pushTrail();
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 + 42 * Math.sin(autoT * omega) + 8 * Math.sin(autoT * omega * 2.7);
}
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;
pushTrail();
paint();
rafId = requestAnimationFrame(frame);
}
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);
}// SparklineTrailDrag — vanilla 판(run/398/demo/03-picker-graph-sparkline-trail-drag/index.html) 의 React 포트.
// 드래그·키보드로 값(0~100)을 정하면 매 프레임 관성 보간된 현재값을 좌표 배열(trail) 끝에
// 쌓고 맨 앞 값을 버려 고정 길이를 유지한 채 그 배열 전체를 꺾은선 경로로 다시 그린다.
import { useEffect, useRef } from "react";
import styles from "./SparklineTrailDrag.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number; pointerId: number };
const TRAIL_LENGTH = 48; // picker-graph-sparkline-trail-length — 변형 03 의 조정 대상
const CHART_W = 480, CHART_H = 150;
const LERP_RATE = 0.24;
const AUTO_PERIOD = 1.3;
const STEP = 5;
function yOf(v: number): number {
return CHART_H - (v / 100) * CHART_H;
}
export default function SparklineTrailDrag() {
const rootRef = useRef<HTMLElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
const grabRef = useRef<HTMLButtonElement | null>(null);
const lineRef = useRef<SVGPathElement | null>(null);
const areaRef = useRef<SVGPathElement | null>(null);
const dotRef = useRef<SVGCircleElement | null>(null);
const valRef = useRef<HTMLElement | null>(null);
const liveRef = useRef<HTMLSpanElement | null>(null);
const trail = useRef(new Array(TRAIL_LENGTH).fill(50)) as Box<number[]>;
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 buildPoints(): [number, number][] {
const step = CHART_W / (TRAIL_LENGTH - 1);
const pts: [number, number][] = [];
for (let i = 0; i < TRAIL_LENGTH; i++) pts.push([i * step, yOf(trail.current[i])]);
return pts;
}
function paint(): void {
const pts = buildPoints();
let d = "M " + pts[0][0] + " " + pts[0][1];
for (let i = 1; i < pts.length; i++) d += " L " + pts[i][0] + " " + pts[i][1];
lineRef.current?.setAttribute("d", d);
areaRef.current?.setAttribute(
"d",
d + " L " + pts[pts.length - 1][0] + " " + CHART_H + " L " + pts[0][0] + " " + CHART_H + " Z"
);
const last = pts[pts.length - 1];
dotRef.current?.setAttribute("cx", String(last[0]));
dotRef.current?.setAttribute("cy", String(last[1]));
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 pushTrail(): void {
trail.current.shift();
trail.current.push(current.current);
}
function reflectNow(): void {
current.current = value.current;
pushTrail();
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 + 42 * Math.sin(autoT.current * omega) + 8 * Math.sin(autoT.current * omega * 2.7);
}
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;
pushTrail();
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.spk + " 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 160"
preserveAspectRatio="none"
role="img"
aria-label="지나온 48개 값을 이은 꺾은선 그래프, 값이 바뀔 때마다 오래된 점은 왼쪽으로 밀려나고 새 점이 오른쪽에 더해진다"
>
<line className={styles.base} x1={0} y1={150} x2={480} y2={150} />
<path className={styles.area} ref={areaRef} />
<path className={styles.line} ref={lineRef} />
<circle className={styles.dot} ref={dotRef} r={5} />
</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>
);
}03 Picker That Leaves Its Drag Path as a Sparkline Trail — Measured area 32.755% · 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
Pickers01 Saturation and brightness square you dragCustom Color Picker: 9 JS Parts, No Library
Pickers04 Out-of-range fade value toggle — dates beyond an allowed-range value gain a rising fade value that marks them unpickableDate Picker UI: 9 Popover & Calendar Patterns
Pickers03 A Difficulty Slider That Overshoots and Springs Back Onto Its TierDifficulty Selector UI — 9 Computed Patterns