Skip to content
Caseroom

Components · Forms

Slider

A native range input with a filled track and a live value. Arrows, Page Up/Down, Home and End.

40%
5
30 days

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/slider.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/slider.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

slider.tsx
import { useState, type CSSProperties } from 'react';
import { cn } from '@/lib/utils';
import { fillOf, slider } from './slider.variants';

export interface SliderProps {
  id: string;
  label: string;
  /** Uncontrolled start value (use value + onValueChange to control it). */
  defaultValue?: number;
  value?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Shown after the number, e.g. "%" or " GB". */
  unit?: string;
  name?: string;
  disabled?: boolean;
  className?: string;
}

/** A native range input with a filled track and a live output. */
export function Slider({ id, label, defaultValue = 50, value, onValueChange, min = 0, max = 100, step = 1, unit = '', name, disabled, className }: SliderProps) {
  const [own, setOwn] = useState(defaultValue);
  const v = value ?? own;
  return (
    <div className={cn(slider.root, className)}>
      <div className={slider.head}>
        <label htmlFor={id} className={slider.label}>{label}</label>
        <output htmlFor={id} data-unit={unit} className={slider.output}>{v}{unit}</output>
      </div>
      <input
        type="range" id={id} name={name} min={min} max={max} step={step} value={v} disabled={disabled} data-slider="" className={slider.input}
        style={{ '--slider-fill': fillOf(v, min, max) } as CSSProperties}
        onChange={(e) => {
          const n = Number(e.target.value);
          setOwn(n);
          onValueChange?.(n);
        }}
      />
    </div>
  );
}
slider.variants.ts
/** A slider on the native range input: arrow keys, Page Up/Down, Home and End come from the browser. The track fills
 *  up to the thumb (a gradient driven by --slider-fill, kept in step by a few lines of script or by React). The
 *  thumb grows on hover and press; the focus ring goes round the whole slider. */
export const slider = {
  root: 'grid w-full gap-3',
  head: 'flex items-baseline justify-between gap-4 text-sm',
  label: 'font-medium',
  output: 'text-muted-foreground tabular-nums',
  input:
    "h-6 w-full cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-offset-4 disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--primary)_var(--slider-fill,50%),var(--muted)_var(--slider-fill,50%))] [&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-muted [&::-moz-range-progress]:h-1.5 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-primary [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform enabled:hover:[&::-webkit-slider-thumb]:scale-115 enabled:active:[&::-webkit-slider-thumb]:scale-125 enabled:active:[&::-webkit-slider-thumb]:bg-pressed [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background",
};

export const fillOf = (value: number, min: number, max: number) => `${((value - min) / (max - min)) * 100}%`;