# Slider (/docs/slider)





The `Slider` wraps Ark UI's `Slider` state machine, so keyboard navigation (`←/→`, `Home/End`, `PageUp/PageDown`), RTL flipping, and ARIA wiring come for free. The wrapper layers a styled track / range / thumb, optional **marks**, and a floating **label** that follows each thumb.

Pass a `number` for a single thumb, or a `[number, number]` tuple for a two-thumb [range](#range). The value shape flows through the whole API — the callbacks echo back the same shape.

```tsx
import { Slider } from '@42-central/ui-react/slider';

<Slider defaultValue={42} label="42%" />        {/* single thumb */}
<Slider defaultValue={[25, 60]} />              {/* two-thumb range */}
```

## Playground [#playground]

<story.Playground />

## Sizes [#sizes]

Five track thicknesses — `xs` through `xl`. The thumb scales to match.

<Preview align="block" code="&#x22;<Slider size=\&#x22;xs\&#x22; defaultValue={42} />\n<Slider size=\&#x22;sm\&#x22; defaultValue={42} />\n<Slider size=\&#x22;md\&#x22; defaultValue={42} />\n<Slider size=\&#x22;lg\&#x22; defaultValue={42} />\n<Slider size=\&#x22;xl\&#x22; defaultValue={42} />&#x22;">
  <div className="flex flex-col gap-6">
    <Slider defaultValue="42" size="xs" />

    <Slider defaultValue="42" size="sm" />

    <Slider defaultValue="42" size="md" />

    <Slider defaultValue="42" size="lg" />

    <Slider defaultValue="42" size="xl" />
  </div>
</Preview>

## Colors [#colors]

Color is orthogonal to the rest of the slider — the accent rides on `data-color` and the range/thumb reference `--c-solid`, so **every palette in the kit flows through for free**, including dark mode and consumer-defined palettes. `brand` is the default; see [Colors](/docs/colors) for the full system.

<Preview align="block" code="&#x22;import { COLORS } from '@42-central/ui-react';\n// ['brand','neutral','red','orange','yellow','green','teal','cyan','blue','purple','pink']\n\n{COLORS.map((c) => (\n  <Slider key={c} color={c} defaultValue={60} />\n))}&#x22;">
  <div className="grid gap-x-8 gap-y-5 sm:grid-cols-2">
    {COLORS.map((c) => (
          <div key={c} className="flex flex-col gap-2">
            <code className="text-xs text-gray-light-500 dark:text-gray-dark-420">{c}</code>
            <Slider defaultValue={60} color={c} />
          </div>
        ))}
  </div>
</Preview>

## Marks [#marks]

Pass `marks` to paint dots along the track. Each mark is `{ value, label? }` — the label is rendered below the dot. Marks **don't** restrict input; they're purely visual unless you pair them with a matching `step`.

<Preview code="&#x22;<Slider\n  defaultValue={50}\n  marks={[\n    { value: 0,   label: '0' },\n    { value: 25,  label: '25' },\n    { value: 50,  label: '50' },\n    { value: 75,  label: '75' },\n    { value: 100, label: '100' },\n  ]}\n/>&#x22;">
  <Slider
    defaultValue="50"
    marks="[
    { value: 0, label: '0' },
    { value: 25, label: '25' },
    { value: 50, label: '50' },
    { value: 75, label: '75' },
    { value: 100, label: '100' },
  ]"
  />
</Preview>

For a segmented stepper, combine `marks` with `step`:

```tsx
<Slider
  min={0}
  max={10}
  step={1}
  defaultValue={4}
  marks={Array.from({ length: 11 }, (_, i) => ({ value: i }))}
/>
```

## Min, max & step [#min-max--step]

`min`, `max`, and `step` define the value space — nothing here assumes `0–100`. The label, marks, and keyboard steps all read from it, so a fractional `step` and a unit-formatted label compose without extra wiring.

<Preview code="`<Slider min={16} max={30} step={0.5} defaultValue={21} label={(v) => \`\${v}°C\`} />`">
  <Slider
    min="16"
    max="30"
    step="0.5"
    defaultValue="21"
    label="formatTemp"
    marks="[
    { value: 16, label: '16°' },
    { value: 23, label: '23°' },
    { value: 30, label: '30°' },
  ]"
  />
</Preview>

## Range [#range]

Pass a `[number, number]` tuple instead of a single number and the slider renders **two thumbs** with the filled range between them — for price bands, time windows, or any min/max selection. Everything else carries over: each thumb gets its own `label`, marks between the thumbs read as active, and the callbacks hand you back a `[number, number]`.

Use `minStepsBetweenThumbs` to keep the thumbs from crossing or sitting on top of each other.

`testId` lands on the root, and each thumb also gets its own `` `${testId}-thumb-${i}` `` (0-indexed) — handy for asserting against a specific thumb in a range. See the [Testing](/docs/testing) guide.

<Preview code="`<Slider defaultValue={[25, 60]} minStepsBetweenThumbs={5} />`">
  <Slider
    defaultValue="[25, 60]"
    minStepsBetweenThumbs="5"
    label="formatPercent"
    showLabel="always"
    marks="[
    { value: 0, label: '0' },
    { value: 50, label: '50' },
    { value: 100, label: '100' },
  ]"
  />
</Preview>

Controlled, the value stays a tuple end to end — `onChange` receives `[number, number]`, no shape guessing:

```tsx
'use client';
import { useState } from 'react';
import { Slider } from '@42-central/ui-react/slider';

export function PriceRange() {
  const [range, setRange] = useState<[number, number]>([25, 60]);
  return (
    <Slider
      value={range}
      onChange={setRange}
      onChangeEnd={(v) => filterByPrice(v)}
      minStepsBetweenThumbs={5}
      label={(v) => `$${v}`}
    />
  );
}
```

## Origin [#origin]

By default the filled range grows from the start of the track. Set `origin="center"` to fill outward from the midpoint — the right shape for balance, pan, or any signed adjustment around a neutral zero.

<Preview code="`<Slider min={-50} max={50} defaultValue={0} origin=&#x22;center&#x22; />`">
  <Slider
    min="-50"
    max="50"
    defaultValue="20"
    origin="center"
    label="formatSigned"
    marks="[
    { value: -50, label: 'L' },
    { value: 0, label: '0' },
    { value: 50, label: 'R' },
  ]"
  />
</Preview>

## Floating label [#floating-label]

`label` accepts any `ReactNode` and floats above the thumb. By default it appears on **hover, focus, or drag** (`showLabelOnHover`). Set it to `never` or `false` to hide it.

<Preview align="block" code="&#x22;<Slider label=\&#x22;35%\&#x22; />                              {/* on hover/focus */}\n<Slider label=\&#x22;35%\&#x22; showLabel=\&#x22;always\&#x22; />           {/* always visible */}\n<Slider showLabel=\&#x22;never\&#x22; />                        {/* no label */}\n<Slider label={null} />                             {/* no label */}&#x22;">
  <div className="flex flex-col gap-8">
    <Slider defaultValue="35" label="35%" />

    <Slider defaultValue="35" label="35%" showLabel="always" />

    <Slider defaultValue="35" showLabel="never" />

    <Slider defaultValue="35" label="null" />
  </div>
</Preview>

> The label must be serializable. To bind it to the current value, drive the slider as a controlled input from a client component (see below).

## Controlled [#controlled]

`onChange` fires on every change (drag, keyboard, click). `onChangeEnd` fires once the interaction settles — use it to debounce expensive work (network calls, expensive re-renders downstream). Drag the example below: **value** tracks every tick, **committed** only updates on release.

<controlledStory.Preview variant="Volume" align="block" />

A controlled value is also how you wire a dynamic label, since the label has to be computed in the same client tree as the slider:

```tsx
'use client';
import { useState } from 'react';
import { Slider } from '@42-central/ui-react/slider';

export function VolumeSlider() {
  const [value, setValue] = useState(50);
  return (
    <Slider
      value={value}
      onChange={setValue}
      onChangeEnd={(v) => commitToServer(v)}
      label={`${value}%`}
    />
  );
}
```

## Disabled & read-only [#disabled--read-only]

`disabled` dims the track and blocks all interaction. `readOnly` keeps full contrast and stays focusable — so the value is still announced to assistive tech — but rejects changes. Reach for `readOnly` to show a value the user can inspect but not edit.

<Preview align="block" code="&#x22;<Slider defaultValue={60} disabled />\n<Slider defaultValue={60} readOnly />&#x22;">
  <div className="flex flex-col gap-6">
    <Slider defaultValue="60" label="formatPercent" />

    <Slider defaultValue="60" showLabel="always" label="formatPercent" />
  </div>
</Preview>

## Forms [#forms]

Each thumb renders a hidden `<input>`, so a `name` wires the slider into native `<form>` submission and `FormData` — no controlled state required.

<Preview code="&#x22;<form action={save}>\n  <Slider name=\&#x22;volume\&#x22; defaultValue={60} />\n</form>&#x22;">
  <Slider name="volume" defaultValue="60" label="formatPercent" />
</Preview>

## Styling parts [#styling-parts]

Every part takes a class override through `classNames` — `root`, `control`, `track`, `range`, `thumb`, `thumbLabel`, and the mark slots. Use it to retheme a one-off without forking the component; the `data-color` system still drives anything you don't override.

<Preview code="&#x22;<Slider\n  defaultValue={65}\n  classNames={{\n    track: 'bg-purple-100 dark:bg-purple-950',\n    range: 'bg-purple-500',\n    thumb: 'border-purple-500',\n  }}\n/>&#x22;">
  <Slider
    defaultValue="65"
    classNames="{
    track: 'bg-purple-100 dark:bg-purple-950',
    range: 'bg-purple-500',
    thumb: 'border-purple-500',
  }"
  />
</Preview>

## API [#api]

<TypeTable
  type="{
  value: {
    type: 'number | [number, number]',
    description: 'Controlled value. A tuple renders a two-thumb range.',
  },
  defaultValue: {
    type: 'number | [number, number]',
    description: 'Uncontrolled initial value. Its shape decides single vs. range.',
  },
  onChange: {
    type: '(value: number | [number, number]) => void',
    description: 'Fires on every value change. Echoes the value shape.',
  },
  onChangeEnd: {
    type: '(value: number | [number, number]) => void',
    description: 'Fires once interaction settles. Use for debounced side effects.',
  },
  min: {
    type: 'number',
    default: '0',
    description: 'Minimum value.',
  },
  max: {
    type: 'number',
    default: '100',
    description: 'Maximum value.',
  },
  step: {
    type: 'number',
    default: '1',
    description: 'Increment per keypress / drag tick.',
  },
  minStepsBetweenThumbs: {
    type: 'number',
    default: '0',
    description: 'Minimum gap, in steps, enforced between range thumbs so they can’t cross or overlap.',
  },
  marks: {
    type: 'Array<{ value: number; label?: ReactNode }>',
    description: 'Tick marks painted on the track.',
  },
  label: {
    type: 'ReactNode | ((value: number) => ReactNode) | null',
    description: 'Floating label above each thumb. The function form receives that thumb’s value. Must be serializable; drive it from a controlled value for dynamic content.',
  },
  showLabel: {
    type: '&#x22;always&#x22; | &#x22;hover&#x22; | &#x22;never&#x22; | boolean',
    default: '&#x22;hover&#x22;',
    description: 'When false the label is always visible.',
  },
  size: {
    type: &#x22;'xs' | 'sm' | 'md' | 'lg' | 'xl'&#x22;,
    default: &#x22;'md'&#x22;,
    description: 'Track thickness and thumb size.',
  },
  color: {
    type: 'Color',
    default: &#x22;'brand'&#x22;,
    description: 'Range + thumb accent. Any palette from the kit color system (or a consumer-defined one).',
  },
  origin: {
    type: &#x22;'start' | 'center' | 'end'&#x22;,
    default: &#x22;'start'&#x22;,
    description: 'Where the filled range begins. &#x22;center&#x22; fills outward from the midpoint.',
  },
  disabled: {
    type: 'boolean',
    default: 'false',
    description: 'Non-interactive, dimmed thumb.',
  },
  readOnly: {
    type: 'boolean',
    default: 'false',
    description: 'Focusable and announced, but the value cannot change.',
  },
  name: {
    type: 'string',
    description: 'Name for the hidden input, wiring the slider into form submission.',
  },
  classNames: {
    type: '{ root?, control?, track?, range?, thumb?, thumbLabel?, mark?, markLabel? }',
    description: 'Per-part class overrides.',
  },
  testId: {
    type: 'string',
    description: 'data-testid on the root. Each thumb also gets its own `${testId}-thumb-${i}` (0-indexed), for range sliders with multiple thumbs. See the Testing guide.',
  },
}"
/>

All other props are forwarded to the underlying Ark `Slider.Root`.

## Accessibility [#accessibility]

* Keyboard: `←` / `→` step by `step`; `Shift + ←/→` jumps by 10×; `Home` / `End` snap to `min` / `max`; `PageUp` / `PageDown` jump by 10×.
* Range: each thumb is an independent, focusable control — `Tab` moves between them, and arrows drive whichever is focused. `minStepsBetweenThumbs` enforces the gap so they never cross.
* RTL: when the document `dir` is `rtl` (or a parent sets it), Ark inverts the thumb direction so `→` still increases the value visually.
* Focus ring is keyboard-only (`focus-visible:`) so pointer interactions stay quiet.
* A hidden `<input>` is rendered inside each thumb so the slider integrates with native `<form>` submission. Pass `name` to wire it up.
