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.json

Usage

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

  1. One state shape, N rows. The group's source of truth is a Record<string, number> keyed by each slider's id. Controlled callers pass values; uncontrolled callers let the group seed itself from each row's value (or min) and remember edits internally.
  2. 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-implements role="slider".
  3. 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.
  4. Three sizes via CSS variables. The size prop swaps track height and thumb diameter through --cb-sg-track-h and --cb-sg-thumb. Every row in the group adopts the same recipe; no per-row vendor-prefix rules are stamped out.
  5. Density without rewriting rows. density controls the inter-row gap only — compact for dense settings panels, spacious for hero callouts. Row internals stay constant.
  6. 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

PropTypeDefaultDescription
slidersreadonly SliderGroupSlider[]requiredRow descriptors. See the type below.
valuesReadonly<Record<string, number>>Controlled values map keyed by slider id. Pair with onValuesChange.
defaultValuesReadonly<Record<string, number>>Uncontrolled initial values. Missing keys fall through to each slider's own value, then min.
onValuesChange(values) => voidFired 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.
disabledbooleanfalseDisable every row. Individual slider.disabled still wins.
titleReactNodeOptional small-caps heading rendered above the rows with a hairline rule.
aria-labelstringAccessible name for the group container (role="group").
classNamestringMerged onto the outer wrapper <div>.

SliderGroupSlider

FieldTypeDefaultDescription
idstringrequiredStable key in the values map; drives htmlFor on the label.
labelReactNoderequiredVisible label for the row.
minnumberrequiredMinimum value.
maxnumberrequiredMaximum value.
stepnumber1Step granularity.
valuenumberminInitial value used in uncontrolled mode (or as fallback for missing defaultValues keys).
unitstringSuffix shown next to the readout. Ignored when format is provided.
format(value) => stringFull formatter for the readout — wins over unit.
disabledbooleanfalseDisable 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. Pass aria-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 generated useId() — readers announce the label as the input's accessible name.
  • aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext are 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-muted halo).
  • 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 + density variants, and the same vendor-prefixed track recipe Labeled Slider uses (so both primitives feel consistent side-by-side).