Slider
Preview
Basic
Loading…
Preview
Code
ts
import Slider from '@/components/form/Slider';src/components/form/Slider.tsx
AI prompt
text
Build a slider component (single value or range, horizontal or vertical) in React + TypeScript + Tailwind CSS.
## Look
- Row: `flex items-center gap-3 text-xs w-full` (vertical: `flex-col items-center h-40`); disabled at 50% opacity.
- Hit area: a padded wrapper (`px-2 py-2`, `flex-1`, `touch-none select-none cursor-pointer`) around a thin rail. The rail is what values are measured against; the padding just enlarges the target.
- Rail `h-1.5 w-full rounded-full bg-slate-200 dark:bg-slate-700` (vertical `w-1.5 h-full`). Fill `rounded-full bg-indigo-600 dark:bg-indigo-500` from min to the value (single) or between the two thumbs (range).
- Thumbs: 16px circles, `border-2 bg-white border-indigo-600 dark:bg-slate-900 dark:border-indigo-400 shadow-sm`, centred on the value, `hover:shadow-md`, focus `ring-2 ring-indigo-400 ring-offset-1 dark:ring-offset-slate-900`.
- Optional readout beside the rail: `tabular-nums font-semibold text-slate-700 dark:text-slate-200 whitespace-nowrap`, "40%" or "$20 – $75". Reserve its widest possible width up front (in `ch`, from formatting min and max) so the rail doesn't shrink under the pointer when the value gains a digit.
## Behaviour
- `value` is a number or a `[low, high]` pair; `onChange` returns the same shape. Only call it when the value actually changes.
- Snap to `min + n * step`, clamp to [min, max], and round to the step's decimal places to kill float noise (0.1 + 0.2). Fractional steps allowed.
- Pointer: press anywhere on the track moves the NEAREST thumb there, focuses it and keeps dragging (pointer capture, preventDefault so text doesn't select). If both thumbs are stacked, decide which one from the direction of the first move. Commit the release point on pointerup too, so a quick flick doesn't stop short.
- Range thumbs clamp at their partner rather than crossing it.
- Keys on a thumb: ←/↓ −step, →/↑ +step, PageUp/PageDown ± one tenth of the span (at least one step), Home/End to the ends of that thumb's allowed range (its partner, for a range thumb). Vertical maps bottom = min.
## API
`value: number | [number, number]`, `onChange`, `min = 0`, `max = 100`, `step = 1`, `showValue = false`, `formatValue = String` (used for the readout AND `aria-valuetext`), `disabled`, `orientation: 'horizontal' | 'vertical' = 'horizontal'`, `label` (single thumb's name), `thumbLabels = ['Minimum', 'Maximum']`, `className`.
## Accessibility
- The THUMBS are the `role="slider"` elements (a range is two sliders), with `aria-valuemin/max` narrowed to the partner for a range, `aria-valuenow`, `aria-valuetext`, `aria-orientation`, `aria-disabled`; `tabIndex -1` when disabled. The readout is `aria-hidden`.
## Demo
Opacity 40% ("40%" readout); price range [20, 75] step 5 as "$20 – $75"; a 0–1 threshold at step 0.05; two vertical sliders (Volume 60, a band [30, 70]); a disabled one at 30.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useRef } from 'react';
import { cn } from '@/lib/cn';
export type SliderValue = number | [number, number];
export interface SliderProps {
/**
* A number for one thumb, a `[low, high]` pair for a range. `onChange` hands
* back the same shape it was given, so the caller never has to narrow.
*/
value: SliderValue;
onChange: (value: SliderValue) => void;
min?: number;
max?: number;
/** Values snap to `min + n * step`. Fractional steps are fine (0.1, 0.25). */
step?: number;
/** Print the current value(s) beside the track, through `formatValue`. */
showValue?: boolean;
/** Formats the readout AND `aria-valuetext` — "40%" is read out, not "40". */
formatValue?: (value: number) => string;
disabled?: boolean;
orientation?: 'horizontal' | 'vertical';
/** Accessible name for a single thumb. A range labels its thumbs with `thumbLabels`. */
label?: string;
thumbLabels?: [string, string];
className?: string;
}
/** Round away the float noise that `0.1 + 0.2` leaves behind, to the step's precision. */
function decimals(step: number) {
const s = String(step);
return s.includes('.') ? s.length - s.indexOf('.') - 1 : 0;
}
/**
* Single-value or range slider.
*
* The thumbs are the focusable `role="slider"` elements — a range is two
* sliders, one per thumb, which is what the ARIA pattern specifies and what a
* screen reader expects ("Minimum, 20" then "Maximum, 80"). The track takes the
* pointer: a press anywhere on it moves the NEAREST thumb there and keeps
* dragging it, so the whole bar is a target rather than a 16px circle.
*/
export default function Slider({
value,
onChange,
min = 0,
max = 100,
step = 1,
showValue = false,
formatValue = String,
disabled = false,
orientation = 'horizontal',
label,
thumbLabels = ['Minimum', 'Maximum'],
className,
}: SliderProps) {
const railRef = useRef<HTMLDivElement>(null);
const thumbRefs = useRef<Array<HTMLDivElement | null>>([]);
/** Index being dragged; -1 = two thumbs stacked, decided by the first move. */
const dragRef = useRef<number | null>(null);
const isRange = Array.isArray(value);
const values: number[] = isRange ? [value[0], value[1]] : [value];
const vertical = orientation === 'vertical';
const span = max - min || 1;
const pct = (v: number) => ((Math.min(max, Math.max(min, v)) - min) / span) * 100;
const snap = (v: number) => {
const clamped = Math.min(max, Math.max(min, v));
const snapped = min + Math.round((clamped - min) / step) * step;
return Number(Math.min(max, snapped).toFixed(decimals(step)));
};
/**
* Commit a new value for one thumb. A range thumb is clamped at its partner
* rather than allowed to cross it — swapping them mid-drag would move focus
* out from under the keyboard user and flip which thumb is "low".
*/
const commit = (index: number, raw: number) => {
let next = snap(raw);
if (!isRange) {
if (next !== value) onChange(next);
return;
}
const [lo, hi] = values;
if (index === 0) next = Math.min(next, hi);
else next = Math.max(next, lo);
const pair: [number, number] = index === 0 ? [next, hi] : [lo, next];
if (pair[0] !== lo || pair[1] !== hi) onChange(pair);
};
const valueAt = (clientX: number, clientY: number) => {
const rect = railRef.current!.getBoundingClientRect();
const ratio = vertical
? (rect.bottom - clientY) / (rect.height || 1)
: (clientX - rect.left) / (rect.width || 1);
return min + Math.min(1, Math.max(0, ratio)) * span;
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (disabled || e.button !== 0) return;
// Without this the drag also selects the page's text and, on touch, the
// browser treats it as the start of a scroll.
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
const v = valueAt(e.clientX, e.clientY);
let index = 0;
if (isRange) {
const [lo, hi] = values;
if (lo === hi) {
// Stacked thumbs: which one the user "meant" is only knowable from the
// direction they drag, so defer — otherwise the low thumb wins, is
// clamped at its partner, and the drag appears stuck.
index = v > hi ? 1 : v < lo ? 0 : -1;
} else {
index = Math.abs(v - lo) <= Math.abs(v - hi) ? 0 : 1;
}
}
dragRef.current = index;
if (index !== -1) {
commit(index, v);
thumbRefs.current[index]?.focus();
}
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (dragRef.current === null) return;
const v = valueAt(e.clientX, e.clientY);
if (dragRef.current === -1) {
if (snap(v) === values[0]) return;
dragRef.current = v < values[0] ? 0 : 1;
thumbRefs.current[dragRef.current]?.focus();
}
commit(dragRef.current, v);
};
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (dragRef.current === null) return;
// Browsers coalesce pointermoves to the frame, and the last one can be
// dropped when the release lands in the same frame — commit the release
// point too, or a quick flick stops short of where the pointer let go.
if (e.type === 'pointerup' && dragRef.current !== -1) commit(dragRef.current, valueAt(e.clientX, e.clientY));
dragRef.current = null;
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
const onKeyDown = (index: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const current = values[index];
const big = Math.max(step, snap(min + span / 10) - min);
// Home/End go to the ends of the range this thumb may occupy, which for a
// range thumb is its partner, not the track's end.
const floor = isRange && index === 1 ? values[0] : min;
const ceil = isRange && index === 0 ? values[1] : max;
const next: Record<string, number> = {
ArrowRight: current + step,
ArrowUp: current + step,
ArrowLeft: current - step,
ArrowDown: current - step,
PageUp: current + big,
PageDown: current - big,
Home: floor,
End: ceil,
};
if (!(e.key in next)) return;
e.preventDefault();
commit(index, next[e.key]);
};
const [lo, hi] = isRange ? [pct(values[0]), pct(values[1])] : [0, pct(values[0])];
const fillStyle: React.CSSProperties = vertical
? { bottom: `${lo}%`, height: `${hi - lo}%` }
: { left: `${lo}%`, width: `${hi - lo}%` };
const readout = values.map(formatValue).join(' – ');
// Reserve the readout's widest possible width up front. Sized to the current
// text, it grows as the value crosses 99 → 100, the rail shrinks under the
// pointer, and the value being dragged shifts by itself.
const widest = Math.max(formatValue(min).length, formatValue(max).length);
const readoutWidth = `${(isRange ? widest * 2 + 3 : widest) + 1}ch`;
return (
<div
className={cn(
'flex gap-3 text-xs',
vertical ? 'flex-col items-center h-40' : 'items-center w-full',
disabled && 'opacity-50',
className,
)}
>
<div
// The padded wrapper is the pointer target; the thin rail inside it is
// what positions are measured against, so the padding widens the hit
// area without skewing the value under the pointer.
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={cn(
'relative touch-none select-none',
vertical ? 'h-full px-2 py-2' : 'flex-1 py-2 px-2',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
)}
>
<div
ref={railRef}
className={cn(
'relative rounded-full bg-slate-200 dark:bg-slate-700',
vertical ? 'h-full w-1.5' : 'h-1.5 w-full',
)}
>
<div
className={cn(
'absolute rounded-full bg-indigo-600 dark:bg-indigo-500',
vertical ? 'left-0 w-full' : 'top-0 h-full',
)}
style={fillStyle}
/>
{values.map((v, i) => {
const at = pct(v);
return (
<div
key={i}
ref={(el) => {
thumbRefs.current[i] = el;
}}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={isRange ? thumbLabels[i] : label}
aria-valuemin={isRange && i === 1 ? values[0] : min}
aria-valuemax={isRange && i === 0 ? values[1] : max}
aria-valuenow={v}
aria-valuetext={formatValue(v)}
aria-orientation={orientation}
aria-disabled={disabled || undefined}
onKeyDown={onKeyDown(i)}
className={cn(
'absolute h-4 w-4 rounded-full border-2 shadow-sm transition-shadow',
'bg-white border-indigo-600 dark:bg-slate-900 dark:border-indigo-400',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-slate-900',
vertical ? 'left-1/2 -translate-x-1/2 translate-y-1/2' : 'top-1/2 -translate-x-1/2 -translate-y-1/2',
!disabled && 'hover:shadow-md active:cursor-grabbing',
)}
style={vertical ? { bottom: `${at}%` } : { left: `${at}%` }}
/>
);
})}
</div>
</div>
{showValue && (
<span
className={cn(
'shrink-0 whitespace-nowrap tabular-nums font-semibold text-slate-700 dark:text-slate-200',
vertical ? 'text-center' : 'text-right',
)}
style={{ minWidth: readoutWidth }}
aria-hidden
>
{readout}
</span>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value* | SliderValue | — | A number for one thumb, a `[low, high]` pair for a range. `onChange` hands back the same shape it was given, so the caller never has to narrow. |
onChange* | (value: SliderValue) => void | — | |
min | number | 0 | |
max | number | 100 | |
step | number | 1 | Values snap to `min + n * step`. Fractional steps are fine (0.1, 0.25). |
showValue | boolean | false | Print the current value(s) beside the track, through `formatValue`. |
formatValue | (value: number) => string | String | Formats the readout AND `aria-valuetext` — "40%" is read out, not "40". |
disabled | boolean | false | |
orientation | 'horizontal' | 'vertical' | 'horizontal' | |
label | string | — | Accessible name for a single thumb. A range labels its thumbs with `thumbLabels`. |
thumbLabels | [string, string] | ['Minimum', 'Maximum'] | |
className | string | — |