A precise range control with one or more thumbs and a live value readout.
pnpm dlx shadcn@latest add @latheui/sliderFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add radix-uiAdd the Lathe items this component imports.
hooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { Slider as Primitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
export type SliderProps = React.ComponentProps<typeof Primitive.Root> & {
label: string
formatValue?: (value: number) => string
/** Lets a single-thumb slider's readout be clicked and typed into. The entry is clamped to min/max and snapped to step. */
editable?: boolean
/** Glide to changes made by external controls; direct dragging can leave this off. */
animateValue?: boolean
}
/** Milliseconds the thumb takes to reach a click on the track. */
const GLIDE = 320
/** Milliseconds the thumb trails the pointer by while dragging: quick, but you can see it chase. */
const FOLLOW = 110
/**
* How the thumb reaches a new value:
* - glide: a click on the track, or a value set from outside (animateValue)
* - follow: a mouse drag, where the thumb chases the pointer on a short ease-out
* - direct: a touch drag, where the thumb stays under the finger. Touch moves arrive so often
* that a trailing transition keeps restarting and stutters.
*/
type Mode = "glide" | "follow" | "direct" | undefined
export function Slider({
label,
value: valueProp,
defaultValue = [50],
onValueChange,
formatValue = String,
editable = false,
animateValue = false,
className,
onPointerDown,
onPointerMove,
onPointerUp,
...props
}: SliderProps) {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
onChange: onValueChange,
})
const [pressMode, setPressMode] = React.useState<Mode>()
const press = React.useRef<{ x: number; y: number; touch: boolean } | null>(null)
const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
React.useEffect(() => () => clearTimeout(timer.current), [])
// A press on this slider wins; otherwise outside changes glide when asked to.
const mode: Mode = pressMode ?? (animateValue ? "glide" : undefined)
const settle = (after: number) => {
clearTimeout(timer.current)
timer.current = setTimeout(() => setPressMode(undefined), after)
}
return (
<div className={cn("space-y-3", className)}>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="min-w-0">{label}</span>
{editable && value.length === 1 ? (
<ValueInput
label={label}
value={value[0]}
min={props.min ?? 0}
max={props.max ?? 100}
step={props.step ?? 1}
disabled={props.disabled}
formatValue={formatValue}
onCommit={(next) => setValue([next])}
/>
) : (
<output className="min-w-[4ch] shrink-0 text-right whitespace-nowrap text-muted-foreground tabular-nums">
{value.map(formatValue).join(" – ")}
</output>
)}
</div>
<Primitive.Root
value={value}
onValueChange={setValue}
data-motion={mode}
className={cn(
"group/slider relative flex h-6 w-full touch-none items-center select-none data-disabled:opacity-50",
// Radix positions each thumb through a wrapper it renders, a direct child of the root.
// A CSS transition picks up from wherever the thumb is, so retargeting mid-flight stays smooth.
"data-[motion=glide]:[&>span]:transition-[left,bottom] data-[motion=glide]:[&>span]:duration-[320ms] data-[motion=glide]:[&>span]:ease-[cubic-bezier(0.65,0,0.35,1)]",
"data-[motion=follow]:[&>span]:transition-[left,bottom] data-[motion=follow]:[&>span]:duration-[110ms] data-[motion=follow]:[&>span]:ease-[cubic-bezier(0.2,0.8,0.3,1)]",
"motion-reduce:[&>span]:transition-none!"
)}
{...props}
onPointerDown={(event) => {
onPointerDown?.(event)
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
clearTimeout(timer.current)
const onThumb = (event.target as HTMLElement).closest('[role="slider"]')
const touch = event.pointerType === "touch"
press.current = { x: event.clientX, y: event.clientY, touch }
// A click on the track glides there; grabbing the thumb starts following at once.
setPressMode(onThumb ? (touch ? "direct" : "follow") : "glide")
}}
onPointerMove={(event) => {
onPointerMove?.(event)
// The press turned into a drag: chase the pointer from here.
if (
press.current &&
pressMode === "glide" &&
Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 3
)
setPressMode(press.current.touch ? "direct" : "follow")
}}
onPointerUp={(event) => {
onPointerUp?.(event)
if (!press.current) return
press.current = null
// Let the thumb finish arriving before dropping the transition.
settle(pressMode === "glide" ? GLIDE : pressMode === "follow" ? FOLLOW : 0)
}}
onLostPointerCapture={() => {
if (!press.current) return
press.current = null
settle(pressMode === "glide" ? GLIDE : pressMode === "follow" ? FOLLOW : 0)
}}
>
<Primitive.Track className="relative h-1.5 grow rounded-full bg-muted">
<Primitive.Range className="absolute h-full rounded-full bg-primary group-data-[motion=glide]/slider:transition-[left,right,top,bottom] group-data-[motion=follow]/slider:transition-[left,right,top,bottom] group-data-[motion=glide]/slider:duration-[320ms] group-data-[motion=glide]/slider:ease-[cubic-bezier(0.65,0,0.35,1)] group-data-[motion=follow]/slider:duration-[110ms] group-data-[motion=follow]/slider:ease-[cubic-bezier(0.2,0.8,0.3,1)] motion-reduce:transition-none!" />
</Primitive.Track>
{value.map((_, i) => (
<Primitive.Thumb
key={i}
aria-label={value.length > 1 ? label + " " + (i + 1) : label}
aria-valuetext={formatValue(value[i])}
className="block size-4 cursor-grab rounded-full border border-border bg-card shadow-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/30 active:cursor-grabbing"
/>
))}
</Primitive.Root>
</div>
)
}
/** Shows the formatted value; on focus it shows the bare number for editing, committed on Enter or blur. */
function ValueInput({
label,
value,
min,
max,
step,
disabled,
formatValue,
onCommit,
}: {
label: string
value: number
min: number
max: number
step: number
disabled?: boolean
formatValue: (value: number) => string
onCommit: (value: number) => void
}) {
const [draft, setDraft] = React.useState<string>()
const decimals = (String(step).split(".")[1] ?? "").length
function commit() {
if (draft === undefined) return
const parsed = Number.parseFloat(draft)
setDraft(undefined)
if (Number.isNaN(parsed)) return
const snapped = Math.round((Math.min(max, Math.max(min, parsed)) - min) / step) * step + min
onCommit(Number(snapped.toFixed(decimals)))
}
return (
<input
type="text"
inputMode="decimal"
aria-label={label + " value"}
disabled={disabled}
value={draft ?? formatValue(value)}
onFocus={(event) => {
setDraft(String(value))
// Select once React has swapped in the bare number.
const input = event.currentTarget
requestAnimationFrame(() => input.select())
}}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur()
if (event.key === "Escape") {
// Drop the draft first so the blur that follows has nothing to commit.
setDraft(undefined)
const input = event.currentTarget
requestAnimationFrame(() => input.blur())
}
}}
className="-mr-1.5 w-24 cursor-text rounded-md border border-transparent bg-transparent px-1.5 py-0.5 text-right text-muted-foreground tabular-nums outline-none transition-colors hover:border-border focus:border-border focus:bg-background focus:text-foreground focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none"
/>
)
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| label* (required) | string | — | Visible and accessible name. |
| value / defaultValue | number[] | [50] | One thumb per value; two values create a range. |
| onValueChange / onValueCommit | (value: number[]) => void | — | Live changes and committed changes. |
| min / max / step | number | — | Range and precision. Defaults to 0, 100, 1. |
| formatValue | (value: number) => string | — | Visible and spoken value formatting. |
| editable | boolean | false | Single thumb only: the readout becomes a field you can type a value into. |
| animateValue | boolean | false | Glide to values set from outside, such as a linked control or a timeline clip. A drag on this slider still follows the pointer. |
Combine the control with your own layout. Semantic colors follow the active theme.
import { Slider } from "@/components/ui/slider"
export default function Demo(){return <div className="w-full max-w-sm space-y-8"><Slider label="Volume" defaultValue={[65]} formatValue={v=>v+"%"}/><Slider label="Price range" defaultValue={[20,80]} formatValue={v=>"$"+v}/></div>}