## Slider Detents — Gesture Stops you can feel. Docs: https://www.interior.dev/docs/slider-detents Reference: https://www.interior.dev/reference/slider-detents License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/slider-detents.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { SliderDetents } from "@/components/interior/slider-detents"; const SPEEDS = [ { value: 0.5, label: "0.5×" }, { value: 1, label: "Normal" }, { value: 1.5, label: "1.5×" }, { value: 2, label: "2×" }, ]; const speed = (v: number) => `${v.toFixed(2)}×`; export function PlaybackSpeed({ onCommit }: { onCommit: (rate: number) => void }) { const [rate, setRate] = useState(1); return ( { setRate(next); onCommit(next); }} /> ); } ``` ### Props - `value` (`number`) Controlled value. Always one of the step grid or a detent, never a raw pointer float. - `onValueChange` (`(value: number) => void`) Fires only when the resolved value actually changes, so dragging across one step emits once. - `min` (`number`) — default: `0` Lower bound. Reported as aria-valuemin. - `max` (`number`) — default: `100` Upper bound. Reported as aria-valuemax. - `step` (`number`) — default: `1` The free grid between detents. Arrow keys move by exactly this, ignoring capture. - `detents` (`readonly (number | SliderDetent)[]`) — default: `[]` The values worth stopping on. A bare number draws a mark; { value, label } also names it in the readout and in aria-valuetext. - `pull` (`number`) — default: `(max - min) * 0.045` Capture radius in value units. Inside it the pointer hands the value to the detent. - `label` (`string`) — default: `"Value"` Visible label, wired to the slider with aria-labelledby via useId. - `format` (`(value: number) => string`) — default: `String` Formats the readout and the spoken value. Also sizes the readout cell up front. - `disabled` (`boolean`) — default: `false` Sets aria-disabled and refuses pointer and key input without removing the control from the page. - `haptic` (`boolean`) — default: `true` Fires navigator.vibrate on detent capture where supported, once per crossing. - `className` (`string`) — default: `""` Appended last on the wrapper. ### Behavior notes - A continuous slider cannot be landed on the one value that matters, so within the pull radius the pointer hands the value to the detent and the thumb springs ahead of the finger to show it happened. - A sticky detent that also swallows the keyboard would make 0.95 unreachable, so arrow keys move by exactly one step and ignore capture entirely; Shift with an arrow, PageUp and PageDown jump detent to detent. - The readout never reflows: the widest reachable string — every formatted bound and every detent label — is measured into the same grid cell up front, so the number holds still while the label beside it crossfades in. - The gesture reports discrete values, so React re-renders once per step instead of once per frame; the thumb, the fill and the tick marks are one spring writing transforms, and the fill is a clipped translate rather than an animated width. - Position is expressed as a percentage translate inside a thumb-inset track, so there is no measurement pass, no zero-width first paint and no jump on hydration. - Screen readers get one aria-valuetext per committed step — the formatted value plus the detent name when it is parked on one — and the visible readout is aria-hidden so the value is never announced twice. ### Source (`components/interior/slider-detents.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { motion, useMotionTemplate, useReducedMotion, useSpring, } from "motion/react"; const CARRIAGE = { stiffness: 520, damping: 34, mass: 0.45 } as const; const GRAB = { type: "spring", stiffness: 700, damping: 46, mass: 0.5 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8, } as const; const INSTANT = { duration: 0 } as const; const THUMB = 18; const plain = (value: number) => String(value); function tidy(value: number) { return Math.round(value * 1e6) / 1e6; } export type SliderDetent = { value: number; label?: string }; const NONE: readonly (number | SliderDetent)[] = []; export type UseSliderDetentsOptions = { value: number; onValueChange: (value: number) => void; min?: number; max?: number; step?: number; detents?: readonly (number | SliderDetent)[]; pull?: number; thumbSize?: number; disabled?: boolean; haptic?: boolean; format?: (value: number) => string; label?: string; labelledBy?: string; }; export function useSliderDetents({ value, onValueChange, min = 0, max = 100, step = 1, detents = NONE, pull, thumbSize = THUMB, disabled = false, haptic = true, format = plain, label, labelledBy, }: UseSliderDetentsOptions) { const trackRef = useRef(null); const [dragging, setDragging] = useState(false); const list = useMemo( () => detents.map((d) => (typeof d === "number" ? { value: d } : d)), [detents], ); const range = max - min; const grab = pull ?? range * 0.045; const emit = useRef(onValueChange); emit.current = onValueChange; const emitted = useRef(value); emitted.current = value; const activeDetent = useMemo( () => list.findIndex((d) => tidy(d.value) === tidy(value)), [list, value], ); const marked = useRef(activeDetent); const held = useRef(false); const commit = useCallback( (next: number) => { const settled = Math.min(max, Math.max(min, tidy(next))); const index = list.findIndex((d) => tidy(d.value) === settled); if (index !== marked.current) { marked.current = index; if (haptic && index >= 0) navigator.vibrate?.(6); } if (settled !== emitted.current) { emitted.current = settled; emit.current(settled); } }, [haptic, list, max, min], ); const capture = useCallback( (clientX: number) => { const el = trackRef.current; if (!el || range <= 0) return null; const rect = el.getBoundingClientRect(); const travel = rect.width - thumbSize; if (travel <= 0) return null; const ratio = (clientX - rect.left - thumbSize / 2) / travel; const raw = Math.min(max, Math.max(min, min + ratio * range)); let index = -1; let nearest = grab; for (let i = 0; i < list.length; i += 1) { const distance = Math.abs(raw - list[i].value); if (distance <= nearest) { nearest = distance; index = i; } } if (index >= 0) return list[index].value; return min + Math.round((raw - min) / step) * step; }, [grab, list, max, min, range, step, thumbSize], ); const release = useCallback(() => { if (!held.current) return; held.current = false; setDragging(false); }, []); const toDetent = useCallback( (direction: number) => { const sorted = list.map((d) => d.value).toSorted((a, b) => a - b); const forward = sorted.find((d) => d > value + 1e-6); const backward = sorted.findLast((d) => d < value - 1e-6); const target = direction > 0 ? forward : backward; commit(target ?? (direction > 0 ? max : min)); }, [commit, list, max, min, value], ); useEffect(() => { window.addEventListener("blur", release); return () => window.removeEventListener("blur", release); }, [release]); const detentLabel = list[activeDetent]?.label; const valueText = detentLabel ? `${format(value)}, ${detentLabel}` : format(value); const percent = range > 0 ? Math.min(1, Math.max(0, (value - min) / range)) : 0; const trackProps = { role: "slider" as const, tabIndex: 0, "aria-orientation": "horizontal" as const, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": value, "aria-valuetext": valueText, "aria-disabled": disabled || undefined, "aria-label": labelledBy ? undefined : label, "aria-labelledby": labelledBy, style: { touchAction: "none" as const }, onPointerDown: (e: React.PointerEvent) => { if (disabled) return; if (e.pointerType === "mouse" && e.button !== 0) return; e.currentTarget.setPointerCapture?.(e.pointerId); e.currentTarget.focus({ preventScroll: true }); held.current = true; setDragging(true); const next = capture(e.clientX); if (next !== null) commit(next); }, onPointerMove: (e: React.PointerEvent) => { if (!held.current) return; const next = capture(e.clientX); if (next !== null) commit(next); }, onPointerUp: release, onPointerCancel: release, onLostPointerCapture: release, onKeyDown: (e: React.KeyboardEvent) => { if (disabled) return; const forward = e.key === "ArrowRight" || e.key === "ArrowUp"; const back = e.key === "ArrowLeft" || e.key === "ArrowDown"; if (forward || back) { const direction = forward ? 1 : -1; if (e.shiftKey) toDetent(direction); else commit(value + direction * step); } else if (e.key === "PageUp") { toDetent(1); } else if (e.key === "PageDown") { toDetent(-1); } else if (e.key === "Home") { commit(min); } else if (e.key === "End") { commit(max); } else { return; } e.preventDefault(); }, }; return { trackRef, trackProps, detents: list, activeDetent, percent, dragging, valueText, }; } export type SliderDetentsProps = { value: number; onValueChange: (value: number) => void; min?: number; max?: number; step?: number; detents?: readonly (number | SliderDetent)[]; pull?: number; label?: string; format?: (value: number) => string; disabled?: boolean; haptic?: boolean; className?: string; }; export function SliderDetents({ value, onValueChange, min = 0, max = 100, step = 1, detents = NONE, pull, label = "Value", format = plain, disabled = false, haptic = true, className = "", }: SliderDetentsProps) { const labelId = useId(); const reduced = useReducedMotion(); const { trackRef, trackProps, detents: list, activeDetent, percent, dragging, } = useSliderDetents({ value, onValueChange, min, max, step, detents, pull, disabled, haptic, format, labelledBy: labelId, }); const carriage = useSpring(percent * 100, CARRIAGE); const offset = useMotionTemplate`${carriage}%`; useEffect(() => { const target = percent * 100; if (reduced) carriage.jump(target); else carriage.set(target); }, [carriage, percent, reduced]); const widest = useMemo(() => { const options = [ format(min), format(max), ...list.map((d) => d.label ? `${format(d.value)} · ${d.label}` : format(d.value), ), ]; return options.reduce((a, b) => (b.length > a.length ? b : a), ""); }, [format, list, max, min]); const suffix = list[activeDetent]?.label ?? ""; const lastLabel = useRef(suffix); if (suffix) lastLabel.current = suffix; const span = max - min; return (
{label} {widest} {format(value)} {lastLabel.current ? ` · ${lastLabel.current}` : ""}
{list.map((d) => ( 0 ? `${((d.value - min) / span) * 100}%` : "0%", }} /> ))}
); } ```