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';
Volume
<Slider
  label="Volume"
  value={at}
  onValueChange={setAt}
  showValue
  format={(value) => `${value}%`}
/>

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 towards

The track is measuredoffsetWidth, 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.

Temperature
<Slider
  label="Temperature"
  min={16}
  max={30}
  step={0.5}
  defaultValue={21}
  marks
  showValue
  format={(value) => `${value.toFixed(1)}°C`}
/>

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

Thicker
<Slider label="Thicker" defaultValue={62} height={12} thumb={22} showValue />

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

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged 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.

PropTypeDefault
valuenumberControlled
defaultValuenumber0
onValueChange(value: number) => voidEvery move
onValueCommit(value: number) => voidOnce, on release
minnumber0
maxnumber100Always reachable, even off the step grid
stepnumber10 for continuous
marksboolean | number[]falsetrue for every step; capped at 60
labelReactNodeAbove the track, and the accessible name
aria-labelstringThe name when there is no visible label
showValuebooleanfalseThe number, at the other end of the label row
format(value: number) => stringStringThe number and the announcement
heightnumber6The track’s thickness
thumbnumber16
radiusnumberhalf the height
disabledbooleanfalse
theme'dark' | 'light''dark'

Keyboard

Key
← ↓One step down
→ ↑One step up
Page Up / Page DownTen steps
Home / EndMinimum 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.