Range Slider
A dual-thumb range slider with a live histogram, number fields and full keyboard control.
npx shadcn@latest add https://sundaeui.dev/r/range-slider.json'use client'
import { useId, useMemo, useRef } from 'react'
export interface RangeSliderProps {
min: number
max: number
step?: number
value: [number, number]
onChange: (v: [number, number]) => void
label: string
/** Distribution shown above the track; bars inside the range light up. */
histogram?: number[]
format?: (n: number) => string
/** Minimum distance between thumbs. */
minGap?: number
}
/** Dual-thumb range slider with a live histogram and editable number fields. */
export function RangeSlider({ min, max, step = 1, value, onChange, label, histogram, format = String, minGap = step }: RangeSliderProps) {
const id = useId()
const track = useRef<HTMLDivElement>(null)
const [lo, hi] = value
const pct = (v: number) => ((v - min) / (max - min)) * 100
const snap = (v: number) => Math.min(max, Math.max(min, Math.round((v - min) / step) * step + min))
const peak = useMemo(() => Math.max(1, ...(histogram ?? [1])), [histogram])
const set = (which: 0 | 1, v: number) => {
const s = snap(v)
onChange(which === 0 ? [Math.min(s, hi - minGap), hi] : [lo, Math.max(s, lo + minGap)])
}
const fromPointer = (clientX: number) => {
const r = track.current!.getBoundingClientRect()
return min + ((clientX - r.left) / r.width) * (max - min)
}
const startDrag = (which: 0 | 1) => (e: React.PointerEvent) => {
e.preventDefault()
;(e.currentTarget as HTMLElement).focus()
const move = (ev: PointerEvent) => set(which, fromPointer(ev.clientX))
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}
const key = (which: 0 | 1) => (e: React.KeyboardEvent) => {
const big = (max - min) / 10
const cur = value[which]
const map: Record<string, number> = { ArrowLeft: cur - step, ArrowDown: cur - step, ArrowRight: cur + step, ArrowUp: cur + step, PageDown: cur - big, PageUp: cur + big, Home: min, End: max }
if (e.key in map) {
e.preventDefault()
set(which, map[e.key])
}
}
const thumb = (which: 0 | 1) => (
<span
role="slider"
tabIndex={0}
aria-label={which === 0 ? `Minimum ${label.toLowerCase()}` : `Maximum ${label.toLowerCase()}`}
aria-valuemin={which === 0 ? min : lo + minGap}
aria-valuemax={which === 0 ? hi - minGap : max}
aria-valuenow={value[which]}
aria-valuetext={format(value[which])}
onPointerDown={startDrag(which)}
onKeyDown={key(which)}
style={{ left: `${pct(value[which])}%` }}
className="absolute top-1/2 z-10 size-5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border border-zinc-300 bg-white shadow-[0_2px_6px_rgb(0_0_0/0.15)] outline-none transition-[box-shadow,transform] hover:scale-110 focus-visible:ring-4 focus-visible:ring-zinc-900/15 active:scale-110 active:cursor-grabbing dark:border-zinc-600 dark:focus-visible:ring-white/20"
/>
)
const numberField = (which: 0 | 1, text: string) => (
<label className="flex-1">
<span className="mb-1 block text-[11.5px] text-zinc-500">{text}</span>
<input
inputMode="decimal"
value={value[which]}
onChange={(e) => {
const n = Number(e.target.value.replace(/[^\d.-]/g, ''))
if (!Number.isNaN(n)) onChange(which === 0 ? [n, hi] : [lo, n])
}}
onBlur={(e) => set(which, Number(e.target.value) || (which ? max : min))}
className="h-9 w-full rounded-lg border border-zinc-200 bg-transparent px-3 text-[13.5px] tabular-nums outline-none focus:border-zinc-900 dark:border-zinc-800 dark:focus:border-zinc-300"
/>
</label>
)
return (
<div className="w-full max-w-md text-zinc-900 dark:text-zinc-100" role="group" aria-labelledby={`${id}-l`}>
<div className="mb-4 flex items-baseline justify-between">
<span id={`${id}-l`} className="text-[14px] font-semibold">
{label}
</span>
<span className="text-[13px] tabular-nums text-zinc-500" aria-live="polite">
{format(lo)} – {format(hi)}
</span>
</div>
{histogram && (
<div aria-hidden className="flex h-16 items-end gap-[2px] px-2.5">
{histogram.map((h, i) => {
const center = min + ((i + 0.5) / histogram.length) * (max - min)
const inside = center >= lo && center <= hi
return <span key={i} className={`flex-1 rounded-t-[2px] transition-colors duration-200 ${inside ? 'bg-zinc-900 dark:bg-zinc-100' : 'bg-zinc-200 dark:bg-zinc-800'}`} style={{ height: `${Math.max(4, (h / peak) * 100)}%` }} />
})}
</div>
)}
<div className="relative h-8 px-2.5">
<div
ref={track}
className="absolute inset-x-2.5 top-1/2 h-1 -translate-y-1/2 cursor-pointer rounded-full bg-zinc-200 dark:bg-zinc-800"
onPointerDown={(e) => {
const v = fromPointer(e.clientX)
const which = Math.abs(v - lo) <= Math.abs(v - hi) ? 0 : 1
set(which, v)
startDrag(which)(e)
}}
>
<span className="absolute inset-y-0 rounded-full bg-zinc-900 dark:bg-zinc-100" style={{ left: `${pct(lo)}%`, right: `${100 - pct(hi)}%` }} />
{thumb(0)}
{thumb(1)}
</div>
</div>
<div className="mt-3 flex items-end gap-3">
{numberField(0, 'Minimum')}
<span className="pb-2 text-zinc-400">–</span>
{numberField(1, 'Maximum')}
</div>
</div>
)
}Overview
For filtering by price, size or time, where people need to see the shape of the data. A histogram above the track shows the distribution, and bars inside the selected range fill in as the thumbs move, so it is obvious where most results are.
Drag either thumb, or click the track and the nearest thumb jumps there. Thumbs keep a minimum gap and snap to the step. Number fields below accept exact values and clamp on blur. Arrow keys move by step, Page Up and Page Down by a tenth, and Home and End to the bounds.
Use it for marketplace and search filters. For a single value, use a native range input.
When to use
- Collecting input where the control itself should guide people.
When not to use
- Cases a plain native input already serves well.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/range-slider.jsonnpx shadcn@latest add https://sundaeui.dev/r/range-slider.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/range-slider.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/range-slider.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy range-slider.tsx into components/sundae/.
- Make sure Tailwind scans that folder (v4 detects it automatically; v3 needs it in `content`).
- Import the component and use it as shown below.
Usage
import { RangeSlider } from '@/components/sundae/range-slider'
const [price, setPrice] = useState<[number, number]>([80, 320])
<RangeSlider label="Nightly price" min={0} max={500} step={5} value={price} onChange={setPrice} histogram={buckets} format={(n) => '$' + n} />Props
| Prop | Type | Default |
|---|---|---|
min / max / step*Bounds and increment (step defaults to 1). | number | — |
value / onChange*Controlled range. | [number, number] | — |
label*Group label. Thumbs are named Minimum and Maximum. | string | — |
histogramEvenly spaced bucket counts. | number[] | — |
formatDisplay and aria-valuetext formatter. | (n) => string | — |
minGapMinimum distance between thumbs. | number | step |
Accessibility
- Each thumb is a role="slider" with min, max, now and valuetext. The bounds update as the other thumb moves.
- Arrows, Page Up/Down, Home and End are supported. The current range is announced politely.
- Number fields give a precise, non-drag alternative.
Customization
- Pass format={(n) => …} for currency, units or dates.
- Swap the zinc classes for the fill and bars.
FAQ
- Is Range Slider free?
- Yes. Range Slider is free for personal and commercial projects, with full source on this page.
- How do I install Range Slider?
- Run npx shadcn@latest add https://sundaeui.dev/r/range-slider.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Range Slider support keyboard and screen readers?
- Each thumb is a role="slider" with min, max, now and valuetext. The bounds update as the other thumb moves. Arrows, Page Up/Down, Home and End are supported. The current range is announced politely.
- Does Range Slider respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All forms →Date Picker
Forms · UnderstatedCombobox
Forms · UnderstatedOTP Field
Forms · Refined- Pro
Onboarding Flow
Forms · Refined Volume Knob
Forms · FuturisticTag Field
Forms · Understated