FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • Field & Form

Resources

  • Roadmap & changelog
  • GitHub ↗

Slider

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.

40%
Show code
SliderVolume.tsx
"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.css
/* 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;
}

Two actions: steps and commits

valueChangeActionvalueCommitAction
CalledOn 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.
LaneA transition of its own: a controlled parent's setValue(next) in the synchronous prefix renders interruptibly.The Root's commit transition, with useOptimistic.
Pendingdata-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 stateNot written.Written when it resolves; reverts to the value before the gesture if it rejects.
Meant forAdopting the value in a controlled parent, a live preview, cheap synchronous work.Saving: a Server Function, a fetch, requestSubmit().

The save goes in valueCommitAction

A drag is dozens of steps, and every pending async action entangles every transition in the app. Keep valueChangeAction cheap and put the network call in valueCommitAction, which runs once per gesture.

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.

A live filter

$100 – $300

15 products, from Product 3

Show code
SliderPrice.tsx
"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.css
/* 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;
}

Anatomy

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>
  • One Thumb per value, chosen by an explicit index (default 0). value / defaultValue is a number for one thumb or an array (ascending) for several; the shape is fixed at mount.
  • Thumb renders two elements: the styled 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.
  • Several thumbs make the Root a 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.
  • Nothing is positioned by the library. It publishes --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).
  • The default value is the platform's: the midpoint, snapped to step (Base UI and Radix default to min).

Props

Slider.Root

PropTypeDefaultDescription
valuenumber | readonly number[]–Controlled value: one number, or one per thumb in ascending order. undefined means uncontrolled.
defaultValuenumber | readonly number[]midpointInitial value in uncontrolled mode; also the form-reset target (the latest value). Default min + (max - min) / 2, snapped to step.
valueChangeActionChangeAction<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.
valueCommitActionChangeAction<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.
minnumber0Lower bound.
maxnumber100Upper bound. A max off the step grid is unreachable, as on a native range (development warning).
stepnumber1Positive. Values snap to min + n × step.
minStepsBetweenThumbsnumber0Minimum 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.
namestring–Form field name for every thumb's input (a Thumb's own name wins). Several thumbs post several entries: read them with FormData.getAll().
formstring–The form attribute for every thumb's input.
disabledbooleanfalseDisables every input (not submitted) and the pointer.
classNamestring | ((state: SliderRootState<T>) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: SliderRootState<T>) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: SliderRootState<T>) => ReactNode)–The parts. The function form receives the shown value; must not suspend.
renderReactElement | ((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

PropTypeDefaultDescription
indexnumber0Which value this thumb edits.
name / formstring–This thumb input's form field name and form attribute; override the Root's.
aria-label / aria-labelledby / aria-describedbystring–Forwarded to the input. Every thumb of a multi-thumb slider needs a name (development warning).
inputRefRef<HTMLInputElement>–The native <input type="range"> inside the thumb.
autoFocusboolean–Forwarded to the input.
classNamestring | ((state: SliderThumbState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: SliderThumbState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: SliderThumbState) => ReactNode)–Rendered inside the styled element, next to the input (an icon, a value bubble).
renderReactElement | ((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

PropTypeDefaultDescription
classNamestring | ((state: SliderTrackState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: SliderTrackState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: SliderTrackState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
index (Value)number–Show one thumb's value. Without it a single-thumb slider shows its value; several thumbs need children.
classNamestring | ((state: SliderRootState / SliderValueState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: SliderRootState / SliderValueState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: SliderRootState / SliderValueState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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.

Data attributes

AttributeOnMeaning
data-pendingRootA 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-pendingThumbThe Root is pending and this is the thumb that moved.
data-draggingRoot, ThumbA pointer is captured (on this thumb).
data-orientationevery parthorizontal or vertical.
data-disabledevery partThe Root's disabled.
data-indexThumb0, 1, …
:focus-visiblethe inputStyle 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; }

Keyboard and ARIA

Every key is the browser's, except ArrowLeft / ArrowRight on vertical and on RTL sliders, which the library normalises.

KeyHorizontalVertical
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 rangesame
Home / Endthe thumb's min / max (its neighbour with several thumbs)same
Tab / Shift+Tabbetween thumbssame
  • Each key press is one step and one commit. Firefox decreases a vertical range on ArrowRight; the library handles ArrowLeft / ArrowRight on vertical sliders so every engine follows the APG (Right increases). Under RTL (the input's computed 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.
  • No 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).
  • A pointer press focuses the input without a focus ring, as on a native range; the page still scrolls along the other axis (touch-action).

Forms and hydration

  • Each thumb posts its value: 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).
  • Form reset works although react-dom keeps a controlled input's 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.
  • Before hydration the inputs are focusable and the keys change them (the visible thumb catches up at hydration, with one "platform" step and commit); the pointer does nothing yet. A plain <input type="range"> from a Server Component is the JS-free slider.
  • Pointer gestures fire no native 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>

Inside a Field

$100 – $400

At most $200 apart.

Show code
SliderField.tsx
"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.css
/* 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;
}
  • A Slider inside a 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.
  • An error sets 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.
  • A drag and the library-handled keys fire no native events, so the Slider tells the Field at each commit (the release, or the key press): 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.
  • The two channels stay separate: the Slider's own save is its data-pending, the form's submission is the Field's.