Slider value pop
A seat slider whose value bubble pops while you drag, ticks light up, numbers roll and steps snap with a bounce.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const MIN = 1, MAX = 16, PER_SEAT = 9, START = 4, INTRO_TO = 12;
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const TICKS = Array.from({ length: MAX - MIN + 1 }, (_, i) => MIN + i);
const at = (v: number) => (v - MIN) / (MAX - MIN);
// Each digit is a 0 to 9 strip that slides to its value, so numbers roll instead of swapping.
function Roll({ value }: { value: string }) {
const chars = [...value];
return (
<span className={styles.digits} aria-hidden="true">
{chars.map((c, i) => {
const place = chars.length - i;
if (!/\d/.test(c)) return <span key={`s${place}`} className={styles.sym}>{c}</span>;
return (
<span key={`d${place}`} className={styles.digit}>
<span className={styles.strip} style={{ "--d": Number(c) } as CSSProperties}>
{DIGITS.map((d) => <span key={d}>{d}</span>)}
</span>
</span>
);
})}
</span>
);
}
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const fill = useRef<HTMLSpanElement>(null);
const thumb = useRef<HTMLSpanElement>(null);
const bubble = useRef<HTMLSpanElement>(null);
const spring = useRef({ x: at(START), v: 0, raf: 0 });
const touched = useRef(false); // the intro has run or someone used the slider
const user = useRef(false); // someone used the slider
const popTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [seats, setSeats] = useState(START);
const [active, setActive] = useState(false);
// A small spring chases the snapped value, so each step lands with a slight overshoot.
useEffect(() => {
const els = [fill.current, thumb.current, bubble.current];
const s = spring.current;
const target = at(seats);
const draw = () => {
els[0]?.style.setProperty("transform", `scaleX(${s.x})`);
els[1]?.style.setProperty("left", `${s.x * 100}%`);
els[2]?.style.setProperty("left", `${s.x * 100}%`);
};
if (reduced) { s.x = target; s.v = 0; draw(); return; }
let last = performance.now();
const frame = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += ((target - s.x) * 520 - s.v * 30) * dt;
s.x += s.v * dt;
draw();
if (Math.abs(s.v) > 0.0005 || Math.abs(target - s.x) > 0.0005) s.raf = requestAnimationFrame(frame);
else { s.x = target; draw(); }
};
cancelAnimationFrame(s.raf);
s.raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(s.raf);
}, [seats, reduced]);
// Once in view, slide from 4 to 12 seats to show the effect, unless someone got there first.
useEffect(() => {
if (reduced) { if (!touched.current) setSeats(INTRO_TO); return; }
if (!inView || touched.current) return;
touched.current = true;
setActive(true);
let v = START, rest: ReturnType<typeof setTimeout> | undefined;
const timer = setInterval(() => {
if (user.current) { clearInterval(timer); return; }
v += 1;
setSeats(v);
if (v >= INTRO_TO) { clearInterval(timer); rest = setTimeout(() => setActive(false), 500); }
}, 120);
return () => {
clearInterval(timer);
clearTimeout(rest);
// Cut short (scrolled away): finish at the intro's end value rather than mid slide.
if (v < INTRO_TO && !user.current) { setSeats(INTRO_TO); setActive(false); }
};
}, [inView, reduced]);
useEffect(() => () => clearTimeout(popTimer.current), []);
// Keyboard steps pop the bubble briefly, as a drag would.
const claim = () => { touched.current = true; user.current = true; };
const pop = () => {
setActive(true);
clearTimeout(popTimer.current);
popTimer.current = setTimeout(() => setActive(false), 900);
};
const price = seats * PER_SEAT;
return (
<div ref={root} className={styles.root}>
<div className={styles.card}>
<div className={styles.head}>
<p className={styles.label}>Seats: <b><Roll value={String(seats)} /></b></p>
<p className={styles.price}><Roll value={`$${price}`} /><span>/ mo</span></p>
</div>
<div className={styles.slider} data-active={active}>
<div className={styles.track} aria-hidden="true">
<span className={styles.rail} />
<span ref={fill} className={styles.fill} />
{TICKS.map((t) => (
<span key={t} className={styles.tick} data-on={t <= seats} style={{ left: `${at(t) * 100}%` }} />
))}
<span ref={thumb} className={styles.thumb} />
<span ref={bubble} className={styles.bubble}><span>{seats}</span></span>
</div>
<input
className={styles.input}
type="range"
min={MIN}
max={MAX}
step={1}
value={seats}
aria-label="Seats"
aria-valuetext={`${seats} seats, $${price} per month`}
onChange={(e) => { claim(); setSeats(Number(e.target.value)); }}
onPointerDown={() => { claim(); clearTimeout(popTimer.current); setActive(true); }}
onPointerUp={() => setActive(false)}
onPointerCancel={() => setActive(false)}
onKeyDown={(e) => { if (e.key.startsWith("Arrow") || e.key === "Home" || e.key === "End" || e.key.startsWith("Page")) { claim(); pop(); } }}
onBlur={() => setActive(false)}
/>
</div>
<div className={styles.scale} aria-hidden="true"><span>{MIN}</span><span>{MAX}</span></div>
<p className={styles.note}>${PER_SEAT} per seat, billed monthly. Add seats any time.</p>
</div>
</div>
);
}
demo.module.css
.root {
container-type: size; width: 100%; height: 100%; display: grid; place-items: center; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.card {
width: min(460px, 100%); box-sizing: border-box; display: grid; gap: 6px; padding: 18px 20px 16px;
border-radius: 16px; border: 1px solid var(--line); background: var(--white);
}
.card p { margin: 0; }
.head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.label { display: flex; align-items: center; gap: 5px; font-size: 15px; font-weight: 600; color: var(--muted); }
.label b { color: var(--ink); font-weight: 750; }
.price { display: flex; align-items: flex-end; gap: 4px; font-size: clamp(22px, 7cqi, 30px); font-weight: 750; letter-spacing: -0.02em; }
.price > span:last-child { margin-bottom: .45em; font-size: 13px; font-weight: 600; letter-spacing: 0; color: var(--muted); }
.digits { display: inline-flex; height: 1.15em; line-height: 1.15em; font-variant-numeric: tabular-nums; }
.digit { display: inline-block; height: 1.15em; overflow: hidden; }
.strip { display: flex; flex-direction: column; translate: 0 calc(var(--d) * -1.15em); transition: translate .45s var(--out); }
.strip span, .sym { display: block; height: 1.15em; }
.sym { display: inline-block; }
/* The real range input sits on top, invisible, with a thumb the same 24px as the drawn one,
so pointer, touch and keyboard all drive the native control. */
.slider { position: relative; height: 44px; margin-top: 30px; }
.track { position: absolute; inset: 0 12px; pointer-events: none; }
.rail, .fill { position: absolute; left: 0; right: 0; top: 50%; height: 6px; margin-top: -3px; border-radius: 3px; }
.rail { background: var(--line); }
.fill { background: var(--accent-text); transform-origin: left; transform: scaleX(0); }
.tick {
position: absolute; top: 50%; width: 4px; height: 4px; margin: -2px 0 0 -2px; border-radius: 50%;
background: var(--muted); opacity: .45; transition: background-color .2s, opacity .2s, scale .3s var(--out);
}
.tick[data-on="true"] { background: var(--white); opacity: .9; }
.thumb {
position: absolute; top: 50%; left: 0; width: 24px; height: 24px; margin: -12px 0 0 -12px; border-radius: 50%; box-sizing: border-box;
background: var(--white); border: 2px solid var(--accent-text); box-shadow: 0 4px 12px -4px color-mix(in srgb, var(--ink) 45%, transparent);
transition: scale .3s var(--out), box-shadow .3s var(--out);
}
.slider[data-active="true"] .thumb { scale: 1.15; box-shadow: 0 0 0 6px var(--accent-pale), 0 4px 12px -4px color-mix(in srgb, var(--ink) 45%, transparent); }
.slider:has(.input:focus-visible) .thumb { outline: 2px solid var(--accent-text); outline-offset: 3px; }
/* The value bubble hides above the thumb and pops up, slightly oversized, while the value changes. */
.bubble {
position: absolute; bottom: calc(50% + 18px); left: 0; transform-origin: 50% 100%;
display: grid; place-items: center; min-width: 30px; height: 26px; padding: 0 8px; box-sizing: border-box; border-radius: 8px;
background: var(--ink); color: var(--white); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums;
scale: .6; opacity: 0; translate: -50% 8px;
transition: scale .35s var(--out), opacity .2s, translate .35s var(--out);
}
.bubble::after {
content: ""; position: absolute; left: 50%; bottom: -4px; width: 8px; height: 8px; margin-left: -4px; rotate: 45deg;
background: inherit; border-radius: 0 0 2px 0;
}
.slider[data-active="true"] .bubble { scale: 1.1; opacity: 1; translate: -50% 0; }
.input {
position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer;
appearance: none; -webkit-appearance: none; background: none; touch-action: pan-y;
}
.input::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 44px; }
.input::-moz-range-thumb { width: 24px; height: 44px; border: 0; }
.scale { display: flex; justify-content: space-between; padding-inline: 6px; font-size: 11px; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
.note { font-size: 12px; color: var(--muted); }
@container (max-height: 250px) {
.card { padding: 14px 16px 12px; gap: 4px; }
.slider { margin-top: 26px; }
}
@media (prefers-reduced-motion: reduce) {
.strip, .tick, .thumb, .bubble { transition: none; }
}
Use it for
- Seat, quantity or budget pickers on pricing pages
- Settings where the exact number matters while dragging
- Sliders with a small number of discrete steps
Avoid
- Continuous values with hundreds of steps, where ticks become noise
- Precise entry, where a number field is faster
How it works
A native range input sits on top of the drawn track with opacity 0 and a 24px thumb, matching the drawn thumb, so the browser handles pointer, touch and keys. Each new value sets a target for a small spring in requestAnimationFrame that writes the fill scale and the thumb and bubble positions through refs, landing each step with a slight overshoot before the loop stops. Ticks light up from a data attribute, and the seat count and price roll as 0 to 9 digit strips.
Accessibility
The control is a real input type range with a label and aria-valuetext that reads seats and price, and a focus ring on the drawn thumb. Arrow keys pop the bubble as a drag does. Reduced motion skips the intro slide, the spring and the rolling, and shows values instantly.