# Slider

> Single-thumb slider primitive that owns clamped, stepped value state and pointer-drag plus keyboard adjustment while you own the track, range, and thumb visuals.

Source: https://docs.astra-void.xyz/lattice-ui/components/slider/

`@lattice-ui/react-slider` · Feature limited · import `Slider` · depends on `runtime`, `focus`, `motion`

Slider is the primitive for picking a number from a continuous range: volume, sensitivity, brightness, and any "drag to set a value" control. It owns the value — clamping it to `[min, max]` and snapping it to `step` — and translates pointer drags and keyboard input on the track and thumb into value changes, so your component only renders the track, the filled range, and the thumb.

Reach for Slider when a control needs a **single numeric value**, **drag interaction on a track**, and **predictable clamping and stepping** without you doing the pointer math.

## Preview

The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive.

_Interactive preview._

## Import

```ts
import { Slider } from "@lattice-ui/react-slider";
```

## Anatomy

`Root`, `Track`, and `Thumb` form the minimum useful slider. `Range` is optional but is the usual way to show the filled portion up to the current value.

```tsx title="Slider anatomy"
<Slider.Root>
  <Slider.Track>
    <Slider.Range />
    <Slider.Thumb />
  </Slider.Track>
</Slider.Root>
```

| Part | Required | Responsibility |
| --- | --- | --- |
| `Slider.Root` | yes | Owns clamped/stepped value state, drag lifecycle, and orientation, shared through context. |
| `Slider.Track` | yes | The draggable rail; an input on it starts a drag toward that position. |
| `Slider.Range` | no | The filled portion from the start of the track to the current value. |
| `Slider.Thumb` | yes | The draggable handle positioned at the current value; also handles keyboard adjustment. |

> **Single thumb only**
>
> This release of Slider is **single-thumb only**. There is no range/dual-thumb mode, and `value`/`defaultValue` are a single `number`, not an array. Compose two independent sliders or track your own state if you need a min/max range until multi-thumb lands.

## Examples

### Basic volume slider

Uncontrolled state seeded with `defaultValue`. Every part renders unstyled, so the track, range and thumb below each carry their own size and color — the primitive owns only the geometry it computes from the value. `onValueChange` would fire on every drag tick, but for a "save on release" control you only need `onValueCommit` — it fires once when the interaction ends, which is the right moment to persist the setting or send it over the network.

```tsx title="VolumeSlider.tsx"
import { Slider } from "@lattice-ui/react-slider";

export function VolumeSlider(props: { onSave: (volume: number) => void }) {
  return (
    <Slider.Root
      defaultValue={40}
      min={0}
      max={100}
      step={5}
      onValueCommit={(volume) => props.onSave(volume)}
    >
      <Slider.Track>
        <Slider.Range />
        <Slider.Thumb />
      </Slider.Track>
    </Slider.Root>
  );
}
```

### Controlled slider with live readout

Pass `value` and `onValueChange` when something outside the slider needs the number as it moves — here a label that tracks the drag in real time. `Slider.Root` renders no instance of its own (it is a context provider), so the track participates directly in the surrounding row layout.

```tsx title="BrightnessSlider.tsx"
import { useState } from "@rbxts/react";
import { Slider } from "@lattice-ui/react-slider";

export function BrightnessSlider() {
  const [brightness, setBrightness] = useState(70);

  return (
    <frame BackgroundTransparency={1} Size={UDim2.fromOffset(330, 32)}>
      <uilistlayout
        FillDirection={Enum.FillDirection.Horizontal}
        Padding={new UDim(0, 12)}
        VerticalAlignment={Enum.VerticalAlignment.Center}
      />

      <Slider.Root value={brightness} onValueChange={setBrightness}>
        <Slider.Track>
          <Slider.Range />
          <Slider.Thumb />
        </Slider.Track>
      </Slider.Root>

      <textlabel
        BackgroundTransparency={1}
        Size={UDim2.fromOffset(48, 20)}
        Text={`${brightness}%`}
        TextColor3={Color3.fromRGB(236, 241, 249)}
        TextXAlignment={Enum.TextXAlignment.Left}
      />
    </frame>
  );
}
```

### Fine-grained steps with min and max

`min`, `max`, and `step` shape the whole value space: every value — incoming, dragged, or keyed — is clamped to `[min, max]` and snapped to the nearest multiple of `step` counted from `min`. A camera-sensitivity slider from 0.1 to 2 in 0.05 increments lands only on 0.10, 0.15, 0.20, and so on; format the readout yourself since the value is a plain number.

```tsx title="SensitivitySlider.tsx"
import { useState } from "@rbxts/react";
import { Slider } from "@lattice-ui/react-slider";

export function SensitivitySlider(props: { onCommit: (sensitivity: number) => void }) {
  const [sensitivity, setSensitivity] = useState(1);

  return (
    <frame BackgroundTransparency={1} Size={UDim2.fromOffset(330, 32)}>
      <uilistlayout
        FillDirection={Enum.FillDirection.Horizontal}
        Padding={new UDim(0, 12)}
        VerticalAlignment={Enum.VerticalAlignment.Center}
      />

      <Slider.Root
        value={sensitivity}
        onValueChange={setSensitivity}
        onValueCommit={props.onCommit}
        min={0.1}
        max={2}
        step={0.05}
      >
        <Slider.Track>
          <Slider.Range />
          <Slider.Thumb />
        </Slider.Track>
      </Slider.Root>

      <textlabel
        BackgroundTransparency={1}
        Size={UDim2.fromOffset(48, 20)}
        Text={string.format("%.2f", sensitivity)}
        TextColor3={Color3.fromRGB(236, 241, 249)}
        TextXAlignment={Enum.TextXAlignment.Left}
      />
    </frame>
  );
}
```

### Vertical orientation

Set `orientation="vertical"` for a column-style control such as a mixer channel. The default track becomes 10x220, the range fills from the bottom, and the thumb travels bottom-to-top; dragging maps the pointer's Y position to the value, with the top of the track as `max`.

```tsx title="AmbienceChannel.tsx"
import { useState } from "@rbxts/react";
import { Slider } from "@lattice-ui/react-slider";

export function AmbienceChannel() {
  const [level, setLevel] = useState(60);

  return (
    <frame BackgroundTransparency={1} Size={UDim2.fromOffset(64, 260)}>
      <uilistlayout
        HorizontalAlignment={Enum.HorizontalAlignment.Center}
        Padding={new UDim(0, 8)}
        SortOrder={Enum.SortOrder.LayoutOrder}
      />

      <Slider.Root orientation="vertical" value={level} onValueChange={setLevel}>
        <Slider.Track>
          <Slider.Range />
          <Slider.Thumb />
        </Slider.Track>
      </Slider.Root>

      <textlabel
        BackgroundTransparency={1}
        Size={UDim2.fromOffset(48, 20)}
        Text={`${level}`}
        TextColor3={Color3.fromRGB(236, 241, 249)}
      />
    </frame>
  );
}
```

### Custom visuals with asChild

Every part accepts `asChild` to project its behavior onto your own element. The track slot carries the drag-start handler, ref, and selection props; the thumb slot additionally centers your element with a forced `AnchorPoint` of (0.5, 0.5). `Slider.Range` is different: your child is stretched to fill the animated fill frame (its `Position` and `Size` are overridden), so style it with color, gradients, and corners rather than sizing it yourself.

```tsx title="StyledSlider.tsx"
import { useState } from "@rbxts/react";
import { Slider } from "@lattice-ui/react-slider";

export function StyledSlider() {
  const [value, setValue] = useState(50);

  return (
    <Slider.Root value={value} onValueChange={setValue}>
      <Slider.Track asChild>
        <frame
          BackgroundColor3={Color3.fromRGB(30, 33, 42)}
          BorderSizePixel={0}
          Size={UDim2.fromOffset(280, 8)}
        >
          <uicorner CornerRadius={new UDim(1, 0)} />

          <Slider.Range asChild>
            <frame BackgroundColor3={Color3.fromRGB(255, 255, 255)} BorderSizePixel={0}>
              <uicorner CornerRadius={new UDim(1, 0)} />
              <uigradient
                Color={
                  new ColorSequence(Color3.fromRGB(88, 142, 255), Color3.fromRGB(140, 190, 255))
                }
              />
            </frame>
          </Slider.Range>

          <Slider.Thumb asChild>
            <textbutton
              AutoButtonColor={false}
              BackgroundColor3={Color3.fromRGB(240, 244, 252)}
              BorderSizePixel={0}
              Size={UDim2.fromOffset(20, 20)}
              Text=""
            >
              <uicorner CornerRadius={new UDim(1, 0)} />
              <uistroke Color={Color3.fromRGB(88, 142, 255)} Thickness={2} />
            </textbutton>
          </Slider.Thumb>
        </frame>
      </Slider.Track>
    </Slider.Root>
  );
}
```

### Keyboard adjustment

When the thumb has input focus, keyboard input adjusts the value without a drag: `Right`/`Up` add `step`, `Left`/`Down` subtract it, `PageUp`/`PageDown` move by `step * 10`, and `Home`/`End` jump to `min`/`max`. Each of those keys both changes **and commits** the value, so a keypress-heavy control fires `onValueCommit` once per press — keep the commit handler cheap or debounce expensive work yourself. `Return`/`Space` commits the current value without changing it. With `step={1}`, this FOV slider nudges by 1 per arrow press and 10 per page press.

```tsx title="FieldOfViewSlider.tsx"
import { useState } from "@rbxts/react";
import { Slider } from "@lattice-ui/react-slider";

export function FieldOfViewSlider(props: { onCommit: (fov: number) => void }) {
  const [fov, setFov] = useState(80);

  return (
    <Slider.Root
      value={fov}
      onValueChange={setFov}
      onValueCommit={props.onCommit}
      min={60}
      max={110}
      step={1}
    >
      <Slider.Track>
        <Slider.Range />
        <Slider.Thumb />
      </Slider.Track>
    </Slider.Root>
  );
}
```

## How it behaves

### Value, clamping, and stepping

The value is a single `number`. `Slider.Root` is controllable via `value`/`onValueChange`, or uncontrolled via `defaultValue` (which itself defaults to `min`). Every value — incoming, dragged, or keyed — is clamped to `[min, max]` and snapped to the nearest multiple of `step` counted from `min`. `min`/`max` default to `0`/`100` and are normalized so the lower bound is always the smaller of the two; `step` defaults to `1`, and a zero or negative `step` falls back to `1`.

### Change vs. commit

`onValueChange` fires as the value moves — every drag tick and every value-changing keypress. `onValueCommit` fires once at the end of an interaction: when a drag is released, after each keyboard adjustment, or on a keyboard `Return`/`Space`. Treat `onValueChange` as the "live preview" channel (update a label, adjust volume locally) and `onValueCommit` as the "persist" channel (save the setting, fire a remote).

### Rendering and layout

`Slider.Root` renders no instance — it is purely a context provider — so `Slider.Track` is the outermost GuiObject and sits directly in the parent layout. The default track is a `frame` sized 260x10 (horizontal) or 10x220 (vertical); the default range is an animated fill `frame`; the default thumb is a 16x16 `textbutton` anchored at its center. All three can be replaced with `asChild`.

### Dragging

A pointer press (`MouseButton1` or `Touch`) on either `Slider.Track` or `Slider.Thumb` starts a drag and immediately jumps the value to the pressed position. While dragging, the root listens to `UserInputService` input changes and updates the value as the pointer moves — even after it leaves the track — then commits on release. Touch drags are tracked per input object so multi-touch doesn't cross wires. Drag listeners are cleaned up when the slider unmounts.

### Keyboard adjustment

`Slider.Thumb` handles keyboard input when focused: arrow `Right`/`Up` increase and `Left`/`Down` decrease by `step`; `PageUp`/`PageDown` move by `step * 10`; `Home`/`End` jump to `min`/`max`. Each of these both changes and commits the value. `Return`/`Space` commits the current value without changing it. These are keyboard key codes — gamepad buttons are not mapped to value changes in this release.

### Orientation

Set `orientation` to `"horizontal"` (default) or `"vertical"`. It governs which pointer axis maps to the value, where the default track sizes itself, and how `Slider.Range` and `Slider.Thumb` position themselves — horizontally the range grows from the left and the thumb tracks left-to-right; vertically the range grows from the bottom and the thumb tracks bottom-to-top.

### Composition with asChild

`Slider.Track` and `Slider.Thumb` merge their behavior onto your single child through the shared `Slot`: the slot's `Active`, `Selectable`, ref, and `InputBegan` drag/keyboard handling win over the child's own props, and the thumb also forces `AnchorPoint` to (0.5, 0.5) so it stays centered on its position. `Slider.Range` with `asChild` keeps the animated fill frame and stretches your child to fill it — the child's `Position` and `Size` are overridden — so express its look through color, gradients, corners, and strokes.

### Motion

`Slider.Range` and `Slider.Thumb` animate toward their target position/size with a response recipe, using a slightly snappier settle while a drag is in progress so the handle stays under the pointer.

> **Roblox gotchas**
>
> Drag uses Roblox pointer input on the track and thumb, so those nodes are made `Active` and `Selectable` only while the slider is enabled — when `disabled`, input is ignored and selection is removed. Keyboard adjustment fires through the thumb's `InputBegan`, so the thumb must be able to receive selection (e.g. via gamepad) for arrow/page keys to reach it.

> **Keyboard input commits on every press**
>
> Arrow, page, and `Home`/`End` presses call `onValueCommit` as well as `onValueChange` — one commit per keypress, unlike a drag's single commit on release. If commit triggers network traffic, debounce it in your handler rather than assuming one commit per interaction.

> **Root is not a container**
>
> `Slider.Root` renders no GuiObject, so you cannot size or position "the slider" through it. Size the track (or your `asChild` track element) instead, and wrap the slider in your own frame when it needs padding or a background.

## API reference

### Slider.Root

| Prop | Type | Description |
| --- | --- | --- |
| `value` | `number` | Controlled value. Pair with onValueChange. |
| `defaultValue` | `number` | Initial value for uncontrolled usage. Defaults to min. |
| `onValueChange` | `(value: number) => void` | Called continuously as the value changes during drag or keyboard input. |
| `onValueCommit` | `(value: number) => void` | Called once when an interaction ends (drag release or keyboard commit). |
| `min` | `number` | Lower bound of the range. Defaults to 0. |
| `max` | `number` | Upper bound of the range. Defaults to 100. |
| `step` | `number` | Increment the value snaps to. Defaults to 1; non-positive values fall back to 1. |
| `orientation` | `"horizontal" \| "vertical"` | Axis the slider runs along. Defaults to "horizontal". |
| `disabled` | `boolean` | Disables drag and keyboard input and removes the track/thumb from selection. Defaults to false. |
| `children` | `React.ReactNode` | The slider parts. Root renders no instance of its own. |

### Slider.Track

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge track behavior (drag start, ref, selection) onto the single child element instead of rendering the default frame. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set; otherwise rendered inside the default track (typically the Range and Thumb). |

### Slider.Range

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge the animated fill onto the single child element; the child is stretched to fill the animated range frame, overriding its Position and Size. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set. |

### Slider.Thumb

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge thumb behavior (drag start, keyboard handling, ref, selection, centered AnchorPoint) onto the single child element instead of the textbutton the part renders. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set. |

## Related

- [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
