# Slider (Caseroom component)

A native range input with a filled track and a live value. Arrows, Page Up/Down, Home and End. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/slider.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/slider/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### slider.astro

```astro
---
import { cn } from '@/lib/utils';
import { fillOf, slider } from './slider.variants';

interface Props {
  id: string;
  label: string;
  value: number;
  min?: number;
  max?: number;
  step?: number;
  /** Shown after the number, e.g. "%" or " GB". */
  unit?: string;
  name?: string;
  disabled?: boolean;
  class?: string;
}

const { id, label, value, min = 0, max = 100, step = 1, unit = '', name, disabled, class: className } = Astro.props;
---

<div class={cn(slider.root, className)}>
  <div class={slider.head}>
    <label for={id} class={slider.label}>{label}</label>
    <output for={id} data-unit={unit} class={slider.output}>{value}{unit}</output>
  </div>
  <input type="range" id={id} name={name} min={min} max={max} step={step} value={value} disabled={disabled} data-slider class={slider.input} style={`--slider-fill: ${fillOf(value, min, max)}`} />
</div>

<script>
  import './slider.client';
</script>
```

### slider.client.ts

```tsx
// Slider for Astro and plain HTML: keeps the filled part of the track (--slider-fill) and the <output> in step with
// the value. The React version does the same in state.
declare global {
  interface Window {
    __caseroomSlider?: boolean;
  }
}

if (typeof window !== 'undefined' && !window.__caseroomSlider) {
  window.__caseroomSlider = true;
  document.addEventListener('input', (e) => {
    const input = e.target as HTMLInputElement;
    if (!input.matches?.('input[type=range][data-slider]')) return;
    const min = Number(input.min || 0);
    const max = Number(input.max || 100);
    input.style.setProperty('--slider-fill', `${((Number(input.value) - min) / (max - min)) * 100}%`);
    const out = document.querySelector<HTMLOutputElement>(`output[for="${CSS.escape(input.id)}"]`);
    if (out) out.value = `${input.value}${out.dataset.unit ?? ''}`;
  });
}

export {};
```

### slider.tsx

```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

```tsx
/** 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}%`;
```
