Snap Slider
A slider that snaps to labelled stops with tick marks; the thumb springs into each notch and the value label follows.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/snap-slider.json'use client'
import { useRef, useState } from 'react'
import './snap-slider.css'
export interface SnapStop { value: string; label: string }
export interface SnapSliderProps {
stops: SnapStop[]
value?: string
defaultValue?: string
onValueChange?: (v: string) => void
label?: string
fill?: 'dark' | 'light'
className?: string
}
/** A slider that springs into labelled stops. */
export function SnapSlider({ stops, value, defaultValue, onValueChange, label = 'Value', fill = 'dark', className }: SnapSliderProps) {
const [inner, setInner] = useState(defaultValue ?? stops[0]?.value)
const v = value ?? inner
const idx = Math.max(0, stops.findIndex((s) => s.value === v))
const [drag, setDrag] = useState<number | null>(null)
const track = useRef<HTMLDivElement>(null)
const n = stops.length - 1
const pct = (drag ?? idx / n) * 100
const at = (cx: number) => { const r = track.current!.getBoundingClientRect(); return Math.min(1, Math.max(0, (cx - r.left) / r.width)) }
const set = (i: number) => { const j = Math.min(n, Math.max(0, i)); setInner(stops[j].value); onValueChange?.(stops[j].value) }
const near = drag !== null ? Math.round(drag * n) : idx
return (
<div className={['sd-sns', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-sns__top"><span className="sd-sns__cap">{label}</span><span key={near} className="sd-sns__val">{stops[near].label}</span></div>
<div
ref={track}
className="sd-sns__track"
data-drag={drag !== null || undefined}
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); setDrag(at(e.clientX)) }}
onPointerMove={(e) => drag !== null && setDrag(at(e.clientX))}
onPointerUp={() => { if (drag !== null) set(Math.round(drag * n)); setDrag(null) }}
>
<span className="sd-sns__rail" />
<span className="sd-sns__fill" style={{ width: `${pct}%` }} />
{stops.map((s, i) => <span key={s.value} className="sd-sns__tick" style={{ left: `${(i / n) * 100}%` }} />)}
<span
className="sd-sns__thumb"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={n}
aria-valuenow={idx}
aria-valuetext={stops[idx].label}
style={{ left: `${pct}%` }}
onKeyDown={(e) => {
const m: Record<string, number> = { ArrowRight: idx + 1, ArrowUp: idx + 1, ArrowLeft: idx - 1, ArrowDown: idx - 1, Home: 0, End: n }
if (e.key in m) { e.preventDefault(); set(m[e.key]) }
}}
/>
</div>
<div className="sd-sns__labs" aria-hidden>
{stops.map((s, i) => <span key={s.value} className="sd-sns__lab" data-on={i === near || undefined} style={{ left: `${(i / n) * 100}%` }}>{i === 0 || i === n || stops.length <= 4 ? s.label : '·'}</span>)}
</div>
</div>
)
}.sd-sns { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-sns[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-sns button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-sns :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-sns .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-sns { width: min(100%, 320px); padding: 18px 18px 14px; border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-sns__top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 18px; }
.sd-sns__cap { font-size: 12.5px; color: var(--mute); }
.sd-sns__val { font-size: 18px; font-weight: 650; animation: sd-sns-in 220ms ease; }
@keyframes sd-sns-in { from { opacity: 0; transform: translateY(4px); } }
.sd-sns__track { position: relative; height: 28px; margin: 0 10px; touch-action: none; cursor: pointer; }
.sd-sns__rail { position: absolute; left: 0; right: 0; top: 12px; height: 4px; border-radius: 4px; background: var(--soft); }
.sd-sns__fill { position: absolute; left: 0; top: 12px; height: 4px; border-radius: 4px; background: var(--fg); }
.sd-sns__tick { position: absolute; top: 10px; width: 2px; height: 8px; margin-left: -1px; border-radius: 2px; background: var(--mute); opacity: 0.5; }
.sd-sns__thumb { position: absolute; top: 2px; width: 24px; height: 24px; margin-left: -12px; border-radius: 99px; background: var(--strong); box-shadow: 0 0 0 4px var(--bg), 0 2px 6px rgb(0 0 0 / 0.3); outline: 0; transition: transform 150ms; }
.sd-sns__thumb:focus-visible { box-shadow: 0 0 0 4px var(--bg), 0 0 0 6px var(--fg); }
.sd-sns__track[data-drag] .sd-sns__thumb { transform: scale(1.12); }
.sd-sns__track:not([data-drag]) .sd-sns__thumb, .sd-sns__track:not([data-drag]) .sd-sns__fill { transition: left 340ms cubic-bezier(0.3, 1.5, 0.4, 1), width 340ms cubic-bezier(0.3, 1.5, 0.4, 1), transform 150ms; }
.sd-sns__labs { position: relative; height: 18px; margin: 8px 10px 0; }
.sd-sns__lab { position: absolute; transform: translateX(-50%); font-size: 11.5px; color: var(--mute); white-space: nowrap; transition: color 200ms; }
.sd-sns__lab:first-child { transform: none; margin-left: -6px; }
.sd-sns__lab:last-child { transform: translateX(-100%); margin-left: 6px; }
.sd-sns__lab[data-on] { color: var(--fg); font-weight: 600; }
@media (prefers-reduced-motion: reduce) { .sd-sns *, .sd-sns *::before, .sd-sns *::after { animation: none !important; transition: none !important; } }Overview
Drag freely, release and the thumb springs into the nearest stop. Each stop has a tick and a short label; the active label goes bold.
Good for discrete choices that still feel like a range: team size, speed, quality, plan tiers.
When to use
- Picking from ordered, discrete options where position carries meaning.
When not to use
- Continuous values: use a range slider.
- Unordered options: use radio cards.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/snap-slider.json$
npx shadcn@latest add https://sundaeui.dev/r/snap-slider.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/snap-slider.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/snap-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 snap-slider.tsx and snap-slider.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SnapSlider } from '@/components/sundae/snap-slider'
<SnapSlider />Props
| Prop | Type | Default |
|---|---|---|
stops*Stops in order. | { value: string; label: string }[] | — |
value / defaultValueSelected stop. | string | — |
onValueChangeCalled on change. | (v: string) => void | — |
labelAccessible name. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A native-like slider: role="slider" with aria-valuetext set to the stop label.
- Arrow keys move one stop; Home/End jump to the ends.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-sns; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Previous / next stop
- Home / End
- First / last stop
FAQ
- Is Snap Slider free?
- Yes. Snap Slider is free for personal and commercial projects, with full source on this page.
- How do I install Snap Slider?
- Run npx shadcn@latest add https://sundaeui.dev/r/snap-slider.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Snap Slider support keyboard and screen readers?
- A native-like slider: role="slider" with aria-valuetext set to the stop label. Arrow keys move one stop; Home/End jump to the ends.
- Does Snap Slider respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Range Slider
Forms · UnderstatedVolume Knob
Forms · FuturisticOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined