Skip to content

Slider

Use a slider when the user is choosing a value on a continuum and the approximate value is what they care about — a volume level, a price ceiling, an export quality. If the exact number matters, or if they are likely to know it before they reach for the control, use an Input with type="number" instead: a slider makes a precise value the hardest thing to hit.

slider/basic.tsx

Import

import { Slider } from "@forte-ui/react";

Anatomy

<Slider.Root>
  <Slider.Label />
  <Slider.Value />
  <Slider.Control>
    <Slider.Track>
      <Slider.Indicator />
      <Slider.Thumb />
    </Slider.Track>
  </Slider.Control>
</Slider.Root>

Root lays the parts out as a two-column grid: Label and Value share the first row, and Control spans both columns underneath. Rendering only a Control gives you a one-row grid, so the parts above it are genuinely optional.

Track is the whole range and Indicator is the filled part of it. Thumb renders a <div> around a visually hidden <input type="range"> — that input is the real control, so it is what takes focus, what carries name on submit, and what a screen reader reads.

Examples

Range slider

An array value is what makes a slider a range — there is no range prop. Place one Thumb per value, and give each an index: without it the thumbs only learn their order once the composite list registers on the client, so a server-rendered range slider paints both handles at the first value and jumps on hydration.

format is an Intl.NumberFormatOptions object applied by Slider.Value and by each thumb's aria-valuetext, so the value is announced the same way it is displayed.

slider/range.tsx
Price range
$25 – $75

Thumb alignment

At min and max the thumb is wider than the rail, so something has to give. thumbAlignment decides what:

  • center (the default) puts the thumb's centre on the control's edge, so half of it hangs outside.
  • edge insets the thumb so its outer edge lands on the control's edge instead. Base UI measures the control's border and padding to work this out, which means it re-measures if you change --forte-slider-control-padding.
  • edge-client-only behaves like edge but drops the pre-hydration script, trading a first paint with no thumb for a smaller bundle.

The red guides in the demo mark the control's edges.

slider/thumb-alignment.tsx
center — the default
edge

Thumb collision

On a range slider, thumbCollisionBehavior decides what happens when a dragged thumb reaches its neighbour. minStepsBetweenValues is the companion knob — it holds a gap open whichever behaviour is in use.

slider/collision.tsx
push — the default
35 – 55

The dragged thumb shoves the other along and does not give the ground back.

swap
35 – 55

Drag one thumb past the other and they trade places.

none
35 – 55

Neither thumb can pass the other; the extra movement is ignored.

Sizes

size scales the rail and the thumb together, and the control's padding is derived from both — the larger of "enough for a 24×24 target" and "enough room for the thumb plus a little air". So every size keeps a full-size pointer target even though the painted rail is only 3px tall at sm.

slider/sizes.tsx
Small
45
Medium
45
Large
45

Tones

tone swaps the indicator fill, the thumb's ring and its halo in one move. The rail stays neutral in every tone: it is the empty part of the range, and colouring it would compete with the fill it exists to contrast against.

slider/tones.tsx
Primary
40
Secondary
55
Danger
85
Neutral
25

Vertical

orientation="vertical" turns the same parts 90°. The root becomes a single centred column and --forte-slider-length now describes the block axis, defaulting to 10rem.

slider/vertical.tsx
Bass
35
Mids
70
Treble
20 – 80

Form integration

A single-thumb slider submits like any other control: give name to the Field.Root and the hidden <input type="range"> carries it.

A range slider is two inputs sharing one name, so it needs a group label rather than a single one. Render Fieldset.Root as the slider root — the legend then names the group while each thumb keeps its own aria-label, and the submitted value is an array.

slider/form.tsx
Export quality
80
Monthly budget
$200 – $800

Disabled

disabled blocks interaction and drops the whole control to 55% opacity. Set it on the Field.Root rather than on the Slider: it takes precedence over the control's own prop and dims the description with it, so the reason the setting is unavailable does not stay at full contrast beside a greyed-out control. That reason has to be visible text — a disabled slider is skipped by Tab and cannot show a tooltip to a keyboard user.

slider/disabled.tsx
Concurrent builds
60

Fixed at 60 on the Hobby plan. Upgrade to change it.

Accessibility

Keyboard interactions
KeyBehaviour
TabMoves focus to the next thumb. Each thumb is its own tab stop, so a range slider takes two.
ArrowRight then ArrowUpIncreases the value by one step.
ArrowLeft then ArrowDownDecreases the value by one step.
Shift then ArrowUpIncreases the value by largeStep (10 by default).
Shift then ArrowDownDecreases the value by largeStep.
PageUpIncreases the value by largeStep.
PageDownDecreases the value by largeStep.
HomeSets the value to min.
EndSets the value to max.

Render a Slider.Value wherever there is room for it. It is a visible duplicate of what aria-valuetext already announces, so nothing depends on it — but it is what makes the control usable for anyone who cannot judge a thumb's position by eye.

The thumb's focus ring is .forte-focus-ring-within, not .forte-focus-ring: focus lands on the hidden <input>, so the thumb <div> never matches :focus-visible itself.

The thumb's elevation and halo are painted by a ::before rather than by the thumb's own box-shadow, and that is not a stylistic choice. The focus ring's second tone is a box-shadow, declared in @layer forte.patterns — which is ordered before forte.components, and layer order outranks specificity completely. A box-shadow on the thumb would therefore win unconditionally and delete half of a ring whose entire promise is that its two tones contrast with each other rather than with the page. If you restyle the thumb, put shadows on the pseudo-element, not on the thumb.

The control is padded well past the painted rail, and that padding is what carries the 24×24 minimum target of SC 2.5.8 — a press anywhere inside the strip moves the nearest thumb, so the target is the whole strip rather than the handle. The padding is derived from the rail thickness and the thumb size, so it cannot fall below the minimum when either is retuned.

In forced-colours mode the rail, the fill and the handle are separated with system keywords, because Base UI builds every part out of <div>s and the UA assigns system colours from native element semantics — a <div role="slider"> gets nothing. The rail's boundary there is drawn with outline rather than border, since a border would change the box and throw the indicator's height: inherit off by its own width in that one mode. The disabled state overrides its own opacity and uses GrayText, because forced colours does not touch opacity and a 55% control would otherwise keep full system contrast and read as enabled.

Contrast

The thumb's ring is --forte-color-primary and the rail is --forte-color-border-muted. The value is legible from three independent places — the indicator's length against the rail, the thumb's position, and the Slider.Value text — so no single pair is load-bearing on its own. If your accent seed lands pale against your page, re-point --forte-slider-thumb-border at --forte-color-foreground-subtle, which is measured at 3.73:1.

The hover halo is a translucent mix of the tone's colour rather than its -soft step, and that matters. --forte-color-primary-soft is accent-3, which is built to sit on the page — 12% of the seed mixed into white in light mode, 22% into near-black in dark — so against the page it is very nearly the page itself, and the halo was effectively invisible in both modes (measured 1.12:1 in light, 1.25:1 in dark). Mixing toward transparent composites the accent over whatever is actually behind the control, which brings every tone to between 1.6:1 and 2.5:1 against the page in both modes and keeps it working over a panel or a card as well.

Motion

The handle and the fill are never animated into position, and that is deliberate.

Base UI rewrites the indicator's width and the thumb's inset-inline-start on every value change — which during a drag means every pointermove. A positional transition therefore does not run once, it restarts every frame, leaving the handle permanently interpolating toward a target that has already moved. It trails the pointer, and it keeps a layout-triggering property under animation for the entire gesture. An earlier version of this component had a --forte-duration-move glide with the transition suppressed during a drag; both suppression triggers turned out to be unreliable — :active is cleared the moment the pointer leaves the control's box, and [data-dragging] is React state that only arrives after Base UI's intentional-drag threshold — so in practice the suppression never fired at all.

A keyboard-only glide is tempting and also wrong: holding an arrow key repeats at roughly 30/s and restarts the same transition each time, which reproduces the lag on a smaller scale, while a single arrow step moves the handle two or three pixels and has nothing worth animating. Native input[type="range"] does not animate either.

What does animate is colour, and the halo around the handle on hover and while dragging — state changes with a start and an end, which is what a transition is for. There is consequently no prefers-reduced-motion block in this component: no geometry moves, so there is nothing for the motion tokens to collapse, and the halo is a colour cue that reduced-motion users keep in full.

Theming

Every property below is declared on Slider.Root and consumed by the parts underneath through inheritance. Setting one on an ancestor of the root has no effect — the root's own declaration wins over an inherited value. Override them on the root itself, through className (an unlayered rule beats the library's @layer forte.components, whatever its specificity) or an inline style.

Theming tokens for Slider
PropertyControlsDefault
--forte-slider-lengthControl length: inline-size when horizontal, block-size when vertical. Capped at 100% of the container; 10rem when vertical.16rem
--forte-slider-track-thicknessRail thickness, and one of the two inputs the control padding derives from. size retunes it: 3px for sm, 6px for lg.4px
--forte-slider-thumb-sizeThumb width and height. size retunes it: 0.875rem for sm, 1.25rem for lg.1rem
--forte-slider-thumb-border-widthThickness of the thumb's ring.2px
--forte-slider-radiusRail and indicator corner radius.var(--forte-radius-pill)
--forte-slider-gapGap between the label/value row and the control.var(--forte-space-2)
--forte-slider-track-bgRail fill — the empty part of the range.var(--forte-color-border-muted)
--forte-slider-indicator-bgFill of the selected part. Swapped per tone.var(--forte-color-primary)
--forte-slider-thumb-bgThumb fill.var(--forte-color-background)
--forte-slider-thumb-borderThumb ring colour. Swapped per tone, and var(--forte-color-danger-border) when invalid inside a field.var(--forte-color-primary)
--forte-slider-thumb-shadowThumb shadow.var(--forte-shadow-1)
--forte-slider-thumb-halo-colorBase colour the halo is mixed from. Swapped per tone.var(--forte-color-primary)
--forte-slider-thumb-halo-strengthHow much of that colour survives the mix toward transparent. 30% for tone="neutral".48%
--forte-slider-thumb-haloThe resolved halo colour. Derived from the two above; set it directly to bypass them.color-mix( in oklab, var(--forte-slider-thumb-halo-color) var(--forte-slider-thumb-halo-strength), transparent )
--forte-slider-thumb-halo-sizeCurrent halo radius. Set by hover and drag; set it yourself for a halo that is always on.0px
--forte-slider-thumb-halo-size-activeHalo radius in those states. It stands down while the focus ring is up, so the ring's outer tone is not covered. size retunes it: 4px for sm, 6px for lg.5px
--forte-slider-control-paddingHow far the control is padded past the rail — the SC 2.5.8 target.max( calc((var(--forte-target-min) - var(--forte-slider-track-thickness)) / 2), calc( (var(--forte-slider-thumb-size) - var(--forte-slider-track-thickness)) / 2 + var(--forte-space-1) ) )

--forte-slider-control-padding is derived from the rail thickness and the thumb size, so resizing either keeps the pointer target correct — you rarely want to set it by hand. Note that thumbAlignment="edge" measures it, so changing it also moves where an edge-aligned thumb rests.

API reference

The tables list what each part adds on top of Base UI. Everything else passes straight through.

Slider.Root

Also takes Base UI's own root props unchanged: value, defaultValue, onValueChange, onValueCommitted, min, max, step, largeStep, minStepsBetweenValues, orientation, thumbAlignment, thumbCollisionBehavior, format, locale, name, form, disabled, render and style. The Value generic is preserved, so a number value keeps onValueChange(value: number) and an array keeps readonly number[].

Props for SliderRoot
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
refRef<HTMLDivElement>A ref to the root `<div>`.
sizeSliderSizemdSize of the slider. Scales the track thickness, the thumb and the gap between the rows together.
toneSliderToneprimaryWhich semantic colour set the indicator and thumb draw from.

Slider.Label

Props for SliderLabel
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Slider.Value

Its children may be a function receiving (formattedValues, values).

Props for SliderValue
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Slider.Control

Props for SliderControl
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Slider.Track

Props for SliderTrack
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Slider.Indicator

Props for SliderIndicator
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Slider.Thumb

Also takes index, aria-label, aria-valuetext, getAriaLabel, getAriaValueText, inputRef, tabIndex, onFocus, onBlur, onKeyDown (all three forwarded to the nested input) and its own disabled.

Props for SliderThumb
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.