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.
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,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { useEffect } from "react";
import { SPRING_GLIDE } from "@/lib/ease";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
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);
// 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 left = useMotionTemplate`${pos}%`;
// Self-offset the thumb from 0% (flush left) to -100% (flush right) of its
// own width so it stays fully inside the track at both ends — no clip, no gap.
const thumbX = useTransform(pos, (p) => `${-p}%`);
// 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 select-none items-center overflow-hidden rounded-lg bg-muted",
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
{/* fill — runs from the left edge to the thumb, consistent tone */}
<motion.div className="absolute inset-y-0 left-0 bg-foreground/15" style={{ width: left }} />
{/* Ticks, inset by half the thumb's width. That inset is the span the
thumb's own centre travels, so a dot sits where the thumb lands. */}
<div className="pointer-events-none absolute inset-x-[3px] 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>
{/* vertical bar thumb — contained at both ends via thumbX */}
<motion.div
{...sliderProps}
animate={reduce ? undefined : { scaleY: dragging ? 1.35 : 1 }}
transition={SPRING_BOUNCY}
className="absolute top-1/2 h-5 w-1.5 rounded-sm bg-foreground shadow-sm outline-none ring-inset ring-foreground/30 focus-visible:ring-4"
style={{ left, 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";
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;
// optional: test DOMs and older browsers omit pointer capture
event.currentTarget.setPointerCapture?.(event.pointerId);
draggingRef.current = true;
setDragging(true);
// 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>) => {
// Releasing without capture throws. The other pointer hooks guard it the
// same way.
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(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,
},
};
}
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 } from "@/lib/ease";
import { type SliderOptions, useSlider } from "@/lib/hooks/use-slider";
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);
// 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 left = useMotionTemplate`${pos}%`;
// Self-offset the thumb from 0% (flush left) to -100% (flush right) of its
// own width so it stays fully inside the track at both ends — no clip, no gap.
const thumbX = useTransform(pos, (p) => `${-p}%`);
// 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 select-none items-center overflow-hidden rounded-lg bg-muted",
options.disabled
? "pointer-events-none opacity-50"
: "cursor-grab active:cursor-grabbing",
className,
)}
>
{/* fill — runs from the left edge to the thumb, consistent tone */}
<motion.div className="absolute inset-y-0 left-0 bg-foreground/15" style={{ width: left }} />
{/* Ticks, inset by half the thumb's width. That inset is the span the
thumb's own centre travels, so a dot sits where the thumb lands. */}
<div className="pointer-events-none absolute inset-x-[3px] 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>
{/* vertical bar thumb — contained at both ends via thumbX */}
<motion.div
{...sliderProps}
animate={reduce ? undefined : { scaleY: dragging ? 1.35 : 1 }}
transition={SPRING_BOUNCY}
className="absolute top-1/2 h-5 w-1.5 rounded-sm bg-foreground shadow-sm outline-none ring-inset ring-foreground/30 focus-visible:ring-4"
style={{ left, 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 { 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 select-none overflow-hidden rounded-full bg-muted",
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";
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;
// optional: test DOMs and older browsers omit pointer capture
event.currentTarget.setPointerCapture?.(event.pointerId);
draggingRef.current = true;
setDragging(true);
// 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>) => {
// Releasing without capture throws. The other pointer hooks guard it the
// same way.
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(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,
},
};
}
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 { 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 select-none overflow-hidden rounded-full bg-muted",
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 { 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 select-none items-center justify-between gap-1",
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";
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;
// optional: test DOMs and older browsers omit pointer capture
event.currentTarget.setPointerCapture?.(event.pointerId);
draggingRef.current = true;
setDragging(true);
// 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>) => {
// Releasing without capture throws. The other pointer hooks guard it the
// same way.
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(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,
},
};
}
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 { 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 select-none items-center justify-between gap-1",
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 { 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 select-none rounded-full bg-muted",
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";
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;
// optional: test DOMs and older browsers omit pointer capture
event.currentTarget.setPointerCapture?.(event.pointerId);
draggingRef.current = true;
setDragging(true);
// 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>) => {
// Releasing without capture throws. The other pointer hooks guard it the
// same way.
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(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,
},
};
}
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 { 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 select-none rounded-full bg-muted",
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 { 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 select-none overflow-hidden",
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";
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;
// optional: test DOMs and older browsers omit pointer capture
event.currentTarget.setPointerCapture?.(event.pointerId);
draggingRef.current = true;
setDragging(true);
// 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>) => {
// Releasing without capture throws. The other pointer hooks guard it the
// same way.
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture(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,
},
};
}
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 { 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 select-none overflow-hidden",
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.
—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated