Swish UI
All componentsFeedback and forms Free

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.

Found on