Components · Forms
Slider
A native range input with a filled track and a live value. Arrows, Page Up/Down, Home and End.
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.
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>
);
}/** 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}%`;---
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 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 {};/** 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}%`;<div class="grid w-full max-w-sm gap-6">
<div class="grid w-full gap-3">
<div class="flex items-baseline justify-between gap-4 text-sm">
<label for="ex-volume" class="font-medium">Preview volume</label>
<output for="ex-volume" data-unit="%" class="text-muted-foreground tabular-nums">40%</output>
</div>
<input type="range" id="ex-volume" min="0" max="100" step="1" value="40" data-slider class="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" style="--slider-fill: 40%">
</div>
<div class="grid w-full gap-3">
<div class="flex items-baseline justify-between gap-4 text-sm">
<label for="ex-seats" class="font-medium">Seats</label>
<output for="ex-seats" data-unit class="text-muted-foreground tabular-nums">5</output>
</div>
<input type="range" id="ex-seats" min="1" max="20" step="1" value="5" data-slider class="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" style="--slider-fill: 21.052631578947366%">
</div>
<div class="grid w-full gap-3">
<div class="flex items-baseline justify-between gap-4 text-sm">
<label for="ex-retention" class="font-medium">Log retention</label>
<output for="ex-retention" data-unit=" days" class="text-muted-foreground tabular-nums">30 days</output>
</div>
<input type="range" id="ex-retention" min="7" max="90" step="1" value="30" disabled data-slider class="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" style="--slider-fill: 27.710843373493976%">
</div>
</div><script type="module" src="/caseroom/r/html/slider.js"></script>