Slider

Select a value from a continuous range. Supports single thumb for simple selection or dual thumbs for range selection with keyboard accessibility.

Single value

Choose one value from a range. Common uses include volume control, brightness adjustment, or rating input.

50
0100
70
0100

Range selection

Select a minimum and maximum value. Useful for price filters, date ranges, or any bounded selection.

2575
0500

Anatomy

MinMaxValue

States

StateAppearanceNotes
Default
Resting state
Hover
Cursor over thumb
Active
Dragging thumb
Disabled
Not interactive

Usage guidelines

✓ Do
  • ·Use sliders for continuous values, not discrete choices
  • ·Provide clear min/max labels to define the range
  • ·Default to a meaningful middle value, not extremes
  • ·Allow precise input via keyboard arrows and Shift for larger steps
✗ Don't
  • ·Don't use sliders for binary choices (use a switch instead)
  • ·Avoid sliders when precision is critical (use numeric input)
  • ·Don't hide the current value — always show it
  • ·Don't use more than 2 thumbs for range selection

Props

PropTypeDefaultDescription
value*number | number[]Current slider value(s). Single number for single thumb, array for range.
onValueChange*(value: number | number[]) => voidCallback fired when value changes.
minnumber0Minimum value.
maxnumber100Maximum value.
stepnumber1Step increment.
disabledbooleanfalsePrevents interaction and reduces opacity.
labelstringLabel text displayed above the slider.
showValuebooleanfalseDisplay current value next to the label.

Implementation

Slider.tsx
tsx
"use client";

import { cn } from "@/lib";
import { useState } from "react";

export interface SliderProps {
  value?: number | number[];
  defaultValue?: number | number[];
  onValueChange?: (value: number | number[]) => void;
  min?: number;
  max?: number;
  step?: number;
  disabled?: boolean;
  label?: string;
  showValue?: boolean;
}

export function Slider({
  value: controlledValue,
  defaultValue,
  onValueChange,
  min = 0,
  max = 100,
  step = 1,
  disabled = false,
  label,
  showValue = false,
}: SliderProps) {
  const [internalValue, setInternalValue] = useState<number | number[]>(
    controlledValue ?? defaultValue ?? 0
  );
  const currentValue = controlledValue ?? internalValue;

  function handleChange(newValue: number | number[]) {
    if (disabled) return;
    if (controlledValue === undefined) {
      setInternalValue(newValue);
    }
    onValueChange?.(newValue);
  }

  function getPercentage(val: number) {
    return ((val - min) / (max - min)) * 100;
  }

  const isRange = Array.isArray(currentValue);
  const values = isRange ? currentValue : [currentValue];
  const minVal = Math.min(...values);
  const maxVal = Math.max(...values);

  return (
    <div className="w-full space-y-2">
      {(label || showValue) && (
        <div className="flex items-center justify-between">
          {label && (
            <label className="text-[12px] font-medium text-[rgb(var(--text-secondary))]">
              {label}
            </label>
          )}
          {showValue && (
            <span className="text-[12px] font-medium text-[rgb(var(--text-primary))]">
               {isRange ? minVal + " – " + maxVal : minVal}
            </span>
          )}
        </div>
      )}
      <div className="relative h-2 flex items-center">
        {/* Track */}
        <div className="absolute h-[3px] rounded-full w-full bg-[rgb(var(--border))]" />
        {/* Fill */}
        <div
          className="absolute h-[3px] rounded-full bg-[rgb(var(--accent))]"
          style={{
             left: getPercentage(minVal) + "%",
             width: getPercentage(maxVal) - getPercentage(minVal) + "%",
          }}
        />
        {/* Thumbs */}
        {values.map((val, i) => (
          <div
            key={i}
            className={cn(
              "absolute w-4 h-4 rounded-full border-2 cursor-pointer",
              "bg-white border-[rgb(var(--accent))]",
              "transition-transform hover:scale-110",
              disabled && "opacity-40 cursor-not-allowed"
            )}
            style={{
               left: getPercentage(val) + "%",
              transform: "translateX(-50%)",
              top: "50%",
              marginTop: "-6px",
            }}
            role="slider"
            aria-valuemin={min}
            aria-valuemax={max}
            aria-valuenow={val}
            aria-label={label}
            tabIndex={disabled ? undefined : 0}
            onKeyDown={(e) => {
              if (disabled) return;
              const delta = e.shiftKey ? step * 10 : step;
              let newVal = val;
              if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
                newVal = Math.max(val - delta, min);
              } else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
                newVal = Math.min(val + delta, max);
              }
              if (newVal !== val) {
                e.preventDefault();
                const newValue = isRange
                  ? (values.map((v, idx) => (idx === i ? newVal : v)) as [number, number])
                  : newVal;
                handleChange(newValue);
              }
            }}
          >
            <input
              type="range"
              min={min}
              max={max}
              step={step}
              value={val}
              onChange={(e) => {
                const newVal = parseFloat(e.target.value);
                if (isRange) {
                  const newValue = [...values] as [number, number];
                  newValue[i] = newVal;
                  handleChange(newValue);
                } else {
                  handleChange(newVal);
                }
              }}
              className="sr-only"
              disabled={disabled}
            />
          </div>
        ))}
      </div>
      <div className="flex justify-between text-[10px] text-[rgb(var(--text-tertiary))]">
        <span>{min}</span>
        <span>{max}</span>
      </div>
    </div>
  );
}

Accessibility

  • Sliders must have an associated label via aria-label or aria-labelledby.
  • Keyboard: Arrow keys adjust by step; Shift + arrow keys adjust by 10× step.
  • Range sliders must have aria-valuemin, aria-valuemax, and aria-valuenow on each thumb.
  • Provide visible focus indicators on thumbs for keyboard users.
  • Value display must be screen-reader accessible via aria-valuetext if needed.