Skip to main content

ns-ui

Slider Loupe

Slider read through a circular magnifying loupe riding the thumb: the tick beneath sits sharp at 1.8x inside the lens while the rest of the scale rests soft-focus; rubber-banding past the bounds wobbles the magnification on release.

Usage

registry/core/slider-loupe/demo.tsx
"use client";

import { useEffect, useState } from "react";
import { LoupeSlider } from "./component";

const DURATION = 262; // 4:22

const fmtTime = (s: number) =>
  `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;

function IconSkipBack() {
  return (
    <svg
      width="14"
      height="14"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
    >
      <polygon points="19 20 9 12 19 4 19 20" fill="currentColor" stroke="none" />
      <line x1="5" y1="19" x2="5" y2="5" />
    </svg>
  );
}

function IconSkipForward() {
  return (
    <svg
      width="14"
      height="14"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
    >
      <polygon points="5 4 15 12 5 20 5 4" fill="currentColor" stroke="none" />
      <line x1="19" y1="5" x2="19" y2="19" />
    </svg>
  );
}

function IconPlay() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
      <polygon points="7 4 20 12 7 20 7 4" />
    </svg>
  );
}

function IconPause() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
      <rect x="6" y="4" width="4" height="16" rx="1" />
      <rect x="14" y="4" width="4" height="16" rx="1" />
    </svg>
  );
}

const buttonClass =
  "inline-flex h-9 w-9 items-center justify-center rounded-sm border border-border bg-transparent text-foreground transition-colors hover:border-foreground/30 hover:bg-foreground/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface";

export default function LoupeSliderDemo() {
  const [pos, setPos] = useState(74);
  const [playing, setPlaying] = useState(false);
  const [zoom, setZoom] = useState(175);

  useEffect(() => {
    if (!playing) return;
    const id = setInterval(() => setPos((p) => Math.min(DURATION, p + 1)), 1000);
    return () => clearInterval(id);
  }, [playing]);

  useEffect(() => {
    if (playing && pos >= DURATION) setPlaying(false);
  }, [playing, pos]);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / slider-loupe — the lens is the readout
      </p>

      <div className="w-full max-w-xl rounded-md border border-border bg-surface">
        {/* now playing */}
        <div className="flex items-center gap-4 border-b border-border px-6 py-5">
          <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-sm border border-border bg-foreground/[0.04] text-ns-muted">
            <svg
              width="20"
              height="20"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              aria-hidden
            >
              <line x1="4" y1="10" x2="4" y2="14" />
              <line x1="8" y1="7" x2="8" y2="17" />
              <line x1="12" y1="4" x2="12" y2="20" />
              <line x1="16" y1="8" x2="16" y2="16" />
              <line x1="20" y1="11" x2="20" y2="13" />
            </svg>
          </div>
          <div className="min-w-0 flex-1">
            <h2 className="truncate text-sm font-semibold text-foreground">
              Nocturne in Monochrome
            </h2>
            <p className="truncate text-sm text-ns-muted">
              Signal Terrain — Field Recordings, Vol. II
            </p>
          </div>
          <span className="rounded-full border border-border px-2.5 py-1 font-mono text-[10px] uppercase tracking-wider text-ns-muted">
            {playing ? "playing" : "paused"}
          </span>
        </div>

        {/* scrub */}
        <div className="px-6 pb-2 pt-5">
          <div className="flex items-baseline justify-between font-mono text-xs">
            <span className="tabular-nums text-foreground">{fmtTime(pos)}</span>
            <span className="tabular-nums text-ns-muted">{fmtTime(DURATION)}</span>
          </div>
          <LoupeSlider
            value={pos}
            onValueChange={setPos}
            min={0}
            max={DURATION}
            step={1}
            tickStep={10}
            majorEvery={3}
            formatLabel={fmtTime}
            formatValue={(v) => `${fmtTime(v)} of ${fmtTime(DURATION)}`}
            aria-label="Playback position"
            className="mt-1"
          />
        </div>

        {/* transport */}
        <div className="flex items-center justify-center gap-3 px-6 pb-6">
          <button
            type="button"
            aria-label="Back 15 seconds"
            className={buttonClass}
            onClick={() => setPos((p) => Math.max(0, p - 15))}
          >
            <IconSkipBack />
          </button>
          <button
            type="button"
            aria-label={playing ? "Pause" : "Play"}
            className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-border bg-foreground text-background transition-colors hover:bg-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
            onClick={() => setPlaying((p) => !p)}
          >
            {playing ? <IconPause /> : <IconPlay />}
          </button>
          <button
            type="button"
            aria-label="Forward 15 seconds"
            className={buttonClass}
            onClick={() => setPos((p) => Math.min(DURATION, p + 15))}
          >
            <IconSkipForward />
          </button>
        </div>

        {/* zoom */}
        <div className="border-t border-border px-6 py-5">
          <div className="flex items-baseline justify-between">
            <div>
              <h3 className="text-sm font-medium text-foreground">
                Waveform zoom
              </h3>
              <p className="mt-0.5 text-xs text-ns-muted">
                Magnification of the editor timeline.
              </p>
            </div>
            <span className="font-mono text-xs tabular-nums text-foreground">
              {zoom}%
            </span>
          </div>
          <LoupeSlider
            value={zoom}
            onValueChange={setZoom}
            min={50}
            max={400}
            step={25}
            tickStep={25}
            majorEvery={2}
            formatLabel={(v) => `${v}%`}
            aria-label="Waveform zoom level"
            className="mt-2"
          />
        </div>

        <div className="border-t border-border px-6 py-3">
          <p className="font-mono text-[11px] text-ns-muted">
            drag past either end for a rubber-band wobble · arrows / PageUp /
            Home steer the lens
          </p>
        </div>
      </div>
    </div>
  );
}

Component

registry/core/slider-loupe/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// LoupeSlider — single-value slider read through a circular magnifying loupe
// riding the thumb. The tick scale is drawn twice from cached layers: a base
// pass pre-blurred (ctx.filter blur(1.5px), rebuilt only on size/theme change)
// at 55% alpha, and a sharp 1.8x-magnified pass inside a 44px circular clip
// centered on the loupe — lens focus IS the readout. The loupe springs after
// the thumb (k=180, zeta=0.75, slight optical lag); dragging past the bounds
// rubber-bands at x0.3 (capped 24px) and release springs home (k=260,
// zeta=0.7) while magnification wobbles 1.8 -> 2.0 -> 1.8 over 350ms.
// Direct-DOM rAF that sleeps at a velocity epsilon, pauses offscreen, and
// carries a 1s forced-settle deadline. All canvas ink is derived from CSS
// tokens at mount and re-derived live on documentElement class changes.
// ---------------------------------------------------------------------------

const LENS_R = 22; // px lens radius (44px loupe)
const PAD = 28; // track inset so the lens never clips at the ends
const MAG_BASE = 1.8;
const MAG_PEAK = 2.0;
const WOBBLE_MS = 350;
const LOUPE_K = 180; // loupe chase spring, s^-2
const LOUPE_ZETA = 0.75;
const HOME_K = 260; // rubber-band return / glide spring, s^-2
const HOME_ZETA = 0.7;
const RUBBER = 0.3; // overshoot gain past the bounds
const RUBBER_MAX = 24; // px overshoot cap
const SETTLE_MS = 1000; // forced-settle deadline

type Vec3 = readonly [number, number, number];

function parseColor(raw: string): Vec3 | null {
  const s = raw.trim();
  if (s.startsWith("#")) {
    const hex = s.slice(1);
    if (hex.length === 3) {
      const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);
      const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);
      const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);
      return Number.isNaN(r + g + b) ? null : [r, g, b];
    }
    if (hex.length >= 6) {
      const r = parseInt(hex.slice(0, 2), 16);
      const g = parseInt(hex.slice(2, 4), 16);
      const b = parseInt(hex.slice(4, 6), 16);
      return Number.isNaN(r + g + b) ? null : [r, g, b];
    }
    return null;
  }
  const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

function rgba(c: Vec3, a: number) {
  return `rgba(${c[0]},${c[1]},${c[2]},${a})`;
}

// round a raw interval to a 1/2/5 decade so tick labels land on clean values
function niceStep(raw: number) {
  const mag = 10 ** Math.floor(Math.log10(Math.max(1e-9, raw)));
  const n = raw / mag;
  return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10) * mag;
}

export function LoupeSlider({
  value,
  defaultValue = 50,
  min = 0,
  max = 100,
  step = 1,
  tickStep,
  majorEvery = 5,
  formatLabel,
  formatValue,
  onValueChange,
  className = "",
  "aria-label": ariaLabel = "Value",
}: {
  /** controlled value; omit for uncontrolled */
  value?: number;
  /** uncontrolled initial value */
  defaultValue?: number;
  /** minimum allowed value */
  min?: number;
  /** maximum allowed value */
  max?: number;
  /** increment per keyboard/drag step */
  step?: number;
  /** minor tick interval in value units; auto-derived when omitted */
  tickStep?: number;
  /** every Nth minor tick is major and labeled */
  majorEvery?: number;
  /** tick label text (drawn on canvas) */
  formatLabel?: (v: number) => string;
  /** aria-valuetext for the current value */
  formatValue?: (v: number) => string;
  /** called with the new value on every drag/keyboard change */
  onValueChange?: (v: number) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** accessible name for the slider. Default "Value". */
  "aria-label"?: string;
}) {
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const loupeRef = useRef<HTMLDivElement>(null);
  const thumbRef = useRef<HTMLDivElement>(null);
  const engineRef = useRef<{
    dragStart: (clientX: number) => void;
    dragMove: (clientX: number) => void;
    dragEnd: () => void;
    glideTo: (v: number) => void;
    dragging: () => boolean;
  } | null>(null);

  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(() =>
    Math.min(max, Math.max(min, defaultValue))
  );
  const current = isControlled ? value : internal;
  const currentRef = useRef(current);
  currentRef.current = current;
  // mirrors :hover into data-hover — synthetic/dispatched pointer events never
  // flip Chromium's real hover pseudo-class, so an autoplay driver using them
  // needs an attribute to target (additive, doesn't change real-pointer feel)
  const [hover, setHover] = useState(false);

  const defaultLabel = (v: number) =>
    step < 1 ? v.toFixed(1) : String(Math.round(v));
  const fmtLabel = formatLabel ?? defaultLabel;
  const fmtValue = formatValue ?? fmtLabel;
  const labelRef = useRef(fmtLabel);
  labelRef.current = fmtLabel;

  const commitRef = useRef<(v: number) => void>(() => {});
  commitRef.current = (v: number) => {
    if (!isControlled) setInternal(v);
    onValueChange?.(v);
  };

  useEffect(() => {
    const container = containerRef.current;
    const canvas = canvasRef.current;
    const loupe = loupeRef.current;
    const thumb = thumbRef.current;
    if (!container || !canvas || !loupe || !thumb) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    // -- token-derived ink: read at mount, re-derived on theme class change --
    let fg: Vec3 = [237, 237, 237];
    let bd: Vec3 = [46, 46, 46];
    let monoFam = "ui-monospace, SFMono-Regular, Menlo, monospace";
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      fg =
        parseColor(cs.getPropertyValue("--foreground")) ??
        parseColor(getComputedStyle(container).color) ??
        fg;
      bd = parseColor(cs.getPropertyValue("--border")) ?? bd;
      const fam = getComputedStyle(container).fontFamily;
      if (fam) monoFam = fam;
    };
    derive();

    // -- hot-path state: locals only, never React state ---------------------
    let w = 0;
    let h = 0;
    let cy = 32;
    let dpr = 1;
    let sized = false;
    let raf = 0;
    let last = 0;
    let visible = true;
    let tx = 0; // thumb x (canvas coords)
    let tv = 0;
    let lx = 0; // loupe x, springs after the thumb
    let lv = 0;
    let targetX = 0;
    let mag = MAG_BASE;
    let wobbleAt = -1; // performance.now() when the wobble started
    let dragging = false;
    let committed = currentRef.current;
    let deadline = 0; // forced-settle deadline (performance.now())

    // cached layers: sharp scale at 2x supersample (stays crisp when the lens
    // magnifies it), blurred base derived from it once per size/theme change
    const sharp = document.createElement("canvas");
    const soft = document.createElement("canvas");

    const span = () => Math.max(1e-9, max - min);
    const xFor = (v: number) =>
      PAD + ((v - min) / span()) * Math.max(1, w - PAD * 2);
    const quantize = (v: number) => {
      const q = min + Math.round((v - min) / step) * step;
      return Math.min(max, Math.max(min, Number(q.toFixed(6))));
    };

    // geometry hugs the lens center: the clip radius is LENS_R*mag/MAG_BASE
    // and the draw pass is scaled by `mag` around the same center, so a
    // pre-mag point at offset d from (lx,cy) lands at d*mag post-transform —
    // the mag-INDEPENDENT safe envelope is |d| <= LENS_R/MAG_BASE (R0),
    // regardless of the 1.8->2.0 wobble. That only bounds height though:
    // labels also need their magnified WIDTH inside the chord at that
    // offset, so they sit at the lens's vertical center (dy small, near 0)
    // for the widest available chord, AND get measured + font-shrunk per
    // label so arbitrary formatLabel strings (not just 2-digit numbers)
    // never get sliced by the rim.
    const drawScale = (g: CanvasRenderingContext2D) => {
      let minor = tickStep && tickStep > 0 ? tickStep : niceStep(span() / 60);
      while (span() / minor > 240) minor *= 2; // cap the tick count
      const every = majorEvery > 0 ? Math.round(majorEvery) : 5;
      const axisY = cy - 12;
      g.strokeStyle = rgba(bd, 1);
      g.lineWidth = 1;
      g.beginPath();
      g.moveTo(PAD, axisY + 0.5);
      g.lineTo(w - PAD, axisY + 0.5);
      g.stroke();
      const count = Math.floor(span() / minor + 1e-6);
      // minor ticks
      g.strokeStyle = rgba(fg, 0.45);
      g.beginPath();
      for (let i = 0; i <= count; i++) {
        if (i % every === 0) continue;
        const x = Math.round(xFor(min + i * minor)) + 0.5;
        g.moveTo(x, axisY);
        g.lineTo(x, axisY + 6);
      }
      g.stroke();
      // major ticks + labels
      g.strokeStyle = rgba(fg, 0.8);
      g.beginPath();
      for (let i = 0; i <= count; i += every) {
        const x = Math.round(xFor(min + i * minor)) + 0.5;
        g.moveTo(x, axisY);
        g.lineTo(x, axisY + 10);
      }
      g.stroke();
      // labels: near lens-center for max chord width, font shrunk per-label
      // when it would still overflow that chord (see comment above drawScale)
      const R0 = LENS_R / MAG_BASE;
      const labelDy = 2; // small clearance below the major tick tips (-2)
      const maxWidth =
        2 * Math.sqrt(Math.max(0, R0 * R0 - labelDy * labelDy)) * 0.92;
      const baseFontPx = 9;
      g.textAlign = "center";
      g.textBaseline = "middle";
      g.fillStyle = rgba(fg, 0.9);
      for (let i = 0; i <= count; i += every) {
        const v = Number((min + i * minor).toFixed(6));
        const text = labelRef.current(v);
        g.font = `500 ${baseFontPx}px ${monoFam}`;
        const measured = g.measureText(text).width;
        const fontPx =
          measured > maxWidth && measured > 0
            ? Math.max(5, baseFontPx * (maxWidth / measured))
            : baseFontPx;
        if (fontPx !== baseFontPx) g.font = `500 ${fontPx}px ${monoFam}`;
        g.fillText(text, xFor(v), cy + labelDy);
      }
    };

    const buildLayers = () => {
      if (!sized) return;
      const S = dpr * 2; // supersample so the 1.8-2.0x pass stays sharp
      sharp.width = Math.ceil(w * S);
      sharp.height = Math.ceil(h * S);
      const sctx = sharp.getContext("2d");
      if (!sctx) return;
      sctx.setTransform(S, 0, 0, S, 0, 0);
      sctx.clearRect(0, 0, w, h);
      drawScale(sctx);
      soft.width = Math.ceil(w * dpr);
      soft.height = Math.ceil(h * dpr);
      const bctx = soft.getContext("2d");
      if (!bctx) return;
      bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      bctx.clearRect(0, 0, w, h);
      bctx.filter = "blur(1.5px)";
      if (bctx.filter.indexOf("blur") !== -1) {
        bctx.drawImage(sharp, 0, 0, w, h);
        bctx.filter = "none";
      } else {
        // no ctx.filter support: approximate with a 9-tap ring average
        bctx.globalAlpha = 1 / 9;
        bctx.drawImage(sharp, 0, 0, w, h);
        for (let k = 0; k < 8; k++) {
          const a = (k / 8) * Math.PI * 2;
          bctx.drawImage(sharp, Math.cos(a) * 1.3, Math.sin(a) * 1.3, w, h);
        }
        bctx.globalAlpha = 1;
      }
    };

    // full clear + composite from cached layers every frame — no filter work
    // on the hot path, no alpha accumulation
    const render = () => {
      if (!sized) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      ctx.globalAlpha = 0.55;
      ctx.drawImage(soft, 0, 0, w, h);
      ctx.globalAlpha = 1;
      const r = LENS_R * (mag / MAG_BASE);
      ctx.save();
      ctx.beginPath();
      ctx.arc(lx, cy, r, 0, Math.PI * 2);
      ctx.clip();
      ctx.clearRect(0, 0, w, h); // knock the soft pass out of the lens
      ctx.translate(lx, cy);
      ctx.scale(mag, mag);
      ctx.translate(-lx, -cy);
      ctx.drawImage(sharp, 0, 0, w, h);
      ctx.restore();
      // loupe + thumb are DOM nodes on OFFSET transforms from the container's
      // top-left anchor — never absolute canvas coords
      const ringScale = mag / MAG_BASE;
      loupe.style.transform = `translate3d(${(lx - LENS_R).toFixed(2)}px, ${(
        cy - LENS_R
      ).toFixed(2)}px, 0) scale(${ringScale.toFixed(4)})`;
      thumb.style.transform = `translate3d(${(tx - 12).toFixed(2)}px, ${(
        cy - 12
      ).toFixed(2)}px, 0)`;
    };

    const resize = () => {
      const rect = container.getBoundingClientRect();
      if (rect.width < PAD * 2 + 8 || rect.height < 8) {
        sized = false; // zero-size guard — never draw into nothing
        return;
      }
      w = rect.width;
      h = rect.height;
      cy = h / 2;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      // a <canvas> is a replaced element: size the CSS box EXPLICITLY, or it
      // renders at dpr scale
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      sized = true;
      buildLayers();
      targetX = xFor(currentRef.current);
      if (!raf) {
        tx = targetX;
        tv = 0;
        lx = targetX;
        lv = 0;
      }
      render();
    };
    resize();

    const loop = (now: number) => {
      raf = 0;
      const dt = last ? Math.min(0.033, (now - last) / 1000) : 1 / 60;
      last = now;
      let active = false;

      if (dragging) {
        active = true; // thumb is pinned to the pointer; loupe still chases
      } else {
        const c = 2 * HOME_ZETA * Math.sqrt(HOME_K);
        tv += (-HOME_K * (tx - targetX) - c * tv) * dt;
        tx += tv * dt;
        if (Math.abs(tx - targetX) < 0.05 && Math.abs(tv) < 0.5) {
          tx = targetX;
          tv = 0;
        } else active = true;
      }

      const cl = 2 * LOUPE_ZETA * Math.sqrt(LOUPE_K);
      lv += (-LOUPE_K * (lx - tx) - cl * lv) * dt;
      lx += lv * dt;
      if (!dragging && Math.abs(lx - tx) < 0.05 && Math.abs(lv) < 0.5) {
        lx = tx;
        lv = 0;
      } else if (Math.abs(lx - tx) >= 0.05 || Math.abs(lv) >= 0.5) {
        active = true;
      }

      if (wobbleAt >= 0) {
        const p = (now - wobbleAt) / WOBBLE_MS;
        if (p >= 1) {
          wobbleAt = -1;
          mag = MAG_BASE;
        } else {
          // half-sine out-and-back, tail damped: 1.8 -> 2.0 -> 1.8
          mag =
            MAG_BASE +
            (MAG_PEAK - MAG_BASE) * Math.sin(Math.PI * p) * (1 - 0.25 * p);
          active = true;
        }
      }

      // forced-settle deadline — springs AND wobble both answer to it
      if (active && !dragging && now > deadline) {
        tx = targetX;
        tv = 0;
        lx = targetX;
        lv = 0;
        mag = MAG_BASE;
        wobbleAt = -1;
        active = false;
      }

      render();
      if (active && visible && !document.hidden) {
        raf = requestAnimationFrame(loop);
      }
    };

    const wake = () => {
      if (!raf && sized) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    // -- interaction (called from React handlers via engineRef) -------------
    const dragMove = (clientX: number) => {
      if (!sized) return;
      const rect = canvas.getBoundingClientRect();
      const x = clientX - rect.left;
      const lo = PAD;
      const hi = w - PAD;
      let px = x;
      if (x < lo) px = lo + Math.max(-RUBBER_MAX, (x - lo) * RUBBER);
      else if (x > hi) px = hi + Math.min(RUBBER_MAX, (x - hi) * RUBBER);
      const clamped = Math.min(hi, Math.max(lo, x));
      const v = quantize(min + ((clamped - lo) / Math.max(1, hi - lo)) * span());
      targetX = xFor(v); // release springs home to the committed value
      if (v !== committed) {
        committed = v;
        commitRef.current(v);
      }
      deadline = performance.now() + SETTLE_MS;
      if (reduced) {
        tx = px;
        lx = px;
        tv = 0;
        lv = 0;
        render();
        return;
      }
      tx = px;
      wake();
    };

    const dragStart = (clientX: number) => {
      if (!sized) return;
      dragging = true;
      committed = currentRef.current;
      dragMove(clientX);
    };

    const dragEnd = () => {
      if (!dragging) return;
      dragging = false;
      const past = tx < PAD - 0.5 || tx > w - PAD + 0.5;
      deadline = performance.now() + SETTLE_MS;
      if (reduced) {
        tx = targetX;
        lx = targetX;
        tv = 0;
        lv = 0;
        render(); // no wobble, instant reposition — magnification itself stays
        return;
      }
      if (past) wobbleAt = performance.now(); // refractive wobble on release
      wake();
    };

    const glideTo = (v: number) => {
      if (dragging || !sized) return;
      committed = v;
      targetX = xFor(v);
      deadline = performance.now() + SETTLE_MS;
      if (reduced) {
        tx = targetX;
        lx = targetX;
        tv = 0;
        lv = 0;
        render();
        return;
      }
      wake();
    };

    engineRef.current = {
      dragStart,
      dragMove,
      dragEnd,
      glideTo,
      dragging: () => dragging,
    };

    // -- observers ----------------------------------------------------------
    const ro = new ResizeObserver(resize);
    ro.observe(container);
    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible) wake();
    });
    io.observe(container);
    // live theme re-derive: rebuild the blur layer on documentElement flips
    const mo = new MutationObserver(() => {
      derive();
      buildLayers();
      render();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });
    const onVis = () => {
      if (!document.hidden) wake();
    };
    document.addEventListener("visibilitychange", onVis);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      engineRef.current = null;
    };
  }, [min, max, step, tickStep, majorEvery]);

  // keyboard + external value changes glide the loupe on the same spring
  useEffect(() => {
    engineRef.current?.glideTo(current);
  }, [current]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    let next: number | null = null;
    const v = currentRef.current;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = v + step;
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = v - step;
        break;
      case "PageUp":
        next = v + step * 10;
        break;
      case "PageDown":
        next = v - step * 10;
        break;
      case "Home":
        next = min;
        break;
      case "End":
        next = max;
        break;
      default:
        return;
    }
    e.preventDefault();
    next = Math.min(max, Math.max(min, Number(next.toFixed(6))));
    if (next !== v) commitRef.current(next);
  };

  return (
    <div
      ref={containerRef}
      data-hover={hover}
      className={`group relative h-16 w-full cursor-ew-resize touch-none select-none font-mono ${className}`}
      onPointerDown={(e) => {
        e.preventDefault();
        e.currentTarget.setPointerCapture(e.pointerId);
        thumbRef.current?.focus({ preventScroll: true });
        engineRef.current?.dragStart(e.clientX);
      }}
      onPointerMove={(e) => {
        if (engineRef.current?.dragging()) engineRef.current.dragMove(e.clientX);
      }}
      onPointerUp={() => engineRef.current?.dragEnd()}
      onPointerCancel={() => engineRef.current?.dragEnd()}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="pointer-events-none absolute left-0 top-0"
      />
      <div
        ref={loupeRef}
        aria-hidden
        className="pointer-events-none absolute left-0 top-0 h-11 w-11 rounded-full border border-border shadow-[0_2px_10px_-4px_rgba(0,0,0,0.4)] transition-colors duration-200 will-change-transform group-hover:border-foreground/40 group-data-[hover=true]:border-foreground/40"
      />
      <div
        ref={thumbRef}
        role="slider"
        tabIndex={0}
        aria-label={ariaLabel}
        aria-orientation="horizontal"
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={current}
        aria-valuetext={fmtValue(current)}
        onKeyDown={onKeyDown}
        className="absolute left-0 top-0 h-6 w-6 rounded-full outline-none will-change-transform focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
      >
        <span
          aria-hidden
          className="absolute left-1/2 top-1/2 h-4 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/80 transition-colors duration-200 group-hover:bg-foreground group-data-[hover=true]:bg-foreground"
        />
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/slider-loupe.tsx, so point the import there.

Use when a slider read through a circular magnifying loupe that rides the thumb, sharpening the tick beneath it while the rest of the scale stays soft-focus; use for precise value-reading (audio scrub, zoom level), not a destructive confirm gesture; pick slider-vernier instead when the final digit needs ceremony and precision is the whole point, slider-range-shear when two bounds are being set rather than one value, or confirm-slide-shatter when the drag is the destructive confirm this line rules out.

Install

npx shadcn add https://design.helpmarq.com/r/slider-loupe.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build a single-value slider whose readout is a 44px circular magnifying loupe riding the thumb. RENDER: canvas 2D draws the tick scale + labels twice from cached layers rebuilt only on size/theme change — a sharp layer supersampled at 2x dpr (so magnified pixels stay crisp) and a soft base derived from it once via ctx.filter blur(1.5px) into an offscreen canvas (9-tap ring-average fallback when ctx.filter is unsupported). Per frame: full clear, soft layer at 55% alpha, then a circular clip centered on the loupe (radius 22 x mag/1.8), clearRect inside the clip to knock out the soft pass, and the sharp layer drawn through translate(lens) scale(mag) translate(-lens) at 1.8x. No per-frame filter work, no alpha accumulation. Canvas gets EXPLICIT style.width/height (replaced element — inset does not size it); DPR clamp 2. Thumb (2x16px foreground hairline in a 24px focus circle) and loupe ring (44px, border-border, hover border-foreground/40) are DOM nodes on offset transforms from the container's top-left anchor, never absolute canvas coords. MOTION: loupe position springs after the thumb at k=180 s^-2, zeta=0.75 for slight optical lag; the ring also scales mag/1.8 so the wobble reads refractive. Dragging past min/max maps overshoot x0.3 capped 24px; release springs home k=260, zeta=0.7 while magnification wobbles 1.8 -> 2.0 -> 1.8 on a damped half-sine over 350ms. Forced-settle deadline 1.0s covers springs AND wobble. Direct-DOM rAF: sleeps at velocity epsilon (0.05px / 0.5px/s), pauses offscreen via IntersectionObserver and on document.hidden, zero-size containers guarded before any draw. INTERACTION: focusable thumb with role=slider, aria-valuemin/max/now/valuetext, aria-orientation; ArrowLeft/Right/Up/Down +-step, PageUp/Down +-10 steps, Home/End; keyboard and external value changes glide on the same k=260 spring with the loupe chasing; pointer drag with setPointerCapture pins the thumb while the loupe lags; accent focus-visible ring with background ring-offset. TOKENS: all canvas ink (--foreground, --border) and the mono label font are read via getComputedStyle at mount and re-derived by a MutationObserver on documentElement class changes, rebuilding the blur layer on theme flip — works in both themes. REDUCED MOTION: thumb and loupe reposition instantly, no wobble; the 1.8x magnification itself stays (informational, not decorative). Tick generation: minor interval prop or auto 1/2/5-decade nice step, every Nth tick major + labeled, count capped at 240, custom formatLabel. Controlled/uncontrolled value, typed props with defaults so the bare component mounts. Tear down rAF, ResizeObserver, IntersectionObserver, MutationObserver, and the visibilitychange listener on unmount. DEMO: audio playback card on a padded surface — loupe scrub slider over an m:ss timestamp scale with live mono readout, skip/play/pause transport, plus a second instance as a waveform zoom-level control labeled in percent.

Props

PropTypeDefaultDescription
value?number—controlled value; omit for uncontrolled
defaultValue?number50uncontrolled initial value
min?number0minimum allowed value
max?number100maximum allowed value
step?number1increment per keyboard/drag step
tickStep?number—minor tick interval in value units; auto-derived when omitted
majorEvery?number5every Nth minor tick is major and labeled
formatLabel?(v: number) => string—tick label text (drawn on canvas)
formatValue?(v: number) => string—aria-valuetext for the current value
onValueChange?(v: number) => void—called with the new value on every drag/keyboard change
className?string—extra classes merged onto the rendered root element
aria-label?string"Value"accessible name for the slider. Default "Value".