Slider
A value set along a track, with optional bounds, value labels, fixed stops and a preferred maximum.
On this page
Code
import { Slider } from "@bosunkit/components/slider";
import { Stack } from "@bosunkit/components/stack";
import { Switch } from "@bosunkit/components/switch";
import { useId, useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Choose which labels to show, then adjust the level. Its accessible name stays available in every combination.";
export default function Presentation(): ReactElement {
const [label, setLabel]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
const [value, setValue]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
const [bounds, setBounds]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);
const prefix: string = useId();
return (
<div style={{ width: "100%" }}>
<Stack space="xl">
<Slider
label="Level"
defaultValue={60}
showLabel={label}
showValue={value}
showBounds={bounds}
formatValue={(next: number): string => `${String(next)}%`}
/>
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-lg)", font: "var(--bk-type-caption)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch id={`${prefix}-label`} checked={label} onCheckedChange={setLabel} />
<label htmlFor={`${prefix}-label`}>Show label</label>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch id={`${prefix}-value`} checked={value} onCheckedChange={setValue} />
<label htmlFor={`${prefix}-value`}>Show value</label>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Switch id={`${prefix}-bounds`} checked={bounds} onCheckedChange={setBounds} />
<label htmlFor={`${prefix}-bounds`}>Show min and max</label>
</div>
</div>
</Stack>
</div>
);
}
A slider suits quick adjustments within known bounds. Give it a visible label and current value unless the surrounding control already supplies them. Use a number field alongside it when someone needs to enter an exact value.
Examples
Current value and bounds
showLabel, showValue and showBounds control the visible annotations independently. The required label always
names the slider for assistive technology. formatValue supplies the same display text to the readout, endpoints and
screen reader; use it for units or named choices. Without it, values are plain numeric text.
Code
import { Slider } from "@bosunkit/components/slider";
import type { ReactElement } from "react";
export const description: string =
"Drag or press the track, or use arrow keys, Home and End. The current value and bounds stay visible.";
export default function Brightness(): ReactElement {
return (
<div style={{ width: "100%" }}>
<Slider
label="Brightness"
defaultValue={70}
showLabel
showValue
showBounds
formatValue={(value: number): string => `${String(value)}%`}
testId="brightness"
/>
</div>
);
}
Brilliance grip
variant="brilliance" draws a narrow filled grip using the display palette. The
brilliance menu uses this same Slider. Both variants support the same labels,
marks, limits and keyboard controls, with a full touch target around the visible grip.
Code
import { Slider } from "@bosunkit/components/slider";
import type { ReactElement } from "react";
export const description: string =
"The brilliance grip uses the display palette and keeps the same touch target, keyboard controls and optional labels.";
export default function Brilliance(): ReactElement {
return (
<div style={{ width: "100%" }}>
<Slider
variant="brilliance"
label="Brightness"
defaultValue={50}
step={5}
largeStep={25}
showLabel
showValue
showBounds
formatValue={(value: number): string => `${String(value)}%`}
testId="brilliance-slider"
/>
</div>
);
}
Icons
labelIcon, thumbIcon, minIcon and maxIcon are independent slots. An icon in the thumb expands the visible grip
while keeping its full touch target. Endpoint icons can accompany showBounds or appear on their own. Icons are
decorative: the label and formatted value name the setting and its selected value for assistive technology.
Code
import { Slider } from "@bosunkit/components/slider";
import { Anchor, Maximize2, Minimize2 } from "lucide-react";
import type { ReactElement } from "react";
export const description: string =
"Set an example swing radius. Icons mark the label, draggable thumb and the ends of the range; the value and unit remain visible.";
export default function AnchorSwing(): ReactElement {
return (
<div style={{ width: "100%" }}>
<Slider
variant="brilliance"
label="Swing radius"
labelIcon={<Anchor />}
thumbIcon={<Anchor />}
minIcon={<Minimize2 />}
maxIcon={<Maximize2 />}
min={10}
max={150}
step={5}
largeStep={25}
defaultValue={40}
showLabel
showValue
showBounds
formatValue={(value: number): string => `${String(value)} m`}
testId="anchor-swing-slider"
/>
</div>
);
}
Fixed numeric steps
step determines which values can be selected. marks draw ticks at meaningful points; they do not change the step.
Keep labels short and sparse enough to fit on a phone. This example has five allowed values.
Code
import { Slider, type SliderMark } from "@bosunkit/components/slider";
import type { ReactElement } from "react";
export const description: string = "Five allowed levels. The thumb snaps to each quarter of the range.";
const MARKS: readonly SliderMark[] = [0, 25, 50, 75, 100].map((value: number): SliderMark => ({
value,
label: `${String(value)}%`,
}));
export default function Stepped(): ReactElement {
return (
<div style={{ width: "100%" }}>
<Slider
label="Light level"
defaultValue={50}
step={25}
marks={MARKS}
showLabel
showValue
formatValue={(value: number): string => `${String(value)}%`}
/>
</div>
);
}
Named choices
An ordered set can use one numeric position per choice, with formatValue reading its name. These positions are
equally spaced choices, not a numeric scale of the underlying setting. Use a segmented control when the choices
have no meaningful order.
Code
import { Slider, type SliderMark } from "@bosunkit/components/slider";
import type { ReactElement } from "react";
export const description: string = "A fixed set of ordered choices. Each step selects and announces one named option.";
const LABELS: readonly string[] = ["Low", "Balanced", "High"];
const MARKS: readonly SliderMark[] = LABELS.map((label: string, value: number): SliderMark => ({ value, label }));
export default function Presets(): ReactElement {
return (
<div style={{ width: "100%" }}>
<Slider
label="Detail level"
min={0}
max={2}
defaultValue={1}
step={1}
marks={MARKS}
showLabel
showValue
formatValue={(value: number): string => LABELS[value] ?? String(value)}
/>
</div>
);
}
Passing a preferred maximum
limit marks a preferred maximum within the allowed range. Its hatched region stays visible, and its explanation
changes when the value crosses the marker. The accessible value includes that explanation above the limit. max
remains a hard bound: the thumb cannot move past it.
Use this for a recommendation that can be exceeded. A safety constraint belongs in the hard bounds and in the system that accepts the setting; a preferred marker is not an alarm or an enforcement mechanism.
Code
import { Slider } from "@bosunkit/components/slider";
import type { ReactElement } from "react";
export const description: string =
"Move above 80% to cross the preferred maximum. The marker stays in place and 100% remains the hard limit.";
export default function PreferredLimit(): ReactElement {
return (
<div style={{ width: "100%" }}>
<Slider
label="Media volume"
defaultValue={70}
min={0}
max={100}
showLabel
showValue
showBounds
formatValue={(value: number): string => `${String(value)}%`}
limit={{ value: 80, label: "Preferred maximum: 80%", exceededLabel: "Above preferred maximum: 80%" }}
hint="The hatched section is available above the preferred maximum."
/>
</div>
);
}
Exact numeric entry
Keep the slider and number field controlled by the same value. An incomplete or out-of-range draft stays in the field with an explanation; the slider keeps the last valid value. Changing the slider updates the field again.
Code
import { FormField } from "@bosunkit/components/form-field";
import { NumberInput } from "@bosunkit/components/number-input";
import { Slider } from "@bosunkit/components/slider";
import { Stack } from "@bosunkit/components/stack";
import { useState, type ChangeEvent, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Drag for a quick adjustment or type a precise value. Both controls share the same level.";
export default function ExactValue(): ReactElement {
const [level, setLevel]: [number, Dispatch<SetStateAction<number>>] = useState<number>(40);
const [draft, setDraft]: [string, Dispatch<SetStateAction<string>>] = useState<string>("40");
const parsed: number = Number(draft);
const valid: boolean = draft.trim() !== "" && Number.isInteger(parsed) && parsed >= 0 && parsed <= 100;
return (
<div style={{ width: "100%" }}>
<Stack space="md">
<Slider
label="Level"
value={level}
showLabel
showValue
showBounds
formatValue={(value: number): string => `${String(value)}%`}
onValueChange={(value: number): void => {
setLevel(value);
setDraft(String(value));
}}
/>
<FormField label="Exact level" error={valid ? undefined : "Enter a whole number from 0 to 100."}>
<NumberInput
value={draft}
min={0}
max={100}
step={1}
unit="%"
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
const next: string = event.currentTarget.value;
const number: number = Number(next);
setDraft(next);
if (next.trim() !== "" && Number.isInteger(number) && number >= 0 && number <= 100) setLevel(number);
}}
/>
</FormField>
</Stack>
</div>
);
}
Disabled
Keep the reason visible when the control is unavailable. A disabled slider takes no pointer or keyboard input.
Code
import { Slider } from "@bosunkit/components/slider";
import type { ReactElement } from "react";
export const description: string = "The disabled control preserves its value and explains why it cannot be adjusted.";
export default function Disabled(): ReactElement {
return (
<div style={{ width: "100%" }}>
<Slider
label="Media volume"
defaultValue={40}
disabled
showLabel
showValue
showBounds
formatValue={(value: number): string => `${String(value)}%`}
hint="Connect an audio output to adjust its volume."
/>
</div>
);
}
Interaction
Arrow keys change one step; Home and End select the hard bounds. Page Up/Down and Shift+Arrow use largeStep, which
defaults to ten steps. Choose an increment that is a multiple of step.
onValueChange reports changes during interaction. onValueCommitted reports a changed value on release or a
keyboard commit. Neither callback means a setting has been saved: the caller owns persistence, pending feedback and
rejections. Examples here change local state only.
The thumb's visible circle uses the spacing scale, while its hit area and the control's height use
--target-touch-size. Endpoints leave room for that area, and focus remains visible around the thumb. This preserves
the target on touch screens without drawing the entire target as a large circle.
limit within min…maxvalue > maxDesign references
Spectrum describes labels, values and bounded adjustment. Material UI shows value labels, marked steps, restricted choices and a paired number field. The kit uses Base UI for its input behavior and follows the WAI-ARIA slider keyboard pattern.
Other patterns include two-thumb range selection, vertical tracks and nonlinear scales. They need separate decisions about thumb names, collision behavior and the meaning of each step; the kit's Slider selects one value on a horizontal track.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "brilliance" | undefined | "default" | The visible grip: a circle by default, or the narrow filled brilliance grip. Both keep a full touch target. |
| value | number | undefined | – | The value the slider holds, for a caller that holds it; pair it with onValueChange. |
| defaultValue | number | undefined | – | The value it starts with, when the caller does not hold value. |
| onValueChange | ((value: number) => void) | undefined | – | Called with the new value as the crew move the thumb. |
| onValueCommitted | ((value: number) => void) | undefined | – | Called when a changed value is released or committed by the keyboard. This is an input event, not a save. |
| min | number | undefined | 0 | The least value; 0 unless set. |
| max | number | undefined | 100 | The greatest value; 100 unless set. |
| step | number | undefined | 1 | The step between values; 1 unless set. |
| largeStep | number | undefined | step * 10 | The increment for Page Up/Down or Shift+Arrow; ten steps unless set. |
| labelrequired | string | – | The value's name, translated by the caller ("Brightness"); the thumb's accessible name. |
| labelIcon | ReactNode | – | Decorative icon beside the label; the required label still supplies the accessible name. |
| thumbIcon | ReactNode | – | Decorative icon inside the draggable thumb; its visible grip expands to fit while keeping the full target. |
| minIcon | ReactNode | – | Decorative icon at the minimum end, beside its value when showBounds is enabled. |
| maxIcon | ReactNode | – | Decorative icon at the maximum end, beside its value when showBounds is enabled. |
| showLabel | boolean | undefined | false | Show the label above the track; false by default for controls with an enclosing label. |
| showValue | boolean | undefined | false | Show the current value above the track; false by default. |
| showBounds | boolean | undefined | false | Show the formatted minimum and maximum below the track; false by default. |
| formatValue | ((value: number) => string) | undefined | String | Format the visible and accessible values with the caller's units or named choices; plain numbers by default. |
| marks | readonly SliderMark[] | undefined | [] | Tick marks and optional labels. Marks do not change allowed values; use step for equally spaced stops. |
| limit | SliderLimit | undefined | – | A preferred maximum with visible feedback when crossed; max remains the hard bound. |
| hint | string | undefined | – | Display text under the slider, also read as its accessible description. |
| disabled | boolean | undefined | – | Whether the slider takes no input. |
| className | string | undefined | – | Classes added to the slider's own. |
| testId | string | undefined | – | Becomes data-testid on the slider's root; the thumb is ${testId}--thumb. |
Related
- Number input: exact numeric entry.
- Segmented radio group: a small set of named choices.
- Cursors: pointer and drag feedback.
- Accessibility: touch targets and keyboard operation.