Slider Group
A compound slider primitive: pass an array of { id, label, min, max, step?, value } descriptors and one map of values, and the group renders N labelled <input type="range"> rows that all read from / write to the same state shape. Use it whenever a feature needs several sliders that move together — colour pickers, HSL editors, audio gain banks, model-tuning panels.
HSL
210°
60%
50%
Customize
Appearance
1
1
Installation
npx shadcn@latest add https://craftbits.dev/r/slider-group.jsonUsage
Uncontrolled — describe each slider once, let the group own the values:
import { SliderGroup } from "@craft-bits/core";
<SliderGroup
title="HSL"
sliders={[
{ id: "hue", label: "Hue", min: 0, max: 360, value: 210, unit: "°" },
{ id: "saturation", label: "Saturation", min: 0, max: 100, value: 60, unit: "%" },
{ id: "lightness", label: "Lightness", min: 0, max: 100, value: 50, unit: "%" },
]}
/>Controlled — pair values with onValuesChange. The callback receives the next full map every time any slider moves:
const [values, setValues] = useState({ hue: 210, saturation: 60, lightness: 50 });
<SliderGroup
title="HSL"
sliders={hslSliders}
values={values}
onValuesChange={setValues}
/>Mix per-slider formatters with shared size and density:
<SliderGroup
size="lg"
density="spacious"
sliders={[
{ id: "gain", label: "Gain", min: 0, max: 1, step: 0.01, value: 0.5, format: (v) => v.toFixed(2) },
{ id: "pan", label: "Pan", min: -1, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2) },
]}
/>Understanding the component
- One state shape, N rows. The group's source of truth is a
Record<string, number>keyed by each slider'sid. Controlled callers passvalues; uncontrolled callers let the group seed itself from each row'svalue(ormin) and remember edits internally. - Native range under each row. Every row is still a real
<input type="range">, so arrow-key stepping, Home/End, PageUp/PageDown, and screen-reader semantics ship for free. The group adds the label, the readout, and the shared state — never re-implementsrole="slider". - Shared track-fill recipe. All rows share one vendor-prefixed stylesheet, injected once per page on a sentinel id. Per-row fill comes from a single CSS variable (
--cb-sg-pct) the React layer writes inline — no DOM measurement, no JS animation loop. - Three sizes via CSS variables. The
sizeprop swaps track height and thumb diameter through--cb-sg-track-hand--cb-sg-thumb. Every row in the group adopts the same recipe; no per-row vendor-prefix rules are stamped out. - Density without rewriting rows.
densitycontrols the inter-row gap only —compactfor dense settings panels,spaciousfor hero callouts. Row internals stay constant. - Schema-aware re-seeding. If callers add or remove a slider id between renders, the group re-seeds new keys (and clamps surviving keys to their fresh
[min, max]) without clobbering values the user has already moved.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
sliders | readonly SliderGroupSlider[] | required | Row descriptors. See the type below. |
values | Readonly<Record<string, number>> | — | Controlled values map keyed by slider id. Pair with onValuesChange. |
defaultValues | Readonly<Record<string, number>> | — | Uncontrolled initial values. Missing keys fall through to each slider's own value, then min. |
onValuesChange | (values) => void | — | Fired with the next full values map whenever any slider moves. |
size | 'sm' | 'md' | 'lg' | 'md' | Track height + thumb diameter applied to every row. |
density | 'compact' | 'comfortable' | 'spacious' | 'comfortable' | Inter-row spacing. |
disabled | boolean | false | Disable every row. Individual slider.disabled still wins. |
title | ReactNode | — | Optional small-caps heading rendered above the rows with a hairline rule. |
aria-label | string | — | Accessible name for the group container (role="group"). |
className | string | — | Merged onto the outer wrapper <div>. |
SliderGroupSlider
| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Stable key in the values map; drives htmlFor on the label. |
label | ReactNode | required | Visible label for the row. |
min | number | required | Minimum value. |
max | number | required | Maximum value. |
step | number | 1 | Step granularity. |
value | number | min | Initial value used in uncontrolled mode (or as fallback for missing defaultValues keys). |
unit | string | — | Suffix shown next to the readout. Ignored when format is provided. |
format | (value) => string | — | Full formatter for the readout — wins over unit. |
disabled | boolean | false | Disable this single row only. |
Accessibility
- Each row renders a native
<input type="range">—role="slider", arrow-key stepping, Home / End jump to bounds, and PageUp / PageDown step in larger increments. All browser-native; no JS rebuilding. - The outer container declares
role="group"so screen readers announce the rows as a single related set. Passaria-label(or wrap in a<fieldset>with a<legend>) for the group's accessible name. - Each row's visible label is a real
<label htmlFor={inputId}>linked via a generateduseId()— readers announce the label as the input's accessible name. aria-valuemin,aria-valuemax,aria-valuenow, andaria-valuetextare mirrored per row from React state so assistive tech announces the formatted value (including unit) — not just the raw number.- Focus is visible via a token-driven ring around each thumb (
--cb-accent-mutedhalo). - Color contrast: labels use
--cb-fg-muted, values use--cb-fg, and the filled track sits on--cb-accent— all pass WCAG AA in the default theme. - The thumb hover-scale is suppressed when
:disabled— no JS branching.
Credits
- Extracted from:
AlgoFlashcards(src/lessons/primitives/interaction/SliderGroup.tsx). The original was a lesson-scaffolded bank tied to phase / cardIndex state with per-slider stylesheets. craft-bits generalises it to a stand-alone compound primitive: schema-driven rows, a single controlled+uncontrolled values shape,size+densityvariants, and the same vendor-prefixed track recipe Labeled Slider uses (so both primitives feel consistent side-by-side).