Range Slider
Five slider designs on one shared engine: stepped dots, a thumbless liquid fill, equalizer bars, a speed-reactive value bubble and a scrolling ruler. All handle dragging, arrow, Page and Home/End keys and controlled values, with 44px touch targets on coarse pointers.
Range Slider
range-slider.tsxThe base RangeSlider: a dot marks every step and the bar-shaped thumb gives a small hop each time it snaps to a new one; the dots can be turned off.
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";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* 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";
// karaui.kamisama-v0.my.id/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 { 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);
// 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 items-center overflow-hidden rounded-lg bg-muted",
TOUCH_GESTURE_CLASS,
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>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
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.tsxThe whole pill is the control. A fill with a rounded cap flows to the value and the label text inverts where the fill sits under it; format controls the readout.
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";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* 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";
// karaui.kamisama-v0.my.id/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.tsxA row of bars swells into a crest centred on the value and sinks back as it moves on; bars before the value are filled, so the level reads at a glance.
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";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* 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";
// karaui.kamisama-v0.my.id/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.tsxPressing the thumb pops up a bubble with the current value that leans toward the direction of travel and flattens the faster you drag, straightening once you let go.
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";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* 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";
// karaui.kamisama-v0.my.id/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.tsxA fixed needle over a moving scale instead of a thumb: flick it and it coasts before resting on a tick. Major ticks carry labels, a unit can follow the number, and decimal steps keep their precision.
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";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* 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";
// karaui.kamisama-v0.my.id/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.
—Updated