Range Slider
Slider with tick dots and a vertical-bar thumb that bounces as it lands on each step. Drag or keyboard, reduced-motion safe.
Inline Slider
range-slider-inline.tsxAn inset fill and inline label and value. Ten evenly spaced stops span the track; markers under text stay hidden but remain interactive, and the thumb parts around either label as it passes.
"use client";
import { InlineSlider } from "@/components/motion/range-slider-inline";
export function InlineSliderPreview() {
return (
<div className="w-full max-w-sm">
<InlineSlider
defaultValue={48}
min={8}
max={128}
step={8}
label="Icon size"
aria-label="Icon size"
formatValueText={(value) => `${value} pixels`}
/>
</div>
);
}
"use client";
// beui.dev/components/motion/range-slider
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import {
type PointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { SPRING_GLIDE } from "@/lib/ease";
import { type SliderOptions, snapSliderValue, useSlider } from "@/lib/hooks/use-slider";
import { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
const STOP_COUNT = 10;
const HANDLE_START = 8;
const HANDLE_END_INSET = 12;
const TEXT_INSET = 20;
// Matches RangeSlider's bouncy grab and release feedback.
const SPRING_BOUNCY = { type: "spring", stiffness: 500, damping: 14, mass: 0.7 } as const;
type Stop = { value: number; x: number };
function mapBetweenStops(
stops: Stop[],
point: number,
from: keyof Stop,
to: keyof Stop,
) {
const upperIndex = stops.findIndex((stop) => stop[from] >= point);
const upper = stops[upperIndex < 0 ? stops.length - 1 : upperIndex];
const lower = stops[Math.max(0, upperIndex - 1)];
if (lower[from] === upper[from]) return upper[to];
return lower[to] +
((point - lower[from]) / (upper[from] - lower[from])) * (upper[to] - lower[to]);
}
function nearestStop(stops: Stop[], x: number) {
return stops.reduce((nearest, stop) =>
Math.abs(stop.x - x) < Math.abs(nearest.x - x) ? stop : nearest,
);
}
export interface InlineSliderProps extends SliderOptions {
/** Rounds snap-stop values. While dragging, the readout keeps this step's decimal precision. */
step?: number;
/** Compact label inside the left edge of the track. */
label: string;
/** Formats the inline value without changing its precision. */
format?: (value: number) => string;
/** Show markers for the ten evenly spaced snap stops, except where they overlap inline text. */
showTicks?: boolean;
className?: string;
}
/** An always-visible inline slider with an inset fill and a thumb that parts
* around its labels, keeping their text readable as the handle passes them. */
export function InlineSlider({
label,
format = String,
showTicks = true,
className,
...options
}: InlineSliderProps) {
const reduce = useReducedMotion();
const step = options.step && options.step > 0 ? options.step : 1;
const precision = step.toFixed(6).replace(/0+$/, "").split(".")[1]?.length ?? 0;
const { current, min, max, commit, trackProps, sliderProps } = useSlider({
...options,
step: 10 ** -precision,
"aria-label": options["aria-label"] ?? label,
formatValueText: options.formatValueText ?? format,
});
const labelRef = useRef<HTMLSpanElement>(null);
const readoutRef = useRef<HTMLSpanElement>(null);
const [geometry, setGeometry] = useState({
width: 292,
labelWidth: 22,
readoutWidth: 24,
});
const [dragging, setDragging] = useState(false);
const dragFrame = useRef<number | null>(null);
const pendingDragValue = useRef<number | null>(null);
const gesture = useRef<{
id: number;
left: number;
offset: number;
x: number;
} | null>(null);
useLayoutEffect(() => {
const track = trackProps.ref.current;
const labelElement = labelRef.current;
const readout = readoutRef.current;
if (!track || !labelElement || !readout) return;
const measure = () => {
const width = track.getBoundingClientRect().width;
if (!width) return;
const next = {
width,
labelWidth: labelElement.getBoundingClientRect().width,
readoutWidth: readout.getBoundingClientRect().width,
};
setGeometry((previous) =>
previous.width === next.width && previous.labelWidth === next.labelWidth &&
previous.readoutWidth === next.readoutWidth ? previous : next,
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(track);
observer.observe(labelElement);
observer.observe(readout);
return () => observer.disconnect();
}, [trackProps.ref]);
// Each stop owns both a value and a physical position. As in RangeSlider,
// the stops span the entire track at even intervals; text only affects
// whether a marker is painted, never whether its stop remains interactive.
const endX = Math.max(HANDLE_START, geometry.width - HANDLE_END_INSET);
const stops = useMemo(() => {
const values = [...new Set(Array.from({ length: STOP_COUNT }, (_, index) =>
snapSliderValue(min + (index / (STOP_COUNT - 1)) * (max - min), min, max, step),
))];
return values.map((value, index) => ({
value,
x: values.length === 1
? HANDLE_START
: HANDLE_START + (index / (values.length - 1)) * (endX - HANDLE_START),
}));
}, [min, max, step, endX]);
const restingX = mapBetweenStops(stops, current, "value", "x");
// One motion value owns the thumb for the entire gesture. Pointer movement
// writes pixels directly; only release/click/keyboard changes use a spring.
// Never derive dragging pixels from a rounded value or swap to a lagging spring.
const handleX = useMotionValue(restingX);
const restingTarget = useRef(restingX);
const settleTo = useCallback((x: number) => {
restingTarget.current = x;
handleX.jump(handleX.get());
if (reduce) handleX.jump(x);
else animate(handleX, x, { type: "spring", ...SPRING_GLIDE });
}, [handleX, reduce]);
useLayoutEffect(() => {
if (gesture.current || restingTarget.current === restingX) return;
settleTo(restingX);
}, [restingX, settleTo]);
useEffect(() => () => {
handleX.stop();
if (dragFrame.current !== null) cancelAnimationFrame(dragFrame.current);
}, [handleX]);
const fillRight = useTransform(handleX, (x) =>
x >= endX ? geometry.width - 2 : x + 8,
);
// Slide a fixed-size fill inside the inset clipping window. The labels and
// dots stay above it, and only transforms animate.
const fillX = useTransform(fillRight, (right) => right - geometry.width + 2);
// Part progressively over six pixels at each text edge instead of
// toggling the stem on/off in a single pointer frame. The thumb uses the
// same two-dot treatment across both the label and numeric readout.
const split = useTransform(handleX, (x) => {
const overlap = (start: number, end: number) => Math.max(0, Math.min(
1,
(x + 4 - start) / 6,
(end - x) / 6,
));
return Math.max(
overlap(TEXT_INSET, TEXT_INSET + geometry.labelWidth),
overlap(
geometry.width - TEXT_INSET - geometry.readoutWidth,
geometry.width - TEXT_INSET,
),
);
});
const stemOpacity = useTransform(split, (amount) => 1 - amount);
const capTop = useTransform(split, (amount) => -amount);
const capBottom = useTransform(split, (amount) => amount);
const labelBounds = { start: TEXT_INSET, end: TEXT_INSET + geometry.labelWidth };
const readoutBounds = {
start: geometry.width - TEXT_INSET - geometry.readoutWidth,
end: geometry.width - TEXT_INSET,
};
const overlapsText = (x: number, bounds: { start: number; end: number }) =>
x + 2 >= bounds.start && x - 2 <= bounds.end;
const ticks = showTicks
? stops
.map((stop) => stop.x)
.filter((x) => !overlapsText(x, labelBounds) && !overlapsText(x, readoutBounds))
: [];
const queueDragCommit = (value: number) => {
pendingDragValue.current = value;
if (dragFrame.current !== null) return;
dragFrame.current = requestAnimationFrame(() => {
dragFrame.current = null;
if (pendingDragValue.current !== null) commit(pendingDragValue.current);
pendingDragValue.current = null;
});
};
const cancelDragCommit = () => {
if (dragFrame.current !== null) cancelAnimationFrame(dragFrame.current);
dragFrame.current = null;
pendingDragValue.current = null;
};
const endGesture = (event: PointerEvent<HTMLDivElement>) => {
const active = gesture.current;
if (!active || active.id !== event.pointerId) return;
// Clear before releasing capture: its lost-capture event must not commit twice.
gesture.current = null;
cancelDragCommit();
setDragging(false);
if (!options.disabled && geometry.width > 0) {
const x = event.type === "pointerup"
? event.clientX - active.left - active.offset
: active.x;
const stop = nearestStop(stops, x);
commit(stop.value);
settleTo(options.value === undefined ? stop.x : restingX);
} else {
settleTo(restingX);
}
releasePointer(event.currentTarget, event.pointerId);
};
return (
<div
{...trackProps}
onPointerDown={(event) => {
if (options.disabled || event.button !== 0 || gesture.current) return;
const rect = event.currentTarget.getBoundingClientRect();
if (!rect.width) return;
event.preventDefault();
const pointerX = event.clientX - rect.left;
const thumbX = handleX.get();
// Grabbing the thumb preserves the exact grab point. A track click
// waits for release, so it glides to a dot without an intermediate jump.
const offset = Math.abs(pointerX - thumbX - 2) <= 12 ? pointerX - thumbX : 2;
gesture.current = { id: event.pointerId, left: rect.left, offset, x: thumbX };
setDragging(true);
handleX.stop();
cancelDragCommit();
capturePointer(event.currentTarget, event.pointerId);
event.currentTarget.querySelector<HTMLButtonElement>("[role=slider]")?.focus({ preventScroll: true });
}}
onPointerMove={(event) => {
const active = gesture.current;
if (!active || active.id !== event.pointerId || options.disabled) return;
const x = Math.min(
endX,
Math.max(HANDLE_START, event.clientX - active.left - active.offset),
);
active.x = x;
handleX.set(x);
// Use the same piecewise map as the resting stops, so value and
// position agree throughout the drag.
queueDragCommit(mapBetweenStops(stops, x, "x", "value"));
}}
onPointerUp={endGesture}
onPointerCancel={endGesture}
onLostPointerCapture={endGesture}
className={cn(
"relative h-10 w-full touch-none select-none overflow-hidden rounded-lg bg-muted",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-[2px] inset-y-0 overflow-hidden rounded-lg"
>
<motion.div
className="absolute inset-0 rounded-lg bg-foreground/15"
style={{ x: fillX }}
/>
</div>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 text-foreground">
<span
ref={labelRef}
className="absolute left-5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-sm font-medium leading-5"
>
{label}
</span>
<span
ref={readoutRef}
className="absolute right-5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-[13px] font-semibold leading-[18px] tracking-tight tabular-nums"
>
{format(current)}
</span>
{ticks.map((left) => (
<span
key={left}
className="absolute top-1/2 size-1 -translate-y-1/2 rounded-full bg-foreground/25"
style={{ left }}
/>
))}
</div>
<motion.div
aria-hidden="true"
animate={reduce ? undefined : { scaleY: dragging ? 1.35 : 1 }}
transition={SPRING_BOUNCY}
className="pointer-events-none absolute left-0 top-2 h-6 w-1 text-foreground"
style={{ x: handleX }}
>
<motion.span className="absolute top-0 size-1 rounded-full bg-current" style={{ y: reduce ? 0 : capTop }} />
<motion.span className="absolute inset-y-0 w-1 rounded-full bg-current" style={{ opacity: stemOpacity }} />
<motion.span className="absolute bottom-0 size-1 rounded-full bg-current" style={{ y: reduce ? 0 : capBottom }} />
</motion.div>
<button
type="button"
{...sliderProps}
onKeyDown={(event) => {
if (options.disabled) return;
const next = {
ArrowRight: stops.find((stop) => stop.value > current)?.value ?? max,
ArrowUp: stops.find((stop) => stop.value > current)?.value ?? max,
ArrowLeft: stops.findLast((stop) => stop.value < current)?.value ?? min,
ArrowDown: stops.findLast((stop) => stop.value < current)?.value ?? min,
Home: min,
End: max,
PageUp: max,
PageDown: min,
}[event.key];
if (next !== undefined) {
event.preventDefault();
commit(next);
}
}}
className="absolute inset-0 cursor-inherit touch-none rounded-lg border-0 outline-none"
/>
</div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Nearest legal value on [min, max] for the given step. max counts as a
* candidate when the step does not divide the range, so a pointer near the end
* does not snap back onto the last whole step. */
export function snapSliderValue(next: number, min: number, max: number, step: number): number {
// Neither case has a grid to walk. An empty range has exactly one legal
// point, and a non-positive step only needs a clamp, which also keeps the
// division below away from zero.
if (!(max > min)) return min;
if (!(step > 0)) return clamp(next, min, max);
const whole = Math.floor(Number(((max - min) / step).toFixed(6)));
const lastWhole = Number((min + whole * step).toFixed(6));
const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);
const snapped =
lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;
return Number(snapped.toFixed(6));
}
export interface SliderOptions {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
"aria-label"?: string;
/** Announced instead of the raw number — pass one when the value carries a
* unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText. */
formatValueText?: (value: number) => string;
}
/**
* Shared value + input plumbing for slider designs: controlled/uncontrolled
* value, step snapping, pointer-capture drag along a track and arrow-key
* control. Visuals and motion live in the component; this only owns the number.
*/
export function useSlider({
value,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
"aria-label": ariaLabel,
formatValueText,
}: SliderOptions) {
const trackRef = useRef<HTMLDivElement>(null);
const sliderEl = useRef<HTMLElement | null>(null);
// The state drives visuals. Move reads this ref instead, so the first
// pointermove after pointerdown does not have to wait on a re-render.
const draggingRef = useRef(false);
const [internal, setInternal] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const controlled = value !== undefined;
// Collapse inverted or empty ranges and non-positive steps here, so that
// percent, ticks and the keyboard maths never divide by zero or walk a
// NaN grid.
const lo = min;
const hi = max > min ? max : min;
const stride = step > 0 ? step : 1;
const current = clamp(controlled ? value : internal, lo, hi);
const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;
const commit = useCallback(
(next: number) => {
const clean = snapSliderValue(next, lo, hi, stride);
if (!controlled) setInternal(clean);
onValueChange?.(clean);
},
[controlled, onValueChange, lo, hi, stride],
);
const commitFromX = useCallback(
(clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
commit(lo + ratio * (hi - lo));
},
[commit, lo, hi],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
// Start the drag first: capture is a convenience, and a browser that
// refuses it — or a test DOM that has no pointer capture at all — must
// not take the drag down with it.
draggingRef.current = true;
setDragging(true);
capturePointer(event.currentTarget, event.pointerId);
// A click on the track should land keyboard focus on the handle.
sliderEl.current?.focus({ preventScroll: true });
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || disabled) return;
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
releasePointer(event.currentTarget, event.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowRight: current + stride,
ArrowUp: current + stride,
ArrowLeft: current - stride,
ArrowDown: current - stride,
PageUp: current + stride * 10,
PageDown: current - stride * 10,
Home: lo,
End: hi,
};
if (event.key in map) {
event.preventDefault();
commit(map[event.key]);
}
},
[disabled, current, stride, lo, hi, commit],
);
return {
current,
percent,
dragging,
min: lo,
max: hi,
step: stride,
commit,
/** Pointer handlers for the track element — drag anywhere on it. */
trackProps: {
ref: trackRef,
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
onLostPointerCapture: endDrag,
},
/** ARIA + keyboard props for the focusable slider element. */
sliderProps: {
// Callback keeps the handle typed across button/div/motion hosts.
ref: (node: HTMLElement | null) => {
sliderEl.current = node;
},
role: "slider" as const,
tabIndex: disabled ? -1 : 0,
"aria-label": ariaLabel,
"aria-valuemin": lo,
"aria-valuemax": hi,
"aria-valuenow": current,
"aria-valuetext": formatValueText?.(current),
"aria-disabled": disabled || undefined,
onKeyDown,
},
};
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/range-slider
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import {
type PointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { SPRING_GLIDE } from "@/lib/ease";
import { type SliderOptions, snapSliderValue, useSlider } from "@/lib/hooks/use-slider";
import { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
const STOP_COUNT = 10;
const HANDLE_START = 8;
const HANDLE_END_INSET = 12;
const TEXT_INSET = 20;
// Matches RangeSlider's bouncy grab and release feedback.
const SPRING_BOUNCY = { type: "spring", stiffness: 500, damping: 14, mass: 0.7 } as const;
type Stop = { value: number; x: number };
function mapBetweenStops(
stops: Stop[],
point: number,
from: keyof Stop,
to: keyof Stop,
) {
const upperIndex = stops.findIndex((stop) => stop[from] >= point);
const upper = stops[upperIndex < 0 ? stops.length - 1 : upperIndex];
const lower = stops[Math.max(0, upperIndex - 1)];
if (lower[from] === upper[from]) return upper[to];
return lower[to] +
((point - lower[from]) / (upper[from] - lower[from])) * (upper[to] - lower[to]);
}
function nearestStop(stops: Stop[], x: number) {
return stops.reduce((nearest, stop) =>
Math.abs(stop.x - x) < Math.abs(nearest.x - x) ? stop : nearest,
);
}
export interface InlineSliderProps extends SliderOptions {
/** Rounds snap-stop values. While dragging, the readout keeps this step's decimal precision. */
step?: number;
/** Compact label inside the left edge of the track. */
label: string;
/** Formats the inline value without changing its precision. */
format?: (value: number) => string;
/** Show markers for the ten evenly spaced snap stops, except where they overlap inline text. */
showTicks?: boolean;
className?: string;
}
/** An always-visible inline slider with an inset fill and a thumb that parts
* around its labels, keeping their text readable as the handle passes them. */
export function InlineSlider({
label,
format = String,
showTicks = true,
className,
...options
}: InlineSliderProps) {
const reduce = useReducedMotion();
const step = options.step && options.step > 0 ? options.step : 1;
const precision = step.toFixed(6).replace(/0+$/, "").split(".")[1]?.length ?? 0;
const { current, min, max, commit, trackProps, sliderProps } = useSlider({
...options,
step: 10 ** -precision,
"aria-label": options["aria-label"] ?? label,
formatValueText: options.formatValueText ?? format,
});
const labelRef = useRef<HTMLSpanElement>(null);
const readoutRef = useRef<HTMLSpanElement>(null);
const [geometry, setGeometry] = useState({
width: 292,
labelWidth: 22,
readoutWidth: 24,
});
const [dragging, setDragging] = useState(false);
const dragFrame = useRef<number | null>(null);
const pendingDragValue = useRef<number | null>(null);
const gesture = useRef<{
id: number;
left: number;
offset: number;
x: number;
} | null>(null);
useLayoutEffect(() => {
const track = trackProps.ref.current;
const labelElement = labelRef.current;
const readout = readoutRef.current;
if (!track || !labelElement || !readout) return;
const measure = () => {
const width = track.getBoundingClientRect().width;
if (!width) return;
const next = {
width,
labelWidth: labelElement.getBoundingClientRect().width,
readoutWidth: readout.getBoundingClientRect().width,
};
setGeometry((previous) =>
previous.width === next.width && previous.labelWidth === next.labelWidth &&
previous.readoutWidth === next.readoutWidth ? previous : next,
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(track);
observer.observe(labelElement);
observer.observe(readout);
return () => observer.disconnect();
}, [trackProps.ref]);
// Each stop owns both a value and a physical position. As in RangeSlider,
// the stops span the entire track at even intervals; text only affects
// whether a marker is painted, never whether its stop remains interactive.
const endX = Math.max(HANDLE_START, geometry.width - HANDLE_END_INSET);
const stops = useMemo(() => {
const values = [...new Set(Array.from({ length: STOP_COUNT }, (_, index) =>
snapSliderValue(min + (index / (STOP_COUNT - 1)) * (max - min), min, max, step),
))];
return values.map((value, index) => ({
value,
x: values.length === 1
? HANDLE_START
: HANDLE_START + (index / (values.length - 1)) * (endX - HANDLE_START),
}));
}, [min, max, step, endX]);
const restingX = mapBetweenStops(stops, current, "value", "x");
// One motion value owns the thumb for the entire gesture. Pointer movement
// writes pixels directly; only release/click/keyboard changes use a spring.
// Never derive dragging pixels from a rounded value or swap to a lagging spring.
const handleX = useMotionValue(restingX);
const restingTarget = useRef(restingX);
const settleTo = useCallback((x: number) => {
restingTarget.current = x;
handleX.jump(handleX.get());
if (reduce) handleX.jump(x);
else animate(handleX, x, { type: "spring", ...SPRING_GLIDE });
}, [handleX, reduce]);
useLayoutEffect(() => {
if (gesture.current || restingTarget.current === restingX) return;
settleTo(restingX);
}, [restingX, settleTo]);
useEffect(() => () => {
handleX.stop();
if (dragFrame.current !== null) cancelAnimationFrame(dragFrame.current);
}, [handleX]);
const fillRight = useTransform(handleX, (x) =>
x >= endX ? geometry.width - 2 : x + 8,
);
// Slide a fixed-size fill inside the inset clipping window. The labels and
// dots stay above it, and only transforms animate.
const fillX = useTransform(fillRight, (right) => right - geometry.width + 2);
// Part progressively over six pixels at each text edge instead of
// toggling the stem on/off in a single pointer frame. The thumb uses the
// same two-dot treatment across both the label and numeric readout.
const split = useTransform(handleX, (x) => {
const overlap = (start: number, end: number) => Math.max(0, Math.min(
1,
(x + 4 - start) / 6,
(end - x) / 6,
));
return Math.max(
overlap(TEXT_INSET, TEXT_INSET + geometry.labelWidth),
overlap(
geometry.width - TEXT_INSET - geometry.readoutWidth,
geometry.width - TEXT_INSET,
),
);
});
const stemOpacity = useTransform(split, (amount) => 1 - amount);
const capTop = useTransform(split, (amount) => -amount);
const capBottom = useTransform(split, (amount) => amount);
const labelBounds = { start: TEXT_INSET, end: TEXT_INSET + geometry.labelWidth };
const readoutBounds = {
start: geometry.width - TEXT_INSET - geometry.readoutWidth,
end: geometry.width - TEXT_INSET,
};
const overlapsText = (x: number, bounds: { start: number; end: number }) =>
x + 2 >= bounds.start && x - 2 <= bounds.end;
const ticks = showTicks
? stops
.map((stop) => stop.x)
.filter((x) => !overlapsText(x, labelBounds) && !overlapsText(x, readoutBounds))
: [];
const queueDragCommit = (value: number) => {
pendingDragValue.current = value;
if (dragFrame.current !== null) return;
dragFrame.current = requestAnimationFrame(() => {
dragFrame.current = null;
if (pendingDragValue.current !== null) commit(pendingDragValue.current);
pendingDragValue.current = null;
});
};
const cancelDragCommit = () => {
if (dragFrame.current !== null) cancelAnimationFrame(dragFrame.current);
dragFrame.current = null;
pendingDragValue.current = null;
};
const endGesture = (event: PointerEvent<HTMLDivElement>) => {
const active = gesture.current;
if (!active || active.id !== event.pointerId) return;
// Clear before releasing capture: its lost-capture event must not commit twice.
gesture.current = null;
cancelDragCommit();
setDragging(false);
if (!options.disabled && geometry.width > 0) {
const x = event.type === "pointerup"
? event.clientX - active.left - active.offset
: active.x;
const stop = nearestStop(stops, x);
commit(stop.value);
settleTo(options.value === undefined ? stop.x : restingX);
} else {
settleTo(restingX);
}
releasePointer(event.currentTarget, event.pointerId);
};
return (
<div
{...trackProps}
onPointerDown={(event) => {
if (options.disabled || event.button !== 0 || gesture.current) return;
const rect = event.currentTarget.getBoundingClientRect();
if (!rect.width) return;
event.preventDefault();
const pointerX = event.clientX - rect.left;
const thumbX = handleX.get();
// Grabbing the thumb preserves the exact grab point. A track click
// waits for release, so it glides to a dot without an intermediate jump.
const offset = Math.abs(pointerX - thumbX - 2) <= 12 ? pointerX - thumbX : 2;
gesture.current = { id: event.pointerId, left: rect.left, offset, x: thumbX };
setDragging(true);
handleX.stop();
cancelDragCommit();
capturePointer(event.currentTarget, event.pointerId);
event.currentTarget.querySelector<HTMLButtonElement>("[role=slider]")?.focus({ preventScroll: true });
}}
onPointerMove={(event) => {
const active = gesture.current;
if (!active || active.id !== event.pointerId || options.disabled) return;
const x = Math.min(
endX,
Math.max(HANDLE_START, event.clientX - active.left - active.offset),
);
active.x = x;
handleX.set(x);
// Use the same piecewise map as the resting stops, so value and
// position agree throughout the drag.
queueDragCommit(mapBetweenStops(stops, x, "x", "value"));
}}
onPointerUp={endGesture}
onPointerCancel={endGesture}
onLostPointerCapture={endGesture}
className={cn(
"relative h-10 w-full touch-none select-none overflow-hidden rounded-lg bg-muted",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-[2px] inset-y-0 overflow-hidden rounded-lg"
>
<motion.div
className="absolute inset-0 rounded-lg bg-foreground/15"
style={{ x: fillX }}
/>
</div>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 text-foreground">
<span
ref={labelRef}
className="absolute left-5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-sm font-medium leading-5"
>
{label}
</span>
<span
ref={readoutRef}
className="absolute right-5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-[13px] font-semibold leading-[18px] tracking-tight tabular-nums"
>
{format(current)}
</span>
{ticks.map((left) => (
<span
key={left}
className="absolute top-1/2 size-1 -translate-y-1/2 rounded-full bg-foreground/25"
style={{ left }}
/>
))}
</div>
<motion.div
aria-hidden="true"
animate={reduce ? undefined : { scaleY: dragging ? 1.35 : 1 }}
transition={SPRING_BOUNCY}
className="pointer-events-none absolute left-0 top-2 h-6 w-1 text-foreground"
style={{ x: handleX }}
>
<motion.span className="absolute top-0 size-1 rounded-full bg-current" style={{ y: reduce ? 0 : capTop }} />
<motion.span className="absolute inset-y-0 w-1 rounded-full bg-current" style={{ opacity: stemOpacity }} />
<motion.span className="absolute bottom-0 size-1 rounded-full bg-current" style={{ y: reduce ? 0 : capBottom }} />
</motion.div>
<button
type="button"
{...sliderProps}
onKeyDown={(event) => {
if (options.disabled) return;
const next = {
ArrowRight: stops.find((stop) => stop.value > current)?.value ?? max,
ArrowUp: stops.find((stop) => stop.value > current)?.value ?? max,
ArrowLeft: stops.findLast((stop) => stop.value < current)?.value ?? min,
ArrowDown: stops.findLast((stop) => stop.value < current)?.value ?? min,
Home: min,
End: max,
PageUp: max,
PageDown: min,
}[event.key];
if (next !== undefined) {
event.preventDefault();
commit(next);
}
}}
className="absolute inset-0 cursor-inherit touch-none rounded-lg border-0 outline-none"
/>
</div>
);
}
API Reference
step?numberRounds snap-stop values. While dragging, the readout keeps this step's decimal precision.
—labelstringCompact label inside the left edge of the track.
—format?((value: number) => string)Formats the inline value without changing its precision.
—showTicks?booleanShow markers for the ten evenly spaced snap stops, except where they overlap inline text.
trueclassName?string—value?number—defaultValue?number—onValueChange?((value: number) => void)—min?number—max?number—disabled?boolean—aria-label?string—formatValueText?((value: number) => string)Announced instead of the raw number — pass one when the value carries a unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText.
—Range Slider
range-slider.tsxTick dots, and a vertical-bar thumb that bounces as it lands on each step.
"use client";
import { useState } from "react";
import { RangeSlider } from "@/components/motion/range-slider";
export function RangeSliderPreview() {
const [value, setValue] = useState(40);
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>Drag the handle</span>
<span className="tabular-nums text-foreground">{value}</span>
</div>
<RangeSlider value={value} onValueChange={setValue} step={5} aria-label="Value" />
</div>
);
}
"use client";
// beui.dev/components/motion/range-slider
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { useEffect, useLayoutEffect, useState } from "react";
import { SPRING_GLIDE } from "@/lib/ease";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Bouncy grab feedback for the thumb scale only.
const SPRING_BOUNCY = { type: "spring", stiffness: 500, damping: 14, mass: 0.7 } as const;
export interface RangeSliderProps extends SliderOptions {
/** Render a tick dot at each step. */
showTicks?: boolean;
className?: string;
}
export function RangeSlider({ showTicks = true, className, ...options }: RangeSliderProps) {
const reduce = useReducedMotion();
const { percent, dragging, min, max, step, trackProps, sliderProps } = useSlider(options);
const [trackWidth, setTrackWidth] = useState(292);
useLayoutEffect(() => {
const track = trackProps.ref.current;
if (!track) return;
const measure = () => {
const width = track.getBoundingClientRect().width;
if (width > 0) setTrackWidth(width);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(track);
return () => observer.disconnect();
}, [trackProps.ref]);
// Spring-smoothed position drives both the thumb and the fill.
const target = useMotionValue(percent);
useEffect(() => {
target.set(percent);
}, [percent, target]);
const smooth = useSpring(target, SPRING_GLIDE);
const pos = reduce ? target : smooth;
const thumbX = useTransform(pos, (p) => 8 + Math.max(0, trackWidth - 20) * p / 100);
// Match InlineSlider: the 4px handle starts 8px inside the track, and
// the rounded fill extends 8px past its left edge. Translate a full-size
// fill inside the 2px inset clip so its corner never stretches.
const fillX = useTransform(pos, (p) => p >= 100
? "0%"
: `calc(${p - 100}% + ${14 - 0.16 * p}px)`);
// Floor rather than round, so a range the step does not divide (0 to 10 by 4)
// stops its dots at the last whole step instead of drawing one past max.
// toFixed comes first because 0.3/0.1 is 2.9999999999999996, which would
// floor to 2 and drop the last dot.
const steps = Math.floor(Number(((max - min) / step).toFixed(6)));
const ticks =
showTicks && steps > 0 && steps <= 50
? Array.from({ length: steps + 1 }, (_, i) => Number((min + i * step).toFixed(6)))
: [];
return (
<div
{...trackProps}
className={cn(
"relative flex h-10 w-full touch-none items-center overflow-hidden rounded-lg bg-muted",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
<div aria-hidden="true" className="pointer-events-none absolute inset-x-[2px] inset-y-0 overflow-hidden rounded-lg">
<motion.div className="absolute inset-0 rounded-lg bg-foreground/15" style={{ x: fillX }} />
</div>
{/* Tick centres follow the same inset path as the handle centre. */}
<div className="pointer-events-none absolute inset-x-[10px] inset-y-0">
{ticks.map((t) => {
const tp = ((t - min) / (max - min)) * 100;
return (
<span
key={t}
className="absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/25"
style={{ left: `${tp}%` }}
/>
);
})}
</div>
{/* Keep the handle inside the rounded progress fill at both ends. */}
<motion.div
{...sliderProps}
animate={reduce ? undefined : { scaleY: dragging ? 1.35 : 1 }}
transition={SPRING_BOUNCY}
className="absolute left-0 top-1/2 h-6 w-1 rounded-full bg-foreground outline-none ring-inset ring-foreground/30 focus-visible:ring-4"
style={{ x: thumbX, y: "-50%" }}
/>
</div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Nearest legal value on [min, max] for the given step. max counts as a
* candidate when the step does not divide the range, so a pointer near the end
* does not snap back onto the last whole step. */
export function snapSliderValue(next: number, min: number, max: number, step: number): number {
// Neither case has a grid to walk. An empty range has exactly one legal
// point, and a non-positive step only needs a clamp, which also keeps the
// division below away from zero.
if (!(max > min)) return min;
if (!(step > 0)) return clamp(next, min, max);
const whole = Math.floor(Number(((max - min) / step).toFixed(6)));
const lastWhole = Number((min + whole * step).toFixed(6));
const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);
const snapped =
lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;
return Number(snapped.toFixed(6));
}
export interface SliderOptions {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
"aria-label"?: string;
/** Announced instead of the raw number — pass one when the value carries a
* unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText. */
formatValueText?: (value: number) => string;
}
/**
* Shared value + input plumbing for slider designs: controlled/uncontrolled
* value, step snapping, pointer-capture drag along a track and arrow-key
* control. Visuals and motion live in the component; this only owns the number.
*/
export function useSlider({
value,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
"aria-label": ariaLabel,
formatValueText,
}: SliderOptions) {
const trackRef = useRef<HTMLDivElement>(null);
const sliderEl = useRef<HTMLElement | null>(null);
// The state drives visuals. Move reads this ref instead, so the first
// pointermove after pointerdown does not have to wait on a re-render.
const draggingRef = useRef(false);
const [internal, setInternal] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const controlled = value !== undefined;
// Collapse inverted or empty ranges and non-positive steps here, so that
// percent, ticks and the keyboard maths never divide by zero or walk a
// NaN grid.
const lo = min;
const hi = max > min ? max : min;
const stride = step > 0 ? step : 1;
const current = clamp(controlled ? value : internal, lo, hi);
const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;
const commit = useCallback(
(next: number) => {
const clean = snapSliderValue(next, lo, hi, stride);
if (!controlled) setInternal(clean);
onValueChange?.(clean);
},
[controlled, onValueChange, lo, hi, stride],
);
const commitFromX = useCallback(
(clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
commit(lo + ratio * (hi - lo));
},
[commit, lo, hi],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
// Start the drag first: capture is a convenience, and a browser that
// refuses it — or a test DOM that has no pointer capture at all — must
// not take the drag down with it.
draggingRef.current = true;
setDragging(true);
capturePointer(event.currentTarget, event.pointerId);
// A click on the track should land keyboard focus on the handle.
sliderEl.current?.focus({ preventScroll: true });
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || disabled) return;
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
releasePointer(event.currentTarget, event.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowRight: current + stride,
ArrowUp: current + stride,
ArrowLeft: current - stride,
ArrowDown: current - stride,
PageUp: current + stride * 10,
PageDown: current - stride * 10,
Home: lo,
End: hi,
};
if (event.key in map) {
event.preventDefault();
commit(map[event.key]);
}
},
[disabled, current, stride, lo, hi, commit],
);
return {
current,
percent,
dragging,
min: lo,
max: hi,
step: stride,
commit,
/** Pointer handlers for the track element — drag anywhere on it. */
trackProps: {
ref: trackRef,
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
onLostPointerCapture: endDrag,
},
/** ARIA + keyboard props for the focusable slider element. */
sliderProps: {
// Callback keeps the handle typed across button/div/motion hosts.
ref: (node: HTMLElement | null) => {
sliderEl.current = node;
},
role: "slider" as const,
tabIndex: disabled ? -1 : 0,
"aria-label": ariaLabel,
"aria-valuemin": lo,
"aria-valuemax": hi,
"aria-valuenow": current,
"aria-valuetext": formatValueText?.(current),
"aria-disabled": disabled || undefined,
onKeyDown,
},
};
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/range-slider
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { useEffect, useLayoutEffect, useState } from "react";
import { SPRING_GLIDE } from "@/lib/ease";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Bouncy grab feedback for the thumb scale only.
const SPRING_BOUNCY = { type: "spring", stiffness: 500, damping: 14, mass: 0.7 } as const;
export interface RangeSliderProps extends SliderOptions {
/** Render a tick dot at each step. */
showTicks?: boolean;
className?: string;
}
export function RangeSlider({ showTicks = true, className, ...options }: RangeSliderProps) {
const reduce = useReducedMotion();
const { percent, dragging, min, max, step, trackProps, sliderProps } = useSlider(options);
const [trackWidth, setTrackWidth] = useState(292);
useLayoutEffect(() => {
const track = trackProps.ref.current;
if (!track) return;
const measure = () => {
const width = track.getBoundingClientRect().width;
if (width > 0) setTrackWidth(width);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(track);
return () => observer.disconnect();
}, [trackProps.ref]);
// Spring-smoothed position drives both the thumb and the fill.
const target = useMotionValue(percent);
useEffect(() => {
target.set(percent);
}, [percent, target]);
const smooth = useSpring(target, SPRING_GLIDE);
const pos = reduce ? target : smooth;
const thumbX = useTransform(pos, (p) => 8 + Math.max(0, trackWidth - 20) * p / 100);
// Match InlineSlider: the 4px handle starts 8px inside the track, and
// the rounded fill extends 8px past its left edge. Translate a full-size
// fill inside the 2px inset clip so its corner never stretches.
const fillX = useTransform(pos, (p) => p >= 100
? "0%"
: `calc(${p - 100}% + ${14 - 0.16 * p}px)`);
// Floor rather than round, so a range the step does not divide (0 to 10 by 4)
// stops its dots at the last whole step instead of drawing one past max.
// toFixed comes first because 0.3/0.1 is 2.9999999999999996, which would
// floor to 2 and drop the last dot.
const steps = Math.floor(Number(((max - min) / step).toFixed(6)));
const ticks =
showTicks && steps > 0 && steps <= 50
? Array.from({ length: steps + 1 }, (_, i) => Number((min + i * step).toFixed(6)))
: [];
return (
<div
{...trackProps}
className={cn(
"relative flex h-10 w-full touch-none items-center overflow-hidden rounded-lg bg-muted",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
<div aria-hidden="true" className="pointer-events-none absolute inset-x-[2px] inset-y-0 overflow-hidden rounded-lg">
<motion.div className="absolute inset-0 rounded-lg bg-foreground/15" style={{ x: fillX }} />
</div>
{/* Tick centres follow the same inset path as the handle centre. */}
<div className="pointer-events-none absolute inset-x-[10px] inset-y-0">
{ticks.map((t) => {
const tp = ((t - min) / (max - min)) * 100;
return (
<span
key={t}
className="absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/25"
style={{ left: `${tp}%` }}
/>
);
})}
</div>
{/* Keep the handle inside the rounded progress fill at both ends. */}
<motion.div
{...sliderProps}
animate={reduce ? undefined : { scaleY: dragging ? 1.35 : 1 }}
transition={SPRING_BOUNCY}
className="absolute left-0 top-1/2 h-6 w-1 rounded-full bg-foreground outline-none ring-inset ring-foreground/30 focus-visible:ring-4"
style={{ x: thumbX, y: "-50%" }}
/>
</div>
);
}
API Reference
showTicks?booleanRender a tick dot at each step.
trueclassName?string—value?number—defaultValue?number—onValueChange?((value: number) => void)—min?number—max?number—step?number—disabled?boolean—aria-label?string—formatValueText?((value: number) => string)Announced instead of the raw number — pass one when the value carries a unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText.
—Fluid Slider
range-slider-fluid.tsxNo thumb. The fill slides behind a rounded liquid cap, and the label flips color wherever the fill covers it.
"use client";
import { useState } from "react";
import { FluidSlider } from "@/components/motion/range-slider-fluid";
export function RangeSliderFluidPreview() {
const [value, setValue] = useState(35);
return (
<div className="w-full max-w-sm">
<FluidSlider
value={value}
onValueChange={setValue}
label="Brightness"
aria-label="Brightness"
/>
</div>
);
}
"use client";
// beui.dev/components/motion/range-slider
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { useEffect } from "react";
import { SPRING_GLIDE, SPRING_PRESS } from "@/lib/ease";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export interface FluidSliderProps extends SliderOptions {
/** Text shown on the left of the track. */
label?: string;
/** Formats the value shown on the right. */
format?: (value: number) => string;
className?: string;
}
/**
* Thumbless slider: the whole pill is the control. The fill glides to the new
* value behind a rounded liquid cap, and the label reads inverted wherever the
* fill has covered it.
*/
export function FluidSlider({
label,
// The value arrives already snapped to the step. Rounding it again would
// only make the label and the announcement disagree with aria-valuenow.
format = (v) => `${v}%`,
className,
...options
}: FluidSliderProps) {
const reduce = useReducedMotion();
const { percent, current, dragging, trackProps, sliderProps } = useSlider({
...options,
formatValueText: options.formatValueText ?? format,
});
const target = useMotionValue(percent);
useEffect(() => {
target.set(percent);
}, [percent, target]);
const smooth = useSpring(target, SPRING_GLIDE);
const pos = reduce ? target : smooth;
// Reveal the fill by clipping a full-width layer rather than animating its
// width: at 0% the clip is empty, so no hairline of a sub-pixel-wide box is
// left behind, and the label inside is never scaled or re-laid out.
const uncovered = useTransform(pos, (v) => 100 - v);
const clipPath = useMotionTemplate`inset(0 ${uncovered}% 0 0 round 9999px)`;
const row = (
<>
{label ? <span className="truncate">{label}</span> : <span />}
<span className="tabular-nums">{format(current)}</span>
</>
);
return (
<motion.div
{...trackProps}
animate={reduce ? undefined : { scale: dragging ? 1.03 : 1 }}
transition={SPRING_PRESS}
className={cn(
"relative flex h-12 w-full touch-none overflow-hidden rounded-full bg-muted",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
{/* uncovered label — sits on the muted track */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-between px-5 text-sm font-medium text-foreground">
{row}
</div>
{/* fill + the same label, both clipped to the value, so the text inverts
as the fill covers it and lines up glyph for glyph with the copy
underneath. The clip's rounded right edge is the liquid cap. */}
<motion.div className="absolute inset-0" style={{ clipPath }}>
<div className="absolute inset-0 bg-foreground" />
<div className="pointer-events-none absolute inset-0 flex items-center justify-between px-5 text-sm font-medium text-background">
{row}
</div>
</motion.div>
{/* focusable, keyboard-controlled handle surface. The ring is inset — an
outset one is clipped away by the track's overflow-hidden. */}
<button
type="button"
{...sliderProps}
className="absolute inset-0 touch-none rounded-full outline-none ring-inset ring-foreground/40 focus-visible:ring-4"
/>
</motion.div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Nearest legal value on [min, max] for the given step. max counts as a
* candidate when the step does not divide the range, so a pointer near the end
* does not snap back onto the last whole step. */
export function snapSliderValue(next: number, min: number, max: number, step: number): number {
// Neither case has a grid to walk. An empty range has exactly one legal
// point, and a non-positive step only needs a clamp, which also keeps the
// division below away from zero.
if (!(max > min)) return min;
if (!(step > 0)) return clamp(next, min, max);
const whole = Math.floor(Number(((max - min) / step).toFixed(6)));
const lastWhole = Number((min + whole * step).toFixed(6));
const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);
const snapped =
lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;
return Number(snapped.toFixed(6));
}
export interface SliderOptions {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
"aria-label"?: string;
/** Announced instead of the raw number — pass one when the value carries a
* unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText. */
formatValueText?: (value: number) => string;
}
/**
* Shared value + input plumbing for slider designs: controlled/uncontrolled
* value, step snapping, pointer-capture drag along a track and arrow-key
* control. Visuals and motion live in the component; this only owns the number.
*/
export function useSlider({
value,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
"aria-label": ariaLabel,
formatValueText,
}: SliderOptions) {
const trackRef = useRef<HTMLDivElement>(null);
const sliderEl = useRef<HTMLElement | null>(null);
// The state drives visuals. Move reads this ref instead, so the first
// pointermove after pointerdown does not have to wait on a re-render.
const draggingRef = useRef(false);
const [internal, setInternal] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const controlled = value !== undefined;
// Collapse inverted or empty ranges and non-positive steps here, so that
// percent, ticks and the keyboard maths never divide by zero or walk a
// NaN grid.
const lo = min;
const hi = max > min ? max : min;
const stride = step > 0 ? step : 1;
const current = clamp(controlled ? value : internal, lo, hi);
const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;
const commit = useCallback(
(next: number) => {
const clean = snapSliderValue(next, lo, hi, stride);
if (!controlled) setInternal(clean);
onValueChange?.(clean);
},
[controlled, onValueChange, lo, hi, stride],
);
const commitFromX = useCallback(
(clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
commit(lo + ratio * (hi - lo));
},
[commit, lo, hi],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
// Start the drag first: capture is a convenience, and a browser that
// refuses it — or a test DOM that has no pointer capture at all — must
// not take the drag down with it.
draggingRef.current = true;
setDragging(true);
capturePointer(event.currentTarget, event.pointerId);
// A click on the track should land keyboard focus on the handle.
sliderEl.current?.focus({ preventScroll: true });
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || disabled) return;
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
releasePointer(event.currentTarget, event.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowRight: current + stride,
ArrowUp: current + stride,
ArrowLeft: current - stride,
ArrowDown: current - stride,
PageUp: current + stride * 10,
PageDown: current - stride * 10,
Home: lo,
End: hi,
};
if (event.key in map) {
event.preventDefault();
commit(map[event.key]);
}
},
[disabled, current, stride, lo, hi, commit],
);
return {
current,
percent,
dragging,
min: lo,
max: hi,
step: stride,
commit,
/** Pointer handlers for the track element — drag anywhere on it. */
trackProps: {
ref: trackRef,
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
onLostPointerCapture: endDrag,
},
/** ARIA + keyboard props for the focusable slider element. */
sliderProps: {
// Callback keeps the handle typed across button/div/motion hosts.
ref: (node: HTMLElement | null) => {
sliderEl.current = node;
},
role: "slider" as const,
tabIndex: disabled ? -1 : 0,
"aria-label": ariaLabel,
"aria-valuemin": lo,
"aria-valuemax": hi,
"aria-valuenow": current,
"aria-valuetext": formatValueText?.(current),
"aria-disabled": disabled || undefined,
onKeyDown,
},
};
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/range-slider
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { useEffect } from "react";
import { SPRING_GLIDE, SPRING_PRESS } from "@/lib/ease";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export interface FluidSliderProps extends SliderOptions {
/** Text shown on the left of the track. */
label?: string;
/** Formats the value shown on the right. */
format?: (value: number) => string;
className?: string;
}
/**
* Thumbless slider: the whole pill is the control. The fill glides to the new
* value behind a rounded liquid cap, and the label reads inverted wherever the
* fill has covered it.
*/
export function FluidSlider({
label,
// The value arrives already snapped to the step. Rounding it again would
// only make the label and the announcement disagree with aria-valuenow.
format = (v) => `${v}%`,
className,
...options
}: FluidSliderProps) {
const reduce = useReducedMotion();
const { percent, current, dragging, trackProps, sliderProps } = useSlider({
...options,
formatValueText: options.formatValueText ?? format,
});
const target = useMotionValue(percent);
useEffect(() => {
target.set(percent);
}, [percent, target]);
const smooth = useSpring(target, SPRING_GLIDE);
const pos = reduce ? target : smooth;
// Reveal the fill by clipping a full-width layer rather than animating its
// width: at 0% the clip is empty, so no hairline of a sub-pixel-wide box is
// left behind, and the label inside is never scaled or re-laid out.
const uncovered = useTransform(pos, (v) => 100 - v);
const clipPath = useMotionTemplate`inset(0 ${uncovered}% 0 0 round 9999px)`;
const row = (
<>
{label ? <span className="truncate">{label}</span> : <span />}
<span className="tabular-nums">{format(current)}</span>
</>
);
return (
<motion.div
{...trackProps}
animate={reduce ? undefined : { scale: dragging ? 1.03 : 1 }}
transition={SPRING_PRESS}
className={cn(
"relative flex h-12 w-full touch-none overflow-hidden rounded-full bg-muted",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
{/* uncovered label — sits on the muted track */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-between px-5 text-sm font-medium text-foreground">
{row}
</div>
{/* fill + the same label, both clipped to the value, so the text inverts
as the fill covers it and lines up glyph for glyph with the copy
underneath. The clip's rounded right edge is the liquid cap. */}
<motion.div className="absolute inset-0" style={{ clipPath }}>
<div className="absolute inset-0 bg-foreground" />
<div className="pointer-events-none absolute inset-0 flex items-center justify-between px-5 text-sm font-medium text-background">
{row}
</div>
</motion.div>
{/* focusable, keyboard-controlled handle surface. The ring is inset — an
outset one is clipped away by the track's overflow-hidden. */}
<button
type="button"
{...sliderProps}
className="absolute inset-0 touch-none rounded-full outline-none ring-inset ring-foreground/40 focus-visible:ring-4"
/>
</motion.div>
);
}
API Reference
label?stringText shown on the left of the track.
—format?((value: number) => string)Formats the value shown on the right.
(v) => `${v}%`className?string—value?number—defaultValue?number—onValueChange?((value: number) => void)—min?number—max?number—step?number—disabled?boolean—aria-label?string—formatValueText?((value: number) => string)Announced instead of the raw number — pass one when the value carries a unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText.
—Wave Slider
range-slider-wave.tsxEqualizer bars peak around the handle and drop back once it passes, so the value moves down the track as a wave.
"use client";
import { useState } from "react";
import { WaveSlider } from "@/components/motion/range-slider-wave";
export function RangeSliderWavePreview() {
const [value, setValue] = useState(45);
return (
<div className="flex w-full max-w-md flex-col gap-2">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>Gain</span>
<span className="tabular-nums text-foreground">{value}</span>
</div>
<WaveSlider value={value} onValueChange={setValue} aria-label="Gain" />
</div>
);
}
"use client";
// beui.dev/components/motion/range-slider
import { motion, useReducedMotion } from "motion/react";
import { useMemo } from "react";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Per-bar spring: soft enough that the crest wobbles as it travels.
const SPRING_BAR = { type: "spring", stiffness: 420, damping: 20, mass: 0.5 } as const;
/** Bar count that reads as a wave without turning into a stripe pattern. */
const BARS = 32;
/** Width of the crest in bars — bigger spreads the bell wider. */
const SPREAD = 2.6;
export interface WaveSliderProps extends SliderOptions {
/** Number of bars drawn across the track. */
bars?: number;
className?: string;
}
/**
* Equalizer slider: bars rise into a crest around the handle position and fall
* back as it passes, so the value reads as a travelling wave. Bars up to the
* value are filled, the rest stay muted.
*/
export function WaveSlider({ bars = BARS, className, ...options }: WaveSliderProps) {
const reduce = useReducedMotion();
const { percent, dragging, trackProps, sliderProps } = useSlider(options);
// Keys only change when the bar count does — the list never reorders.
const keys = useMemo(() => Array.from({ length: bars }, (_, i) => `bar-${i}`), [bars]);
const head = (percent / 100) * (bars - 1);
const lanes = keys.map((key, i) => {
const distance = Math.abs(i - head);
return {
key,
distance,
// Gaussian crest centred on the handle.
crest: Math.exp(-(distance ** 2) / (2 * SPREAD ** 2)),
filled: i <= Math.round(head),
};
});
return (
<div
{...trackProps}
className={cn(
"relative flex h-20 w-full touch-none items-center justify-between gap-1",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
{lanes.map((lane) => (
<motion.span
key={lane.key}
className={cn(
"h-14 flex-1 origin-center rounded-full",
// /45 keeps the unfilled track above the 3:1 non-text contrast
// floor in both themes (measured 4.16 dark / 3.13 light)
lane.filled ? "bg-foreground" : "bg-foreground/45",
)}
animate={{
scaleY: reduce ? 0.4 : 0.22 + lane.crest * (dragging ? 0.78 : 0.6),
}}
transition={
reduce
? { duration: 0 }
: { ...SPRING_BAR, delay: Math.min(lane.distance * 0.012, 0.12) }
}
/>
))}
<button
type="button"
{...sliderProps}
className="absolute inset-0 touch-none rounded-xl outline-none ring-foreground/30 focus-visible:ring-4"
/>
</div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
"use client";
import { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Nearest legal value on [min, max] for the given step. max counts as a
* candidate when the step does not divide the range, so a pointer near the end
* does not snap back onto the last whole step. */
export function snapSliderValue(next: number, min: number, max: number, step: number): number {
// Neither case has a grid to walk. An empty range has exactly one legal
// point, and a non-positive step only needs a clamp, which also keeps the
// division below away from zero.
if (!(max > min)) return min;
if (!(step > 0)) return clamp(next, min, max);
const whole = Math.floor(Number(((max - min) / step).toFixed(6)));
const lastWhole = Number((min + whole * step).toFixed(6));
const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);
const snapped =
lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;
return Number(snapped.toFixed(6));
}
export interface SliderOptions {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
"aria-label"?: string;
/** Announced instead of the raw number — pass one when the value carries a
* unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText. */
formatValueText?: (value: number) => string;
}
/**
* Shared value + input plumbing for slider designs: controlled/uncontrolled
* value, step snapping, pointer-capture drag along a track and arrow-key
* control. Visuals and motion live in the component; this only owns the number.
*/
export function useSlider({
value,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
"aria-label": ariaLabel,
formatValueText,
}: SliderOptions) {
const trackRef = useRef<HTMLDivElement>(null);
const sliderEl = useRef<HTMLElement | null>(null);
// The state drives visuals. Move reads this ref instead, so the first
// pointermove after pointerdown does not have to wait on a re-render.
const draggingRef = useRef(false);
const [internal, setInternal] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const controlled = value !== undefined;
// Collapse inverted or empty ranges and non-positive steps here, so that
// percent, ticks and the keyboard maths never divide by zero or walk a
// NaN grid.
const lo = min;
const hi = max > min ? max : min;
const stride = step > 0 ? step : 1;
const current = clamp(controlled ? value : internal, lo, hi);
const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;
const commit = useCallback(
(next: number) => {
const clean = snapSliderValue(next, lo, hi, stride);
if (!controlled) setInternal(clean);
onValueChange?.(clean);
},
[controlled, onValueChange, lo, hi, stride],
);
const commitFromX = useCallback(
(clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
commit(lo + ratio * (hi - lo));
},
[commit, lo, hi],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
// Start the drag first: capture is a convenience, and a browser that
// refuses it — or a test DOM that has no pointer capture at all — must
// not take the drag down with it.
draggingRef.current = true;
setDragging(true);
capturePointer(event.currentTarget, event.pointerId);
// A click on the track should land keyboard focus on the handle.
sliderEl.current?.focus({ preventScroll: true });
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || disabled) return;
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
releasePointer(event.currentTarget, event.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowRight: current + stride,
ArrowUp: current + stride,
ArrowLeft: current - stride,
ArrowDown: current - stride,
PageUp: current + stride * 10,
PageDown: current - stride * 10,
Home: lo,
End: hi,
};
if (event.key in map) {
event.preventDefault();
commit(map[event.key]);
}
},
[disabled, current, stride, lo, hi, commit],
);
return {
current,
percent,
dragging,
min: lo,
max: hi,
step: stride,
commit,
/** Pointer handlers for the track element — drag anywhere on it. */
trackProps: {
ref: trackRef,
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
onLostPointerCapture: endDrag,
},
/** ARIA + keyboard props for the focusable slider element. */
sliderProps: {
// Callback keeps the handle typed across button/div/motion hosts.
ref: (node: HTMLElement | null) => {
sliderEl.current = node;
},
role: "slider" as const,
tabIndex: disabled ? -1 : 0,
"aria-label": ariaLabel,
"aria-valuemin": lo,
"aria-valuemax": hi,
"aria-valuenow": current,
"aria-valuetext": formatValueText?.(current),
"aria-disabled": disabled || undefined,
onKeyDown,
},
};
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/range-slider
import { motion, useReducedMotion } from "motion/react";
import { useMemo } from "react";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Per-bar spring: soft enough that the crest wobbles as it travels.
const SPRING_BAR = { type: "spring", stiffness: 420, damping: 20, mass: 0.5 } as const;
/** Bar count that reads as a wave without turning into a stripe pattern. */
const BARS = 32;
/** Width of the crest in bars — bigger spreads the bell wider. */
const SPREAD = 2.6;
export interface WaveSliderProps extends SliderOptions {
/** Number of bars drawn across the track. */
bars?: number;
className?: string;
}
/**
* Equalizer slider: bars rise into a crest around the handle position and fall
* back as it passes, so the value reads as a travelling wave. Bars up to the
* value are filled, the rest stay muted.
*/
export function WaveSlider({ bars = BARS, className, ...options }: WaveSliderProps) {
const reduce = useReducedMotion();
const { percent, dragging, trackProps, sliderProps } = useSlider(options);
// Keys only change when the bar count does — the list never reorders.
const keys = useMemo(() => Array.from({ length: bars }, (_, i) => `bar-${i}`), [bars]);
const head = (percent / 100) * (bars - 1);
const lanes = keys.map((key, i) => {
const distance = Math.abs(i - head);
return {
key,
distance,
// Gaussian crest centred on the handle.
crest: Math.exp(-(distance ** 2) / (2 * SPREAD ** 2)),
filled: i <= Math.round(head),
};
});
return (
<div
{...trackProps}
className={cn(
"relative flex h-20 w-full touch-none items-center justify-between gap-1",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
{lanes.map((lane) => (
<motion.span
key={lane.key}
className={cn(
"h-14 flex-1 origin-center rounded-full",
// /45 keeps the unfilled track above the 3:1 non-text contrast
// floor in both themes (measured 4.16 dark / 3.13 light)
lane.filled ? "bg-foreground" : "bg-foreground/45",
)}
animate={{
scaleY: reduce ? 0.4 : 0.22 + lane.crest * (dragging ? 0.78 : 0.6),
}}
transition={
reduce
? { duration: 0 }
: { ...SPRING_BAR, delay: Math.min(lane.distance * 0.012, 0.12) }
}
/>
))}
<button
type="button"
{...sliderProps}
className="absolute inset-0 touch-none rounded-xl outline-none ring-foreground/30 focus-visible:ring-4"
/>
</div>
);
}
API Reference
bars?numberNumber of bars drawn across the track.
32className?string—value?number—defaultValue?number—onValueChange?((value: number) => void)—min?number—max?number—step?number—disabled?boolean—aria-label?string—formatValueText?((value: number) => string)Announced instead of the raw number — pass one when the value carries a unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText.
—Bubble Slider
range-slider-bubble.tsxGrab the thumb and a value bubble pops out of it. The bubble tilts and squashes with how fast you drag, then settles upright.
"use client";
import { useState } from "react";
import { BubbleSlider } from "@/components/motion/range-slider-bubble";
export function RangeSliderBubblePreview() {
const [value, setValue] = useState(28);
return (
<div className="flex w-full max-w-sm flex-col gap-1">
<span className="text-sm text-muted-foreground">Drag fast and the bubble leans</span>
<BubbleSlider value={value} onValueChange={setValue} aria-label="Value" />
</div>
);
}
"use client";
// beui.dev/components/motion/range-slider
import {
AnimatePresence,
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
useVelocity,
} from "motion/react";
import { useEffect } from "react";
import { SPRING_GLIDE, SPRING_PANEL, SPRING_PRESS } from "@/lib/ease";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Loose enough that the bubble keeps leaning a beat after the pointer stops.
const SPRING_TILT = { stiffness: 260, damping: 22, mass: 0.4 } as const;
/** Drag speed (px/s of track percent) that maxes out lean and squash. */
const FULL_TILT = 320;
export interface BubbleSliderProps extends SliderOptions {
/** Formats the value shown in the bubble. */
format?: (value: number) => string;
className?: string;
}
/**
* Slider with a value bubble that pops out of the thumb on grab and reacts to
* how fast you drag: it leans into the direction of travel and squashes along
* the way, then settles upright when you let go.
*/
export function BubbleSlider({ format, className, ...options }: BubbleSliderProps) {
const reduce = useReducedMotion();
// A bare number needs no valueText — it would only repeat aria-valuenow.
const { percent, current, dragging, trackProps, sliderProps } = useSlider({
...options,
formatValueText: options.formatValueText ?? format,
});
// The value is already snapped to the step — rounding here would only make
// the bubble disagree with aria-valuenow on a fractional scale.
const readout = format ? format(current) : current;
const target = useMotionValue(percent);
useEffect(() => {
target.set(percent);
}, [percent, target]);
const smooth = useSpring(target, SPRING_GLIDE);
const pos = reduce ? target : smooth;
const left = useMotionTemplate`${pos}%`;
// One spring drives the whole reaction: lean is signed, squash reads its
// magnitude. Two springs off the same velocity would just run twice.
const velocity = useVelocity(pos);
const lean = useSpring(
useTransform(velocity, [-FULL_TILT, 0, FULL_TILT], [1, 0, -1], { clamp: true }),
SPRING_TILT,
);
const tilt = useTransform(lean, (v) => v * 16);
const squash = useTransform(lean, (v) => 1 + Math.abs(v) * 0.18);
const stretch = useTransform(lean, (v) => 1 - Math.abs(v) * 0.12);
return (
<div
className={cn(
// px/pb leave room for the thumb, the bubble and the 48px hit area to
// overhang the 8px track without escaping the component's own box
"relative flex h-20 w-full items-end px-5 pb-5",
options.disabled ? "pointer-events-none opacity-50" : undefined,
className,
)}
>
<div
{...trackProps}
className={cn(
"relative h-2 w-full touch-none rounded-full bg-muted",
TOUCH_GESTURE_CLASS,
options.disabled ? undefined : "cursor-grab active:cursor-grabbing",
)}
>
<motion.div
className="absolute inset-y-0 left-0 rounded-full bg-foreground"
style={{ width: left }}
/>
{/* thumb — overhangs the track by half its width at both ends, which the
wrapper's padding leaves room for */}
<motion.div
className="absolute top-1/2 size-5 rounded-full border-2 border-foreground bg-background shadow-sm"
style={{ left, x: "-50%", y: "-50%" }}
animate={reduce ? undefined : { scale: dragging ? 1.25 : 1 }}
transition={SPRING_PRESS}
/>
{/* bubble — anchored to the thumb, leaning with drag velocity */}
<motion.div
className="pointer-events-none absolute bottom-6"
style={{ left, x: "-50%" }}
>
<AnimatePresence>
{dragging ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, y: 10 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.12 } }
: { opacity: 0, scale: 0.5, y: 8, transition: { duration: 0.12 } }
}
transition={reduce ? { duration: 0.12 } : SPRING_PANEL}
style={
reduce
? undefined
: { rotate: tilt, scaleX: squash, scaleY: stretch, originY: 1 }
}
className="relative rounded-xl bg-foreground px-2.5 py-1 text-sm font-medium tabular-nums text-background shadow-md"
>
{readout}
<span className="absolute -bottom-1 left-1/2 size-2.5 -translate-x-1/2 rotate-45 rounded-[3px] bg-foreground" />
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
{/* 8px of track is not a touch target — pad the hit area out to 48px */}
<button
type="button"
{...sliderProps}
className="absolute -inset-y-5 inset-x-0 touch-none rounded-full outline-none ring-foreground/30 focus-visible:ring-4"
/>
</div>
</div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Nearest legal value on [min, max] for the given step. max counts as a
* candidate when the step does not divide the range, so a pointer near the end
* does not snap back onto the last whole step. */
export function snapSliderValue(next: number, min: number, max: number, step: number): number {
// Neither case has a grid to walk. An empty range has exactly one legal
// point, and a non-positive step only needs a clamp, which also keeps the
// division below away from zero.
if (!(max > min)) return min;
if (!(step > 0)) return clamp(next, min, max);
const whole = Math.floor(Number(((max - min) / step).toFixed(6)));
const lastWhole = Number((min + whole * step).toFixed(6));
const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);
const snapped =
lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;
return Number(snapped.toFixed(6));
}
export interface SliderOptions {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
"aria-label"?: string;
/** Announced instead of the raw number — pass one when the value carries a
* unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText. */
formatValueText?: (value: number) => string;
}
/**
* Shared value + input plumbing for slider designs: controlled/uncontrolled
* value, step snapping, pointer-capture drag along a track and arrow-key
* control. Visuals and motion live in the component; this only owns the number.
*/
export function useSlider({
value,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
"aria-label": ariaLabel,
formatValueText,
}: SliderOptions) {
const trackRef = useRef<HTMLDivElement>(null);
const sliderEl = useRef<HTMLElement | null>(null);
// The state drives visuals. Move reads this ref instead, so the first
// pointermove after pointerdown does not have to wait on a re-render.
const draggingRef = useRef(false);
const [internal, setInternal] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const controlled = value !== undefined;
// Collapse inverted or empty ranges and non-positive steps here, so that
// percent, ticks and the keyboard maths never divide by zero or walk a
// NaN grid.
const lo = min;
const hi = max > min ? max : min;
const stride = step > 0 ? step : 1;
const current = clamp(controlled ? value : internal, lo, hi);
const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;
const commit = useCallback(
(next: number) => {
const clean = snapSliderValue(next, lo, hi, stride);
if (!controlled) setInternal(clean);
onValueChange?.(clean);
},
[controlled, onValueChange, lo, hi, stride],
);
const commitFromX = useCallback(
(clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
commit(lo + ratio * (hi - lo));
},
[commit, lo, hi],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
// Start the drag first: capture is a convenience, and a browser that
// refuses it — or a test DOM that has no pointer capture at all — must
// not take the drag down with it.
draggingRef.current = true;
setDragging(true);
capturePointer(event.currentTarget, event.pointerId);
// A click on the track should land keyboard focus on the handle.
sliderEl.current?.focus({ preventScroll: true });
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || disabled) return;
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
releasePointer(event.currentTarget, event.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowRight: current + stride,
ArrowUp: current + stride,
ArrowLeft: current - stride,
ArrowDown: current - stride,
PageUp: current + stride * 10,
PageDown: current - stride * 10,
Home: lo,
End: hi,
};
if (event.key in map) {
event.preventDefault();
commit(map[event.key]);
}
},
[disabled, current, stride, lo, hi, commit],
);
return {
current,
percent,
dragging,
min: lo,
max: hi,
step: stride,
commit,
/** Pointer handlers for the track element — drag anywhere on it. */
trackProps: {
ref: trackRef,
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
onLostPointerCapture: endDrag,
},
/** ARIA + keyboard props for the focusable slider element. */
sliderProps: {
// Callback keeps the handle typed across button/div/motion hosts.
ref: (node: HTMLElement | null) => {
sliderEl.current = node;
},
role: "slider" as const,
tabIndex: disabled ? -1 : 0,
"aria-label": ariaLabel,
"aria-valuemin": lo,
"aria-valuemax": hi,
"aria-valuenow": current,
"aria-valuetext": formatValueText?.(current),
"aria-disabled": disabled || undefined,
onKeyDown,
},
};
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/range-slider
import {
AnimatePresence,
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
useVelocity,
} from "motion/react";
import { useEffect } from "react";
import { SPRING_GLIDE, SPRING_PANEL, SPRING_PRESS } from "@/lib/ease";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Loose enough that the bubble keeps leaning a beat after the pointer stops.
const SPRING_TILT = { stiffness: 260, damping: 22, mass: 0.4 } as const;
/** Drag speed (px/s of track percent) that maxes out lean and squash. */
const FULL_TILT = 320;
export interface BubbleSliderProps extends SliderOptions {
/** Formats the value shown in the bubble. */
format?: (value: number) => string;
className?: string;
}
/**
* Slider with a value bubble that pops out of the thumb on grab and reacts to
* how fast you drag: it leans into the direction of travel and squashes along
* the way, then settles upright when you let go.
*/
export function BubbleSlider({ format, className, ...options }: BubbleSliderProps) {
const reduce = useReducedMotion();
// A bare number needs no valueText — it would only repeat aria-valuenow.
const { percent, current, dragging, trackProps, sliderProps } = useSlider({
...options,
formatValueText: options.formatValueText ?? format,
});
// The value is already snapped to the step — rounding here would only make
// the bubble disagree with aria-valuenow on a fractional scale.
const readout = format ? format(current) : current;
const target = useMotionValue(percent);
useEffect(() => {
target.set(percent);
}, [percent, target]);
const smooth = useSpring(target, SPRING_GLIDE);
const pos = reduce ? target : smooth;
const left = useMotionTemplate`${pos}%`;
// One spring drives the whole reaction: lean is signed, squash reads its
// magnitude. Two springs off the same velocity would just run twice.
const velocity = useVelocity(pos);
const lean = useSpring(
useTransform(velocity, [-FULL_TILT, 0, FULL_TILT], [1, 0, -1], { clamp: true }),
SPRING_TILT,
);
const tilt = useTransform(lean, (v) => v * 16);
const squash = useTransform(lean, (v) => 1 + Math.abs(v) * 0.18);
const stretch = useTransform(lean, (v) => 1 - Math.abs(v) * 0.12);
return (
<div
className={cn(
// px/pb leave room for the thumb, the bubble and the 48px hit area to
// overhang the 8px track without escaping the component's own box
"relative flex h-20 w-full items-end px-5 pb-5",
options.disabled ? "pointer-events-none opacity-50" : undefined,
className,
)}
>
<div
{...trackProps}
className={cn(
"relative h-2 w-full touch-none rounded-full bg-muted",
TOUCH_GESTURE_CLASS,
options.disabled ? undefined : "cursor-grab active:cursor-grabbing",
)}
>
<motion.div
className="absolute inset-y-0 left-0 rounded-full bg-foreground"
style={{ width: left }}
/>
{/* thumb — overhangs the track by half its width at both ends, which the
wrapper's padding leaves room for */}
<motion.div
className="absolute top-1/2 size-5 rounded-full border-2 border-foreground bg-background shadow-sm"
style={{ left, x: "-50%", y: "-50%" }}
animate={reduce ? undefined : { scale: dragging ? 1.25 : 1 }}
transition={SPRING_PRESS}
/>
{/* bubble — anchored to the thumb, leaning with drag velocity */}
<motion.div
className="pointer-events-none absolute bottom-6"
style={{ left, x: "-50%" }}
>
<AnimatePresence>
{dragging ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, y: 10 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.12 } }
: { opacity: 0, scale: 0.5, y: 8, transition: { duration: 0.12 } }
}
transition={reduce ? { duration: 0.12 } : SPRING_PANEL}
style={
reduce
? undefined
: { rotate: tilt, scaleX: squash, scaleY: stretch, originY: 1 }
}
className="relative rounded-xl bg-foreground px-2.5 py-1 text-sm font-medium tabular-nums text-background shadow-md"
>
{readout}
<span className="absolute -bottom-1 left-1/2 size-2.5 -translate-x-1/2 rotate-45 rounded-[3px] bg-foreground" />
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
{/* 8px of track is not a touch target — pad the hit area out to 48px */}
<button
type="button"
{...sliderProps}
className="absolute -inset-y-5 inset-x-0 touch-none rounded-full outline-none ring-foreground/30 focus-visible:ring-4"
/>
</div>
</div>
);
}
API Reference
format?((value: number) => string)Formats the value shown in the bubble.
—className?string—value?number—defaultValue?number—onValueChange?((value: number) => void)—min?number—max?number—step?number—disabled?boolean—aria-label?string—formatValueText?((value: number) => string)Announced instead of the raw number — pass one when the value carries a unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText.
—Ruler Slider
range-slider-ruler.tsxThe needle stays put and the scale scrolls under it. A flick keeps going and settles on the nearest tick. Fractional steps read at the step's own precision.
"use client";
import { useState } from "react";
import { RulerSlider } from "@/components/motion/range-slider-ruler";
export function RangeSliderRulerPreview() {
const [value, setValue] = useState(72.5);
return (
<div className="w-full max-w-sm">
<RulerSlider
value={value}
onValueChange={setValue}
min={40}
max={120}
step={0.5}
gap={12}
majorEvery={10}
unit="kg"
aria-label="Weight"
/>
</div>
);
}
"use client";
// beui.dev/components/motion/range-slider
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion } from "motion/react";
import { type KeyboardEvent, useEffect, useRef } from "react";
import { type SliderOptions, snapSliderValue, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Settle spring for the snap after a flick — quick, no overshoot past the tick.
const SPRING_SNAP = { type: "spring", stiffness: 500, damping: 40, mass: 0.6 } as const;
export interface RulerSliderProps extends SliderOptions {
/** Pixels between two steps. */
gap?: number;
/** Label every Nth step; those ticks are drawn tall. */
majorEvery?: number;
/** Unit shown next to the value. */
unit?: string;
className?: string;
}
/**
* Ruler slider: the scale scrolls under a fixed needle instead of a handle
* moving along a track. Flicks carry momentum and settle onto the nearest tick.
*/
export function RulerSlider({
gap = 14,
majorEvery = 5,
unit,
className,
...options
}: RulerSliderProps) {
const reduce = useReducedMotion();
// Decimal places the step implies, so 0.5 reads "72.5" and 1 reads "72".
// Fixed width keeps the readout from jittering as the value rolls; tick
// labels stay trimmed so a whole-number scale is not littered with ".0".
// ponytail: reads 0 decimals for an exponential step (1e-7) — no such scale
// is legible on a ruler anyway, so no parsing beyond this.
const decimals = String(options.step ?? 1).split(".")[1]?.length ?? 0;
const readout = (value: number) => value.toFixed(decimals);
const { current, min, max, step, commit, sliderProps } = useSlider({
...options,
// "72.5 kg" beats a bare "72.5" for a screen reader — but a caller who
// formats the announcement itself outranks the unit.
formatValueText:
options.formatValueText ?? (unit ? (v) => `${readout(v)} ${unit}` : undefined),
});
// The range need not divide by the step (0–10 by 4). Full ticks stop at the
// last whole one and max gets a tick of its own, so the scale never runs past
// the value the slider can actually report.
const span = Number(((max - min) / step).toFixed(6));
const wholeSteps = Math.floor(span);
const remainder = span - wholeSteps;
const maxOffset = span * gap;
const x = useMotionValue(-((current - min) / step) * gap);
// While the pointer drives the strip (or its momentum still runs), x owns the
// value; outside of that the value owns x.
const interacting = useRef(false);
// True only while the pointer is down. It keeps a cancelled momentum's
// transition end from snapping underneath a fresh grab.
const holding = useRef(false);
// A new gesture or key press bumps this, so a snap that resolves late cannot
// clear interacting underneath an active drag.
const gesture = useRef(0);
// ponytail: every tick is in the DOM — fine to a few hundred (80 units at
// step 0.5 is 161). Window to the visible span if a finer step is ever needed.
// Each tick carries an offset because max sits `remainder` of a step past the
// last whole tick. Whenever remainder is under 0.5 that point falls inside
// the previous box, so an appended flex box can never centre on it.
const ticks = Array.from({ length: wholeSteps + 1 }, (_, i) => ({
// toFixed trims float dust from fractional steps (0.1 + 0.2 …).
value: Number((min + i * step).toFixed(6)),
major: i % majorEvery === 0,
offset: i * gap,
}));
// A tiny remainder puts this label close to the one before it. That is what
// a scale ending a hair past a step looks like.
if (remainder > 0) ticks.push({ value: max, major: true, offset: maxOffset });
const snapToTick = () => {
// The same nearest-tick rule useSlider applies. max counts as a candidate
// when the step does not divide the range, so a flick near the end does
// not settle on the last whole step.
const target = snapSliderValue(min + (-x.get() / gap) * step, min, max, step);
const snapped = -((target - min) / step) * gap;
const id = ++gesture.current;
if (reduce) {
x.set(snapped);
interacting.current = false;
return;
}
animate(x, snapped, SPRING_SNAP).then(() => {
if (gesture.current === id) interacting.current = false;
});
};
// A key press takes the scale back from momentum: without this the coasting
// strip keeps committing its own value and swallows the keyboard input.
const rootProps = {
...sliderProps,
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
x.stop();
gesture.current++;
interacting.current = false;
holding.current = false;
sliderProps.onKeyDown(event);
},
};
useEffect(() => {
if (interacting.current) return;
x.set(-((current - min) / step) * gap);
}, [current, min, step, gap, x]);
useMotionValueEvent(x, "change", (v) => {
if (!interacting.current) return;
commit(min + (-v / gap) * step);
});
return (
<div
{...rootProps}
className={cn(
"relative w-full touch-none overflow-hidden",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
"rounded-2xl outline-none ring-foreground/30 focus-visible:ring-4",
className,
)}
>
<div className="pointer-events-none flex items-baseline justify-center gap-1 pt-1 pb-3">
<span className="text-3xl font-semibold tabular-nums text-foreground">
{readout(current)}
</span>
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
</div>
{/* masked, not overlaid with background-coloured gradients — the fade has
to work on any surface the slider is dropped onto */}
<div className="relative h-12 [mask-image:linear-gradient(to_right,transparent,black_18%,black_82%,transparent)]">
{/* strip — dragged directly, so momentum comes from the drag gesture */}
<motion.div
drag={options.disabled ? false : "x"}
dragConstraints={{ left: -maxOffset, right: 0 }}
dragElastic={0.03}
dragMomentum={!reduce}
dragTransition={{ power: 0.22, timeConstant: 320 }}
onDragStart={() => {
gesture.current++;
interacting.current = true;
holding.current = true;
}}
// Momentum end when there is momentum, drag end when there is not.
onDragTransitionEnd={() => {
if (!holding.current) snapToTick();
}}
onDragEnd={() => {
holding.current = false;
if (reduce) snapToTick();
}}
// The ticks are positioned rather than laid out, so the row needs an
// explicit width plus half a gap of slop each side to cover the
// whole drag surface.
style={{ x, marginLeft: -gap / 2, width: maxOffset + gap }}
className="absolute inset-y-0 left-1/2"
>
{ticks.map((tick) => (
// pb reserves the label row, so minor ticks need no spacer node
<span
key={tick.value}
className="absolute bottom-0 flex -translate-x-1/2 flex-col items-center pb-[18px]"
style={{ left: tick.offset + gap / 2 }}
>
<span
className={cn(
"w-px rounded-full",
// minor ticks at /45 clear the 3:1 non-text floor in both themes
tick.major ? "h-7 bg-foreground/70" : "h-3.5 bg-foreground/45",
)}
/>
{tick.major ? (
<span className="absolute bottom-0 text-[10px] tabular-nums text-muted-foreground">
{tick.value}
</span>
) : null}
</span>
))}
</motion.div>
{/* needle — the read head the scale moves under */}
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2">
<span className="block h-9 w-[3px] rounded-full bg-foreground" />
</div>
</div>
</div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
"use client";
import { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Nearest legal value on [min, max] for the given step. max counts as a
* candidate when the step does not divide the range, so a pointer near the end
* does not snap back onto the last whole step. */
export function snapSliderValue(next: number, min: number, max: number, step: number): number {
// Neither case has a grid to walk. An empty range has exactly one legal
// point, and a non-positive step only needs a clamp, which also keeps the
// division below away from zero.
if (!(max > min)) return min;
if (!(step > 0)) return clamp(next, min, max);
const whole = Math.floor(Number(((max - min) / step).toFixed(6)));
const lastWhole = Number((min + whole * step).toFixed(6));
const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);
const snapped =
lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;
return Number(snapped.toFixed(6));
}
export interface SliderOptions {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
"aria-label"?: string;
/** Announced instead of the raw number — pass one when the value carries a
* unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText. */
formatValueText?: (value: number) => string;
}
/**
* Shared value + input plumbing for slider designs: controlled/uncontrolled
* value, step snapping, pointer-capture drag along a track and arrow-key
* control. Visuals and motion live in the component; this only owns the number.
*/
export function useSlider({
value,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
"aria-label": ariaLabel,
formatValueText,
}: SliderOptions) {
const trackRef = useRef<HTMLDivElement>(null);
const sliderEl = useRef<HTMLElement | null>(null);
// The state drives visuals. Move reads this ref instead, so the first
// pointermove after pointerdown does not have to wait on a re-render.
const draggingRef = useRef(false);
const [internal, setInternal] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const controlled = value !== undefined;
// Collapse inverted or empty ranges and non-positive steps here, so that
// percent, ticks and the keyboard maths never divide by zero or walk a
// NaN grid.
const lo = min;
const hi = max > min ? max : min;
const stride = step > 0 ? step : 1;
const current = clamp(controlled ? value : internal, lo, hi);
const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;
const commit = useCallback(
(next: number) => {
const clean = snapSliderValue(next, lo, hi, stride);
if (!controlled) setInternal(clean);
onValueChange?.(clean);
},
[controlled, onValueChange, lo, hi, stride],
);
const commitFromX = useCallback(
(clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
commit(lo + ratio * (hi - lo));
},
[commit, lo, hi],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
// Start the drag first: capture is a convenience, and a browser that
// refuses it — or a test DOM that has no pointer capture at all — must
// not take the drag down with it.
draggingRef.current = true;
setDragging(true);
capturePointer(event.currentTarget, event.pointerId);
// A click on the track should land keyboard focus on the handle.
sliderEl.current?.focus({ preventScroll: true });
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || disabled) return;
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
releasePointer(event.currentTarget, event.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowRight: current + stride,
ArrowUp: current + stride,
ArrowLeft: current - stride,
ArrowDown: current - stride,
PageUp: current + stride * 10,
PageDown: current - stride * 10,
Home: lo,
End: hi,
};
if (event.key in map) {
event.preventDefault();
commit(map[event.key]);
}
},
[disabled, current, stride, lo, hi, commit],
);
return {
current,
percent,
dragging,
min: lo,
max: hi,
step: stride,
commit,
/** Pointer handlers for the track element — drag anywhere on it. */
trackProps: {
ref: trackRef,
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
onLostPointerCapture: endDrag,
},
/** ARIA + keyboard props for the focusable slider element. */
sliderProps: {
// Callback keeps the handle typed across button/div/motion hosts.
ref: (node: HTMLElement | null) => {
sliderEl.current = node;
},
role: "slider" as const,
tabIndex: disabled ? -1 : 0,
"aria-label": ariaLabel,
"aria-valuemin": lo,
"aria-valuemax": hi,
"aria-valuenow": current,
"aria-valuetext": formatValueText?.(current),
"aria-disabled": disabled || undefined,
onKeyDown,
},
};
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/range-slider
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion } from "motion/react";
import { type KeyboardEvent, useEffect, useRef } from "react";
import { type SliderOptions, snapSliderValue, useSlider } from "@/lib/hooks/use-slider";
import { TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Settle spring for the snap after a flick — quick, no overshoot past the tick.
const SPRING_SNAP = { type: "spring", stiffness: 500, damping: 40, mass: 0.6 } as const;
export interface RulerSliderProps extends SliderOptions {
/** Pixels between two steps. */
gap?: number;
/** Label every Nth step; those ticks are drawn tall. */
majorEvery?: number;
/** Unit shown next to the value. */
unit?: string;
className?: string;
}
/**
* Ruler slider: the scale scrolls under a fixed needle instead of a handle
* moving along a track. Flicks carry momentum and settle onto the nearest tick.
*/
export function RulerSlider({
gap = 14,
majorEvery = 5,
unit,
className,
...options
}: RulerSliderProps) {
const reduce = useReducedMotion();
// Decimal places the step implies, so 0.5 reads "72.5" and 1 reads "72".
// Fixed width keeps the readout from jittering as the value rolls; tick
// labels stay trimmed so a whole-number scale is not littered with ".0".
// ponytail: reads 0 decimals for an exponential step (1e-7) — no such scale
// is legible on a ruler anyway, so no parsing beyond this.
const decimals = String(options.step ?? 1).split(".")[1]?.length ?? 0;
const readout = (value: number) => value.toFixed(decimals);
const { current, min, max, step, commit, sliderProps } = useSlider({
...options,
// "72.5 kg" beats a bare "72.5" for a screen reader — but a caller who
// formats the announcement itself outranks the unit.
formatValueText:
options.formatValueText ?? (unit ? (v) => `${readout(v)} ${unit}` : undefined),
});
// The range need not divide by the step (0–10 by 4). Full ticks stop at the
// last whole one and max gets a tick of its own, so the scale never runs past
// the value the slider can actually report.
const span = Number(((max - min) / step).toFixed(6));
const wholeSteps = Math.floor(span);
const remainder = span - wholeSteps;
const maxOffset = span * gap;
const x = useMotionValue(-((current - min) / step) * gap);
// While the pointer drives the strip (or its momentum still runs), x owns the
// value; outside of that the value owns x.
const interacting = useRef(false);
// True only while the pointer is down. It keeps a cancelled momentum's
// transition end from snapping underneath a fresh grab.
const holding = useRef(false);
// A new gesture or key press bumps this, so a snap that resolves late cannot
// clear interacting underneath an active drag.
const gesture = useRef(0);
// ponytail: every tick is in the DOM — fine to a few hundred (80 units at
// step 0.5 is 161). Window to the visible span if a finer step is ever needed.
// Each tick carries an offset because max sits `remainder` of a step past the
// last whole tick. Whenever remainder is under 0.5 that point falls inside
// the previous box, so an appended flex box can never centre on it.
const ticks = Array.from({ length: wholeSteps + 1 }, (_, i) => ({
// toFixed trims float dust from fractional steps (0.1 + 0.2 …).
value: Number((min + i * step).toFixed(6)),
major: i % majorEvery === 0,
offset: i * gap,
}));
// A tiny remainder puts this label close to the one before it. That is what
// a scale ending a hair past a step looks like.
if (remainder > 0) ticks.push({ value: max, major: true, offset: maxOffset });
const snapToTick = () => {
// The same nearest-tick rule useSlider applies. max counts as a candidate
// when the step does not divide the range, so a flick near the end does
// not settle on the last whole step.
const target = snapSliderValue(min + (-x.get() / gap) * step, min, max, step);
const snapped = -((target - min) / step) * gap;
const id = ++gesture.current;
if (reduce) {
x.set(snapped);
interacting.current = false;
return;
}
animate(x, snapped, SPRING_SNAP).then(() => {
if (gesture.current === id) interacting.current = false;
});
};
// A key press takes the scale back from momentum: without this the coasting
// strip keeps committing its own value and swallows the keyboard input.
const rootProps = {
...sliderProps,
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
x.stop();
gesture.current++;
interacting.current = false;
holding.current = false;
sliderProps.onKeyDown(event);
},
};
useEffect(() => {
if (interacting.current) return;
x.set(-((current - min) / step) * gap);
}, [current, min, step, gap, x]);
useMotionValueEvent(x, "change", (v) => {
if (!interacting.current) return;
commit(min + (-v / gap) * step);
});
return (
<div
{...rootProps}
className={cn(
"relative w-full touch-none overflow-hidden",
TOUCH_GESTURE_CLASS,
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
"rounded-2xl outline-none ring-foreground/30 focus-visible:ring-4",
className,
)}
>
<div className="pointer-events-none flex items-baseline justify-center gap-1 pt-1 pb-3">
<span className="text-3xl font-semibold tabular-nums text-foreground">
{readout(current)}
</span>
{unit ? <span className="text-sm text-muted-foreground">{unit}</span> : null}
</div>
{/* masked, not overlaid with background-coloured gradients — the fade has
to work on any surface the slider is dropped onto */}
<div className="relative h-12 [mask-image:linear-gradient(to_right,transparent,black_18%,black_82%,transparent)]">
{/* strip — dragged directly, so momentum comes from the drag gesture */}
<motion.div
drag={options.disabled ? false : "x"}
dragConstraints={{ left: -maxOffset, right: 0 }}
dragElastic={0.03}
dragMomentum={!reduce}
dragTransition={{ power: 0.22, timeConstant: 320 }}
onDragStart={() => {
gesture.current++;
interacting.current = true;
holding.current = true;
}}
// Momentum end when there is momentum, drag end when there is not.
onDragTransitionEnd={() => {
if (!holding.current) snapToTick();
}}
onDragEnd={() => {
holding.current = false;
if (reduce) snapToTick();
}}
// The ticks are positioned rather than laid out, so the row needs an
// explicit width plus half a gap of slop each side to cover the
// whole drag surface.
style={{ x, marginLeft: -gap / 2, width: maxOffset + gap }}
className="absolute inset-y-0 left-1/2"
>
{ticks.map((tick) => (
// pb reserves the label row, so minor ticks need no spacer node
<span
key={tick.value}
className="absolute bottom-0 flex -translate-x-1/2 flex-col items-center pb-[18px]"
style={{ left: tick.offset + gap / 2 }}
>
<span
className={cn(
"w-px rounded-full",
// minor ticks at /45 clear the 3:1 non-text floor in both themes
tick.major ? "h-7 bg-foreground/70" : "h-3.5 bg-foreground/45",
)}
/>
{tick.major ? (
<span className="absolute bottom-0 text-[10px] tabular-nums text-muted-foreground">
{tick.value}
</span>
) : null}
</span>
))}
</motion.div>
{/* needle — the read head the scale moves under */}
<div className="pointer-events-none absolute bottom-5 left-1/2 -translate-x-1/2">
<span className="block h-9 w-[3px] rounded-full bg-foreground" />
</div>
</div>
</div>
);
}
API Reference
gap?numberPixels between two steps.
14majorEvery?numberLabel every Nth step; those ticks are drawn tall.
5unit?stringUnit shown next to the value.
—className?string—value?number—defaultValue?number—onValueChange?((value: number) => void)—min?number—max?number—step?number—disabled?boolean—aria-label?string—formatValueText?((value: number) => string)Announced instead of the raw number — pass one when the value carries a unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText.
—Related components
Wheel Picker
iOS-style picker wheel: a 3D drum on native momentum scroll that snaps to the nearest notch, with wheel, drag and keyboard control. Composes side by side for date and time pickers, reduced-motion safe.
Switch
Toggle with a spring-driven thumb and press feedback.
Input
Text input with label, left/right icons, optional stable error row, error shake and success check draw.
Updated