Skip to content
design
Components

Slider

A value set along a track, with optional bounds, value labels, fixed stops and a preferred maximum.

On this page
Level
60%
Adjust the level and choose whether to show its label, current value and bounds. The small visible thumb keeps a full touch target.
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.

01Show what is selectedKeep the current value readable without hovering. Name ordered choices and include units where a number alone is ambiguous.
02Distinguish preferred and hard limitsA preferred maximum is a marker that can be crossed. The hard maximum is the end of the selectable range.
03Keep the target easy to operateThe visible thumb is small, with a full touch target around it. The track accepts a press, and the keyboard can reach every 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.

Brightness
70%
Drag or press the track, or use arrow keys, Home and End. The current value and bounds stay visible.
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.

Brightness
50%
The brilliance grip uses the display palette and keeps the same touch target, keyboard controls and optional labels.
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.

Swing radius
40 m
Set an example swing radius. Icons mark the label, draggable thumb and the ends of the range; the value and unit remain visible.
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.

Light level
50%
Five allowed levels. The thumb snaps to each quarter of the range.
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.

Detail level
Balanced
A fixed set of ordered choices. Each step selects and announces one named option.
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.

Media volume
70%

Preferred maximum: 80%

The hatched section is available above the preferred maximum.

Move above 80% to cross the preferred maximum. The marker stays in place and 100% remains the hard limit.
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.

Level
40%
%
Drag for a quick adjustment or type a precise value. Both controls share the same level.
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.

Media volume
40%

Connect an audio output to adjust its volume.

The disabled control preserves its value and explains why it cannot be adjusted.
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.

Do
limit within min…max
Make a preferred maximum and the hard endpoint distinct, and explain when the recommendation is exceeded.
Don't
value > max
Do not label a passable marker as the hard maximum. The displayed range must agree with the allowed values.

Design 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

PropTypeDefaultDescription
variant"default" | "brilliance" | undefined"default"The visible grip: a circle by default, or the narrow filled brilliance grip. Both keep a full touch target.
valuenumber | undefined–The value the slider holds, for a caller that holds it; pair it with onValueChange.
defaultValuenumber | 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.
minnumber | undefined0The least value; 0 unless set.
maxnumber | undefined100The greatest value; 100 unless set.
stepnumber | undefined1The step between values; 1 unless set.
largeStepnumber | undefinedstep * 10The increment for Page Up/Down or Shift+Arrow; ten steps unless set.
labelrequiredstring–The value's name, translated by the caller ("Brightness"); the thumb's accessible name.
labelIconReactNode–Decorative icon beside the label; the required label still supplies the accessible name.
thumbIconReactNode–Decorative icon inside the draggable thumb; its visible grip expands to fit while keeping the full target.
minIconReactNode–Decorative icon at the minimum end, beside its value when showBounds is enabled.
maxIconReactNode–Decorative icon at the maximum end, beside its value when showBounds is enabled.
showLabelboolean | undefinedfalseShow the label above the track; false by default for controls with an enclosing label.
showValueboolean | undefinedfalseShow the current value above the track; false by default.
showBoundsboolean | undefinedfalseShow the formatted minimum and maximum below the track; false by default.
formatValue((value: number) => string) | undefinedStringFormat the visible and accessible values with the caller's units or named choices; plain numbers by default.
marksreadonly SliderMark[] | undefined[]Tick marks and optional labels. Marks do not change allowed values; use step for equally spaced stops.
limitSliderLimit | undefined–A preferred maximum with visible feedback when crossed; max remains the hard bound.
hintstring | undefined–Display text under the slider, also read as its accessible description.
disabledboolean | undefined–Whether the slider takes no input.
classNamestring | undefined–Classes added to the slider's own.
testIdstring | undefined–Becomes data-testid on the slider's root; the thumb is ${testId}--thumb.