# Slider

**When to use:** Let the user pick a number from a continuous or stepped range by dragging a thumb: volume, brightness, price ranges, zoom, quality presets. For free numeric entry use `NumberInput`; for a few discrete options use `OptionPicker` or `RadioGroup`.

**Keywords:** range slider, volume control

**Import:** `import { Slider } from '@ahrowe/ui'`
**Types:** `import type { SliderProps, SliderMark } from '@ahrowe/ui'`

```tsx
import { useState } from 'react';
import { Slider } from '@ahrowe/ui';

// Controlled
const [value, setValue] = useState(40);
<Slider value={value} onChange={setValue} aria-label="Value" />

// Uncontrolled
<Slider defaultValue={40} onChangeEnd={(v) => save(v)} />

// Value bubble above the thumb
<Slider value={volume} onChange={setVolume} showValue />

// Custom range, step, and formatting
<Slider
  value={price}
  onChange={setPrice}
  min={0}
  max={1000}
  step={50}
  showValue
  formatValue={(v) => `€${v}`}
/>

// Tick marks (labelled and unlabelled)
<Slider
  value={quality}
  onChange={setQuality}
  min={0}
  max={4}
  step={1}
  marks={[
    { value: 0, label: 'Low' },
    { value: 2, label: 'Medium' },
    { value: 4, label: 'High' },
  ]}
/>

// Larger thumb, themed colours (one-off)
<Slider
  value={value}
  onChange={setValue}
  size="26px"
  style={{
    '--slider-range-color': 'var(--success-color)',
    '--slider-thumb-color': 'var(--success-color)',
  } as React.CSSProperties}
/>
```

**Interaction:** drag the thumb or click anywhere on the track to jump there. With keyboard focus: `←`/`↓` and `→`/`↑` move by `step`, `Page Up`/`Page Down` by ten steps, `Home`/`End` jump to `min`/`max`. Values always snap to `step`.

**`onChange` vs `onChangeEnd`:** `onChange` fires on every change (each drag move and each key press); `onChangeEnd` fires once the value is committed, on pointer release or on each keyboard step. Use `onChangeEnd` to defer expensive work (a network save, a heavy re-render) until the user settles.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `range` | `boolean` | Two thumbs, working in `[from, to]` pairs |
| `minRange` | `number` | Smallest gap the thumbs may be pushed to (range mode, default `0`) |
| `thumbLabels` | `[string, string]` | Accessible name per thumb (range mode, default `'From'`/`'To'`) |
| `value` | `number \| [number, number]` | Controlled value (omit for uncontrolled) |
| `defaultValue` | `number \| [number, number]` | Initial value when uncontrolled (default `min`, or `[min, max]` in range mode) |
| `onChange` | `(value: number \| [number, number]) => void` | Fires on every change, matching the mode |
| `onChangeEnd` | `(value: number \| [number, number]) => void` | Fires once the value is committed |
| `min` | `number` | Minimum (default `0`) |
| `max` | `number` | Maximum (default `100`) |
| `step` | `number` | Snap increment (default `1`) |
| `disabled` | `boolean` | Disable interaction |
| `marks` | `SliderMark[]` | Tick marks, `{ value, label? }` |
| `showValue` | `boolean` | Show the current value in a bubble above the thumb, one per thumb in range mode. The bubble takes no room of its own, so leave space above the slider |
| `formatValue` | `(value: number) => ReactNode` | Format the bubble text and `aria-valuetext` |
| `size` | `string` | Thumb diameter, any CSS length. Defaults to `1.125em` of `--default-font-size`, i.e. 18px at a 16px base |
| `aria-label` | `string` | Accessible label |

**Accessibility:** renders `role="slider"` with `aria-valuemin` / `aria-valuemax` / `aria-valuenow` (and `aria-valuetext` when `formatValue` returns a string/number). Give it an `aria-label`, or name it from a visible label with `aria-labelledby`, which lands on the slider (on the group in range mode, where `thumbLabels` name the thumbs). Respects `prefers-reduced-motion` (no thumb/fill transition).

**Theming:** override these CSS variables theme-wide via `ThemeProvider` or per instance via `style`; each falls back to a built-in default:

| Variable | Falls back to |
|----------|---------------|
| `--slider-track-color` | `color-mix(in srgb, currentcolor 20%, transparent)`: a tint of the text colour, visible on a Card as on the page |
| `--slider-range-color` | `var(--primary-color)` |
| `--slider-thumb-color` | `var(--primary-color)` |
| `--slider-thumb-size` | `1.125x --default-font-size`, i.e. 18px at a 16px base (also settable per instance via `size`) |
| `--slider-track-height` | `0.375x --default-font-size`, i.e. 6px at a 16px base |

**Range mode:** set `range` for two thumbs and `[from, to]` pairs.

```tsx
const [price, setPrice] = useState<[number, number]>([20, 80]);
<Slider range value={price} onChange={setPrice} min={0} max={200} />

// Hold the ends apart, and name each thumb for screen readers
<Slider range defaultValue={[20, 80]} minRange={10} thumbLabels={['Cheapest', 'Dearest']} />
```

`range` is a discriminated prop, not a widening of `value`: with `range` the value is a
`[number, number]` and `onChange` receives one, without it both stay `number`. An existing
`onChange={(value: number) => …}` therefore keeps compiling untouched.

A thumb never crosses its neighbour. Push one past the other and it stops on it, or on `minRange`
short of it, which the neighbour wins over the step: a thumb lands exactly on its neighbour rather
than overshooting to the next step. The fill covers the span between the thumbs instead of running
from the minimum.

Note `range` the prop (two thumbs) and `range` the slot (the filled part of the track) are
different things that happen to share a word. The slot means the same in both modes.

**Accessibility:** a single-thumb slider is itself `role="slider"`. A range slider is a
`role="group"` named by `aria-label`, containing one `role="slider"` per thumb, each with its own
`aria-valuenow` and each bounded by its neighbour rather than by `min`/`max`, so assistive tech
announces the limit the thumb actually has. Both thumbs are tabbable and take the same keys.

**Slots:** `root` `track` `range` `thumb` `mark` `markLabel` `value`

## Sizing

The thumb and track scale with the theme's text, so the control stays in proportion with the value and mark labels beside it — those are sized from `--text-small-font-size` and already grew. `--slider-thumb-size` and `--slider-track-height` override them, and the `size` prop still wins where it is given.
