# Slider

> Single-thumb slider with pointer drag and keyboard control (arrows, PageUp/Down, Home/End), steps and disabled state.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/slider
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/slider.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/slider-demo.tsx"
"use client"

import * as React from "react"

import { Slider } from "@/components/slider"

export default function SliderDemo() {
  const [volume, setVolume] = React.useState(60)
  const [brightness, setBrightness] = React.useState(30)
  return (
    <div className="grid w-full max-w-sm gap-6">
      <div className="grid gap-3">
        <div className="flex items-center justify-between text-sm">
          <span className="font-medium">Volume</span>
          <span className="tabular-nums text-muted-foreground" data-testid="volume">{volume}</span>
        </div>
        <Slider aria-label="Volume" value={volume} onValueChange={setVolume} />
      </div>
      <div className="grid gap-3">
        <div className="flex items-center justify-between text-sm">
          <span className="font-medium">Brightness (step 10)</span>
          <span className="tabular-nums text-muted-foreground">{brightness}%</span>
        </div>
        <Slider aria-label="Brightness" value={brightness} onValueChange={setBrightness} step={10} />
      </div>
      <div className="grid gap-3">
        <span className="text-sm font-medium text-muted-foreground">Disabled</span>
        <Slider aria-label="Disabled" defaultValue={40} disabled />
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/slider.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface SliderProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  value?: number
  defaultValue?: number
  onValueChange?: (value: number) => void
  min?: number
  max?: number
  step?: number
  disabled?: boolean
  "aria-label"?: string
}

export function Slider({
  value,
  defaultValue = 0,
  onValueChange,
  min = 0,
  max = 100,
  step = 1,
  disabled = false,
  className,
  "aria-label": ariaLabel,
  ...props
}: SliderProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const track = React.useRef<HTMLDivElement>(null)
  const thumb = React.useRef<HTMLDivElement>(null)
  const dragging = React.useRef(false)
  const [active, setActive] = React.useState(false)

  const clamp = (n: number) => Math.min(max, Math.max(min, n))
  const current = clamp(value ?? internal)
  const percent = max === min ? 0 : ((current - min) / (max - min)) * 100

  const commit = (next: number) => {
    const snapped = clamp(Number((Math.round((next - min) / step) * step + min).toFixed(6)))
    if (snapped === current) return
    setInternal(snapped)
    onValueChange?.(snapped)
  }

  const fromPointer = (clientX: number) => {
    const rect = track.current?.getBoundingClientRect()
    if (!rect || rect.width === 0) return
    commit(min + ((clientX - rect.left) / rect.width) * (max - min))
  }

  const onKeyDown = (event: React.KeyboardEvent) => {
    if (disabled) return
    const big = step * 10
    const map: Record<string, number> = {
      ArrowRight: current + step,
      ArrowUp: current + step,
      ArrowLeft: current - step,
      ArrowDown: current - step,
      PageUp: current + big,
      PageDown: current - big,
      Home: min,
      End: max,
    }
    if (event.key in map) {
      event.preventDefault()
      commit(map[event.key])
    }
  }

  return (
    <div
      {...props}
      data-disabled={disabled || undefined}
      className={cn("relative flex h-6 w-full touch-none select-none items-center", disabled && "opacity-50", className)}
      onPointerDown={(event) => {
        if (disabled || event.button !== 0) return
        event.currentTarget.setPointerCapture(event.pointerId)
        dragging.current = true
        setActive(true)
        thumb.current?.focus()
        fromPointer(event.clientX)
      }}
      onPointerMove={(event) => dragging.current && fromPointer(event.clientX)}
      onPointerUp={() => {
        dragging.current = false
        setActive(false)
      }}
      onPointerCancel={() => {
        dragging.current = false
        setActive(false)
      }}
    >
      <div ref={track} className="relative h-1.5 w-full rounded-full bg-muted">
        <div className="absolute inset-y-0 left-0 rounded-full bg-primary" style={{ width: `${percent}%` }} />
        <div
          ref={thumb}
          role="slider"
          tabIndex={disabled ? -1 : 0}
          aria-label={ariaLabel}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={current}
          aria-disabled={disabled || undefined}
          onKeyDown={onKeyDown}
          className={cn(
            "absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-primary bg-background shadow-sm outline-none transition-transform focus-visible:ring-4 focus-visible:ring-ring/40",
            active && "scale-110",
          )}
          style={{ left: `${percent}%` }}
        />
      </div>
    </div>
  )
}
```
