Input
Slider
One number, chosen by dragging. The track is measured rather than assumed, and the thumb springs to a jump but never to a finger.
Usage
import { Slider } from '@carabine/ui/slider';A spring for a jump, and nothing for a finger. A thumb that eases towards the pointer is a thumb that is behind it, and the lag is the only thing you feel.
How it moves
Clicking the track is a jump, so it springs. Dragging is not, so the thumb is written straight to the pointer — one line, guarded, at the top of the move handler:
if (dragging.current) return; // a finger is not something to animate towardsThe track is measured — offsetWidth, watched by a guarded ResizeObserver — not assumed from a prop. And max is always reachable even when it does not sit on the step grid, because a slider whose maximum cannot be selected is a slider with a lie in its label:
return Math.abs(held - max) < Math.abs(held - grid) ? max : grid;Examples
Steps and marks
step quantises, 0 is continuous, and marks draws the grid — true for every step, or an array for the ones that matter. The tick list is capped at 60, because a mark every pixel is a texture, not a scale.
format writes both what is drawn and what is announced. Two formatters is how a slider ends up reading “40%” and saying “0.4”.
Shape
Customization
<Slider
label="Volume"
onValueChange={preview} // every move — cheap work only
onValueCommit={save} // once, on release — the expensive one
height={6} // the track's thickness
thumb={16}
radius={3}
theme="light"
/>Two callbacks because they answer different questions. onValueChange fires on every frame of a drag and belongs to whatever is cheap; onValueCommit fires once when the pointer is released, and that is where a request goes.
Styling reference
Tailwind utilities; there is no stylesheet for this component. The track is a recess — an opaque hole with inset shadows rather than a border, because a hole does not cast a shadow outwards. The thumb is an object on top of it and carries a hairline and a short drop shadow.
The pointer handling is defensive in three places, and all three came from the same bug: a native drag stealing the gesture, so pointerup never arrived and every later press was refused by a capture that was still held. preventDefault() on the press, user-select: none while dragging, and onLostPointerCapture — the one event that fires for every way capture can be taken away.
API reference
| Prop | Type | |
|---|---|---|
| className | string | Added to the component’s own classes, so yours wins |
| style | CSSProperties | Merged after the component’s own inline styles, so yours wins |
Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.
| Prop | Type | Default | |
|---|---|---|---|
| value | number | — | Controlled |
| defaultValue | number | 0 | |
| onValueChange | (value: number) => void | — | Every move |
| onValueCommit | (value: number) => void | — | Once, on release |
| min | number | 0 | |
| max | number | 100 | Always reachable, even off the step grid |
| step | number | 1 | 0 for continuous |
| marks | boolean | number[] | false | true for every step; capped at 60 |
| label | ReactNode | — | Above the track, and the accessible name |
| aria-label | string | — | The name when there is no visible label |
| showValue | boolean | false | The number, at the other end of the label row |
| format | (value: number) => string | String | The number and the announcement |
| height | number | 6 | The track’s thickness |
| thumb | number | 16 | |
| radius | number | half the height | |
| disabled | boolean | false | |
| theme | 'dark' | 'light' | 'dark' |
Keyboard
| Key | |
|---|---|
| ← ↓ | One step down |
| → ↑ | One step up |
| Page Up / Page Down | Ten steps |
| Home / End | Minimum and maximum |
The label is wired with aria-labelledby rather than <label htmlFor>: a label only names a labelable element, and a div[role=slider] is not one. Clicking it did nothing, silently, until it was.