A slider (WAI-ARIA Slider and Slider (Multi-Thumb) patterns) and the library's first continuous control. Each thumb is a styleable element containing a visually hidden native <input type="range">, so the role, the value and bounds, every key (RTL mirroring included), labels, forms and disabled are the platform's. The library adds the styleable thumb and track, pointer dragging, several thumbs that cannot cross, and the Async React half: a drag produces many steps and one commit.
"use client";
import { useState } from "react";
import { Slider } from "@funstack/ui/slider";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";
// Uncontrolled, saved on release. The thumb follows the pointer (urgent); the save runs once per
// gesture in valueCommitAction (800 ms, data-pending on the Root and the thumb). With failures on,
// the save rejects: the error reaches the boundary and the value rolls back to where the gesture
// started. The save goes in valueCommitAction, never in valueChangeAction (every step).
export function SliderVolume() {
const [fail, setFail] = useState(false);
return (
<div className="demo-stack">
<label className="demo-field">
<input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} />
Make the save fail
</label>
<DemoErrorBoundary>
<Slider.Root
className="demo-slider"
defaultValue={40}
valueCommitAction={(next) => fakeSave(`volume ${next}`, { fail })}
getAriaValueText={(v) => `${v} percent`}
>
<div className="demo-slider-head">
<Slider.Label className="demo-slider-label">Volume</Slider.Label>
<Slider.Value>
{({ value, pending }) => `${value}%${pending ? " · saving…" : ""}`}
</Slider.Value>
</div>
<Slider.Track className="demo-slider-track">
<Slider.Indicator className="demo-slider-fill" />
<Slider.Thumb className="demo-slider-thumb" />
</Slider.Track>
</Slider.Root>
</DemoErrorBoundary>
</div>
);
}/* Slider: the library publishes geometry as custom properties (--slider-value on each Thumb,
--slider-start / --slider-end on the Root) and never positions anything; this CSS does.
Registering the properties lets keyboard steps and rollbacks animate; a drag does not. */
@property --slider-value {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@property --slider-start {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --slider-end {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
.demo-slider {
display: grid;
gap: 0.4rem;
inline-size: min(22rem, 100%);
}
.demo-slider-head {
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--color-text);
}
.demo-slider-label {
font-weight: 600;
}
.demo-slider-track {
position: relative;
block-size: 6px;
margin-block: 0.75rem;
border-radius: 3px;
background: var(--color-border);
cursor: pointer;
}
.demo-slider-fill {
position: absolute;
inset-block: 0;
inset-inline-start: var(--slider-start);
inset-inline-end: calc(100% - var(--slider-end));
border-radius: inherit;
background: var(--color-primary);
transition:
--slider-start 120ms ease-out,
--slider-end 120ms ease-out;
}
.demo-slider-thumb {
position: absolute;
inline-size: 20px;
block-size: 20px;
inset-block-start: 50%;
inset-inline-start: var(--slider-value);
translate: -50% -50%;
border-radius: 50%;
background: var(--color-bg);
box-shadow:
0 0 0 2px var(--color-primary),
0 1px 3px rgb(0 0 0 / 0.25);
transition:
--slider-value 120ms ease-out,
scale 120ms;
}
[dir="rtl"] .demo-slider-thumb {
translate: 50% -50%;
}
.demo-slider-thumb:has(:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
.demo-slider-thumb[data-dragging] {
scale: 1.15;
}
.demo-slider-thumb[data-pending]:not([data-dragging]) {
box-shadow:
0 0 0 4px var(--color-primary),
0 1px 3px rgb(0 0 0 / 0.25);
animation: demo-slider-pulse 0.8s infinite alternate;
}
/* Inside a Field: the thumbs carry the Field's data-invalid. */
.demo-slider-thumb[data-invalid] {
box-shadow:
0 0 0 2px #dc2626,
0 1px 3px rgb(0 0 0 / 0.25);
}
@keyframes demo-slider-pulse {
to {
opacity: 0.6;
}
}
/* Follow the pointer exactly while dragging. */
[data-dragging] .demo-slider-thumb,
[data-dragging] .demo-slider-fill {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.demo-slider-thumb,
.demo-slider-fill {
transition: none;
animation: none;
}
}
.demo-slider-results {
margin: 0;
padding-inline-start: 1.25rem;
color: var(--color-text);
}
.demo-slider-results[data-stale] {
opacity: 0.6;
}| valueChangeAction | valueCommitAction | |
|---|---|---|
| Called | On every step: the press, each pointer move and the release position, each key press, a form reset, a value found at hydration, and the revert after a failed save (reason: "dismiss", uncontrolled). | Once per gesture: pointer release, each key press, a form reset, a value found at hydration. |
| Lane | A transition of its own: a controlled parent's setValue(next) in the synchronous prefix renders interruptibly. | The Root's commit transition, with useOptimistic. |
| Pending | data-pending while a step is in flight (including the Suspense wait of what it renders). Without a valueChangeAction a step starts no transition and is never pending. | data-pending on the Root and the moved thumb until the save settles. |
| Uncontrolled state | Not written. | Written when it resolves; reverts to the value before the gesture if it rejects. |
| Meant for | Adopting the value in a controlled parent, a live preview, cheap synchronous work. | Saving: a Server Function, a fetch, requestSubmit(). |
The thumb under the pointer is urgent: it moves in the same event, while a controlled parent's update and the save run in transitions. What the thumbs show is the value the gesture reached while a pointer is captured or anything is in flight, and the optimistic value otherwise, so the thumb never snaps back between two moves, and slides back only when a save fails or a controlled parent does not adopt the value.
"use client";
import { Suspense, use, useState } from "react";
import { Slider } from "@funstack/ui/slider";
import { sleep } from "./fake-api.js";
const products = Array.from({ length: 40 }, (_, i) => ({
name: `Product ${i + 1}`,
price: ((i * 37) % 50) * 10,
}));
type Product = (typeof products)[number];
const cache = new Map<string, Promise<Product[]>>();
/** A pretend search that takes 300 ms per price range. */
function search(lo: number, hi: number): Promise<Product[]> {
const key = `${lo}-${hi}`;
let promise = cache.get(key);
if (promise === undefined) {
promise = sleep(300).then(() => products.filter((p) => p.price >= lo && p.price <= hi));
cache.set(key, promise);
}
return promise;
}
function Results({ range: [lo = 0, hi = 0] }: { range: readonly number[] }) {
const found = use(search(lo, hi));
return (
<>
{found.length} products, from {found[0]?.name ?? "none"}
</>
);
}
// Controlled, two thumbs, a live filter. valueChangeAction adopts every step with setPrice in
// its synchronous prefix: a transition update. The results suspend for each range while the old
// ones stay on screen (dimmed through `pending`), and the thumbs never wait for them. Thumbs
// cannot cross: each input's native min / max are its neighbour's value plus one step.
export function SliderPrice() {
const [price, setPrice] = useState<readonly number[]>([100, 300]);
return (
<Slider.Root
className="demo-slider"
value={price}
min={0}
max={500}
step={10}
minStepsBetweenThumbs={1}
valueChangeAction={(next) => {
setPrice(next);
}}
getAriaValueText={(v) => `${v} dollars`}
>
{({ pending }) => (
<>
<div className="demo-slider-head">
<Slider.Label className="demo-slider-label">Price</Slider.Label>
<Slider.Value>{({ values: [lo, hi] }) => `$${lo} – $${hi}`}</Slider.Value>
</div>
<Slider.Track className="demo-slider-track">
<Slider.Indicator className="demo-slider-fill" />
<Slider.Thumb index={0} aria-label="Minimum price" className="demo-slider-thumb" />
<Slider.Thumb index={1} aria-label="Maximum price" className="demo-slider-thumb" />
</Slider.Track>
<p className="demo-slider-results" data-stale={pending ? "" : undefined}>
<Suspense fallback="Searching…">
<Results range={price} />
</Suspense>
</p>
</>
)}
</Slider.Root>
);
}/* Slider: the library publishes geometry as custom properties (--slider-value on each Thumb,
--slider-start / --slider-end on the Root) and never positions anything; this CSS does.
Registering the properties lets keyboard steps and rollbacks animate; a drag does not. */
@property --slider-value {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@property --slider-start {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --slider-end {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
.demo-slider {
display: grid;
gap: 0.4rem;
inline-size: min(22rem, 100%);
}
.demo-slider-head {
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--color-text);
}
.demo-slider-label {
font-weight: 600;
}
.demo-slider-track {
position: relative;
block-size: 6px;
margin-block: 0.75rem;
border-radius: 3px;
background: var(--color-border);
cursor: pointer;
}
.demo-slider-fill {
position: absolute;
inset-block: 0;
inset-inline-start: var(--slider-start);
inset-inline-end: calc(100% - var(--slider-end));
border-radius: inherit;
background: var(--color-primary);
transition:
--slider-start 120ms ease-out,
--slider-end 120ms ease-out;
}
.demo-slider-thumb {
position: absolute;
inline-size: 20px;
block-size: 20px;
inset-block-start: 50%;
inset-inline-start: var(--slider-value);
translate: -50% -50%;
border-radius: 50%;
background: var(--color-bg);
box-shadow:
0 0 0 2px var(--color-primary),
0 1px 3px rgb(0 0 0 / 0.25);
transition:
--slider-value 120ms ease-out,
scale 120ms;
}
[dir="rtl"] .demo-slider-thumb {
translate: 50% -50%;
}
.demo-slider-thumb:has(:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
.demo-slider-thumb[data-dragging] {
scale: 1.15;
}
.demo-slider-thumb[data-pending]:not([data-dragging]) {
box-shadow:
0 0 0 4px var(--color-primary),
0 1px 3px rgb(0 0 0 / 0.25);
animation: demo-slider-pulse 0.8s infinite alternate;
}
/* Inside a Field: the thumbs carry the Field's data-invalid. */
.demo-slider-thumb[data-invalid] {
box-shadow:
0 0 0 2px #dc2626,
0 1px 3px rgb(0 0 0 / 0.25);
}
@keyframes demo-slider-pulse {
to {
opacity: 0.6;
}
}
/* Follow the pointer exactly while dragging. */
[data-dragging] .demo-slider-thumb,
[data-dragging] .demo-slider-fill {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.demo-slider-thumb,
.demo-slider-fill {
transition: none;
animation: none;
}
}
.demo-slider-results {
margin: 0;
padding-inline-start: 1.25rem;
color: var(--color-text);
}
.demo-slider-results[data-stale] {
opacity: 0.6;
}import { Slider } from "@funstack/ui/slider";
<Slider.Root name="volume" defaultValue={40} valueCommitAction={saveVolume}> {/* div */}
<Slider.Label>Volume</Slider.Label> {/* label: for= the input (one thumb), id (several) */}
<Slider.Value /> {/* span: the shown value */}
<Slider.Track> {/* div: owns the pointer */}
<Slider.Indicator /> {/* span: the fill */}
<Slider.Thumb /> {/* span + visually hidden input type=range */}
</Slider.Track>
</Slider.Root>index (default 0). value / defaultValue is a number for one thumb or an array (ascending) for several; the shape is fixed at mount.span (ref, className, render) and, inside it, the native input (inputRef), which is the focusable element with role slider. aria-label, aria-labelledby, aria-describedby, name, form and autoFocus are forwarded to the input.role="group" named by the Label; each thumb needs its own name (aria-label="Minimum price"). A thumb's native min / max are its neighbours' values (plus minStepsBetweenThumbs × step), so thumbs cannot cross with the pointer, the keys or Home / End.--slider-value on each Thumb and --slider-start, --slider-end, --slider-value-<i> on the Root (inherited); your CSS places the fill and the thumbs (see slider.css above).step (Base UI and Radix default to min).Slider.Root| Prop | Type | Default | Description |
|---|---|---|---|
value | number | readonly number[] | – | Controlled value: one number, or one per thumb in ascending order. undefined means uncontrolled. |
defaultValue | number | readonly number[] | midpoint | Initial value in uncontrolled mode; also the form-reset target (the latest value). Default min + (max - min) / 2, snapped to step. |
valueChangeAction | ChangeAction<T, SliderValueChangeDetails> | – | Runs in the Root's step transition on every step. Awaited; data-pending meanwhile. Adopt the value here in a controlled parent. Not the place for a save. |
valueCommitAction | ChangeAction<T, SliderValueCommitDetails> | – | Runs in the Root's commit transition once per gesture. Awaited; the thumb keeps the gesture's value with data-pending until it settles. Uncontrolled, the value is committed when it resolves and reverts when it rejects. May be a Server Function. |
min | number | 0 | Lower bound. |
max | number | 100 | Upper bound. A max off the step grid is unreachable, as on a native range (development warning). |
step | number | 1 | Positive. Values snap to min + n × step. |
minStepsBetweenThumbs | number | 0 | Minimum number of steps between two adjacent thumbs. |
orientation | "horizontal" | "vertical" | "horizontal" | Vertical sliders have min at the bottom (the hidden inputs get writing-mode: vertical-lr; direction: rtl). |
getAriaValueText | (value: number, index: number) => string | – | aria-valuetext for each thumb, from the value it shows. For values whose number alone is not meaningful. |
name | string | – | Form field name for every thumb's input (a Thumb's own name wins). Several thumbs post several entries: read them with FormData.getAll(). |
form | string | – | The form attribute for every thumb's input. |
disabled | boolean | false | Disables every input (not submitted) and the pointer. |
className | string | ((state: SliderRootState<T>) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: SliderRootState<T>) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: SliderRootState<T>) => ReactNode) | – | The parts. The function form receives the shown value; must not suspend. |
render | ReactElement | ((props, state: SliderRootState<T>) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
T is number or readonly number[], inferred from value / defaultValue. SliderRootState<T>: { value, values, pending, dragging, orientation, disabled, min, max, step }, where value is the shown value and values the same as an array. Details: { reason, thumb, event }, reason being "activate", "platform" (a form reset, a value found at hydration) or "dismiss" (the revert after a failed save, steps only), and thumb the index that moved (null on platform paths and the revert). The Root owns role and, while a Label is mounted and there are several thumbs, aria-labelledby (merged with yours). There is no transition prop on any part.
Slider.Thumb| Prop | Type | Default | Description |
|---|---|---|---|
index | number | 0 | Which value this thumb edits. |
name / form | string | – | This thumb input's form field name and form attribute; override the Root's. |
aria-label / aria-labelledby / aria-describedby | string | – | Forwarded to the input. Every thumb of a multi-thumb slider needs a name (development warning). |
inputRef | Ref<HTMLInputElement> | – | The native <input type="range"> inside the thumb. |
autoFocus | boolean | – | Forwarded to the input. |
className | string | ((state: SliderThumbState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: SliderThumbState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: SliderThumbState) => ReactNode) | – | Rendered inside the styled element, next to the input (an icon, a value bubble). |
render | ReactElement | ((props, state: SliderThumbState) => ReactNode) | – | Replace the default <span> (element form) or take over rendering (function form). |
SliderThumbState: { index, value, pending, dragging, orientation, disabled }. The input's attributes (min, max, step, value, aria-valuetext, …) are all the library's; a render element never touches the input.
Slider.Track, Slider.Indicator| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: SliderTrackState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: SliderTrackState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: SliderTrackState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: SliderTrackState) => ReactNode) | – | Replace the default <div / span> (element form) or take over rendering (function form). |
SliderTrackState (and SliderIndicatorState): { orientation, dragging, disabled }. A press on the Track moves the nearest thumb, focuses its input and starts dragging it; its box is the reference for the pointer maths. When two thumbs share a value (stacked), the one that can move toward the pointer moves, whichever of them was pressed; a press exactly on the value lets the first move decide. The commit uses the release position. The Indicator renders no position: place it from --slider-start / --slider-end.
Slider.Label, Slider.Value| Prop | Type | Default | Description |
|---|---|---|---|
index (Value) | number | – | Show one thumb's value. Without it a single-thumb slider shows its value; several thumbs need children. |
className | string | ((state: SliderRootState / SliderValueState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: SliderRootState / SliderValueState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: SliderRootState / SliderValueState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: SliderRootState / SliderValueState) => ReactNode) | – | Replace the default <label / span> (element form) or take over rendering (function form). |
The Label must stay a <label> with one thumb (it names the input through for). The Value is a plain span, not <output>: a live region that updates on every step would chatter, and the slider already exposes its value. SliderValueState is the Root's state plus index. Their state is not tied to the Root's value type: value is typed number | readonly number[]; narrow it with <Slider.Value<number>> or read values.
| Attribute | On | Meaning |
|---|---|---|
data-pending | Root | A step (with a valueChangeAction) or commit is in flight. Even a synchronous step action makes each pointer move a short pending render; for a spinner use [data-pending]:not([data-dragging]). |
data-pending | Thumb | The Root is pending and this is the thumb that moved. |
data-dragging | Root, Thumb | A pointer is captured (on this thumb). |
data-orientation | every part | horizontal or vertical. |
data-disabled | every part | The Root's disabled. |
data-index | Thumb | 0, 1, … |
:focus-visible | the input | Style the thumb with .thumb:has(:focus-visible). |
.thumb { inset-inline-start: var(--slider-value); translate: -50% -50%; }
.thumb:has(:focus-visible) { outline: 2px solid var(--focus); }
/* Animate keyboard steps and rollbacks, never the pointer: */
@property --slider-value { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
.thumb { transition: --slider-value 120ms ease-out; }
[data-dragging] .thumb { transition: none; }Every key is the browser's, except ArrowLeft / ArrowRight on vertical and on RTL sliders, which the library normalises.
| Key | Horizontal | Vertical |
|---|---|---|
| ArrowRight / ArrowUp | + step (Right is − step under RTL: by the library) | + step (Right: by the library) |
| ArrowLeft / ArrowDown | − step (Left is + step under RTL: by the library) | − step (Left: by the library) |
| PageUp / PageDown | ± 10 % of the range | same |
| Home / End | the thumb's min / max (its neighbour with several thumbs) | same |
| Tab / Shift+Tab | between thumbs | same |
direction) the track is mirrored, so ArrowLeft increases; Chromium and Firefox mirror a native range, Safari does not, so the library handles these two keys there as well. Left-to-right horizontal keys stay native.aria-valuemin, aria-valuemax, aria-valuenow or aria-orientation: the native min, max, value and writing mode provide them (Chromium even lets the ARIA ones override the real values).touch-action).volume=73, or price=100&price=300 with one Root name. A range has no native constraint and nothing is required; a Field's validate can make it invalid (Inside a Field).value attribute equal to its current value: the library writes the reset target into each input's defaultValue during the reset event, then dispatches one step and one commit with reason: "platform". Uncontrolled, the target is the latest defaultValue; controlled, the mount-time value is offered to the parent."platform" step and commit); the pointer does nothing yet. A plain <input type="range"> from a Server Component is the JS-free slider.input / change events, so a <form onChange> listener sees keyboard changes only, and not ArrowLeft / ArrowRight on vertical or RTL sliders (handled from the keydown).// Submit on release:
<Slider.Root name="brightness" valueCommitAction={() => formRef.current?.requestSubmit()}>…</Slider.Root>
// A Server Function as the save, straight from a Server Component:
<Slider.Root defaultValue={settings.volume} valueCommitAction={saveVolume}>…</Slider.Root>"use client";
import { startTransition, useState } from "react";
import { Field } from "@funstack/ui/field";
import { Form } from "@funstack/ui/form";
import { Slider } from "@funstack/ui/slider";
import { fakeSave } from "./fake-api.js";
// A two-thumb Slider as a Field's control. Field.Label names the group, both thumbs post under
// the Field's name, and `validate` reads them with `getAll`. The rule is checked at each release
// (a drag fires no native `input` / `change`; the Slider tells the Field itself), and a blocked
// Save focuses the first thumb.
export function SliderField() {
const [saved, setSaved] = useState<string | null>(null);
return (
<Form.Root
className="demo-form"
autoReset={false}
action={async (formData) => {
await fakeSave("the price range");
startTransition(() => setSaved(formData.getAll("price").join(" – ")));
}}
>
<Field.Root
name="price"
className="demo-fieldset"
validate={(_value, formData) => {
const [lo = 0, hi = 0] = formData.getAll("price").map(Number);
return hi - lo > 200 ? "Narrow the range to $200 or less." : null;
}}
>
<Field.Label className="demo-label">Price</Field.Label>
<Slider.Root className="demo-slider" defaultValue={[100, 400]} max={500} step={10}>
<Slider.Value>{({ values: [lo, hi] }) => `$${lo} – $${hi}`}</Slider.Value>
<Slider.Track className="demo-slider-track">
<Slider.Indicator className="demo-slider-fill" />
<Slider.Thumb index={0} aria-label="Minimum price" className="demo-slider-thumb" />
<Slider.Thumb index={1} aria-label="Maximum price" className="demo-slider-thumb" />
</Slider.Track>
</Slider.Root>
<Field.Description className="demo-description">At most $200 apart.</Field.Description>
<Field.Error className="demo-field-error" />
</Field.Root>
<div className="demo-row">
<Form.Submit className="demo-button demo-primary demo-submit">
{({ pending }) => (
<>
{pending && <span className="demo-spinner" aria-hidden />}
Save
</>
)}
</Form.Submit>
</div>
{saved !== null && <output className="demo-readout">Saved ${saved}.</output>}
</Form.Root>
);
}/* Slider: the library publishes geometry as custom properties (--slider-value on each Thumb,
--slider-start / --slider-end on the Root) and never positions anything; this CSS does.
Registering the properties lets keyboard steps and rollbacks animate; a drag does not. */
@property --slider-value {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
@property --slider-start {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --slider-end {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
.demo-slider {
display: grid;
gap: 0.4rem;
inline-size: min(22rem, 100%);
}
.demo-slider-head {
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--color-text);
}
.demo-slider-label {
font-weight: 600;
}
.demo-slider-track {
position: relative;
block-size: 6px;
margin-block: 0.75rem;
border-radius: 3px;
background: var(--color-border);
cursor: pointer;
}
.demo-slider-fill {
position: absolute;
inset-block: 0;
inset-inline-start: var(--slider-start);
inset-inline-end: calc(100% - var(--slider-end));
border-radius: inherit;
background: var(--color-primary);
transition:
--slider-start 120ms ease-out,
--slider-end 120ms ease-out;
}
.demo-slider-thumb {
position: absolute;
inline-size: 20px;
block-size: 20px;
inset-block-start: 50%;
inset-inline-start: var(--slider-value);
translate: -50% -50%;
border-radius: 50%;
background: var(--color-bg);
box-shadow:
0 0 0 2px var(--color-primary),
0 1px 3px rgb(0 0 0 / 0.25);
transition:
--slider-value 120ms ease-out,
scale 120ms;
}
[dir="rtl"] .demo-slider-thumb {
translate: 50% -50%;
}
.demo-slider-thumb:has(:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
.demo-slider-thumb[data-dragging] {
scale: 1.15;
}
.demo-slider-thumb[data-pending]:not([data-dragging]) {
box-shadow:
0 0 0 4px var(--color-primary),
0 1px 3px rgb(0 0 0 / 0.25);
animation: demo-slider-pulse 0.8s infinite alternate;
}
/* Inside a Field: the thumbs carry the Field's data-invalid. */
.demo-slider-thumb[data-invalid] {
box-shadow:
0 0 0 2px #dc2626,
0 1px 3px rgb(0 0 0 / 0.25);
}
@keyframes demo-slider-pulse {
to {
opacity: 0.6;
}
}
/* Follow the pointer exactly while dragging. */
[data-dragging] .demo-slider-thumb,
[data-dragging] .demo-slider-fill {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.demo-slider-thumb,
.demo-slider-fill {
transition: none;
animation: none;
}
}
.demo-slider-results {
margin: 0;
padding-inline-start: 1.25rem;
color: var(--color-text);
}
.demo-slider-results[data-stale] {
opacity: 0.6;
}Field.Root is the Field's control with no new prop. One thumb: its input takes the Field's control id, so Field.Label's for names it, and the Field's description and error describe it. Several thumbs: the Root (role="group") is named by Field.Label through aria-labelledby and described by the Field; each thumb still needs its own aria-label.name and disabled default from the Field. validate's value is the first thumb's; read every thumb with formData.getAll(name). Field.Root required has no effect (a range is never empty) and logs a development warning.aria-invalid="true" on every thumb's input (never on the group) and data-invalid on the Root and every Thumb. The message lives in the first thumb's custom validity, so a blocked submission focuses the first thumb.validate re-runs and a shown server error is dismissed, as typing would. A rolled-back save or a form reset re-runs validate but does not dismiss a server error.data-pending, the form's submission is the Field's.