Slider
An input for selecting a value or range of values along a track.
npx shadcn@latest add @velaui/sliderpnpm dlx velaui add @velaui/slideryarn dlx shadcn@latest add @velaui/sliderbunx --bun shadcn@latest add @velaui/sliderveni add sliderInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/sliderpnpm dlx velaui add @velaui/slideryarn dlx shadcn@latest add @velaui/sliderbunx --bun shadcn@latest add @velaui/sliderExamples
- Default
- Price range
- Ruler
- Full volume
- Disabled
- Orientation
Usage
Props
| Prop | Type | Default |
|---|---|---|
max | number | 100 |
min | number | 0 |
orientation | "horizontal" | "vertical" | "horizontal" |
showLabel | boolean | false |
showRuler | boolean | false |
step | number | 1 |
variant | "accent" | "primary" | "primary" |
Takes these and every prop of SliderProps.
| Prop | Type | Default |
|---|---|---|
max | number | 100 |
min | number | 0 |
orientation | import("/app/node_modules/.bun/@base-ui+react@1.6.0+df7f6a806acd27ca/node_modules/@base-ui/react/index").Orientation | "horizontal" |
showLabel | boolean | false |
showRuler | boolean | false |
step | number | 1 |
variant | "accent" | "primary" | "primary" |
Takes these and every prop of SliderProps.
Dependencies
- npm
class-variance-authority@0.7.1radix-ui@1.6.0- Installs with it
- Tooltip
- npm
@base-ui/react@1.6.0class-variance-authority@0.7.1- Installs with it
- Tooltip
Source
components/ui/slider.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const sliderRangeVariants = cva(
"absolute rounded-full select-none data-horizontal:h-full data-vertical:w-full",
{
variants: {
variant: {
primary:
"bg-primary-default group-data-disabled/slider:bg-primary-disabled",
accent:
"bg-accent-default group-data-disabled/slider:bg-accent-disabled",
},
},
defaultVariants: {
variant: "primary",
},
},
);
const sliderThumbVariants = cva(
// Hover and press are drawn as a halo OUTSIDE the 16px handle, so they are a
// ring, not a border — the handle measures the same in every state. The
// value tooltip is portalled by Tooltip, so it never reflows the track.
"relative block size-4 shrink-0 rounded-full ring-0 outline-none transition-[background-color,box-shadow] select-none after:absolute after:-inset-2 focus-visible:ring-3 group-data-disabled/slider:pointer-events-none",
{
variants: {
variant: {
primary:
"bg-primary-default hover:ring-2 hover:ring-bg-surface-1 focus-visible:ring-ring-neutral-shadow active:bg-primary-hover active:ring-0 group-data-disabled/slider:bg-primary-disabled",
accent:
"bg-accent-default hover:bg-accent-hover hover:ring-2 hover:ring-teal-100 focus-visible:ring-ring-accent-shadow active:bg-accent-default active:ring-0 group-data-disabled/slider:bg-accent-disabled",
},
},
defaultVariants: {
variant: "primary",
},
},
);
export type SliderProps = React.ComponentProps<typeof SliderPrimitive.Root> &
VariantProps<typeof sliderThumbVariants> & {
/** Render the selected range's low/high values under the track. */
showLabel?: boolean;
/** Render the tick ruler above the track. */
showRuler?: boolean;
};
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
step = 1,
orientation = "horizontal",
variant = "primary",
showLabel = false,
showRuler = false,
onValueChange,
...props
}: SliderProps) {
// The thumb tooltips read the live value, so an uncontrolled slider has to
// mirror it in state — defaultValue alone never updates as the user drags.
const [uncontrolledValues, setUncontrolledValues] = React.useState<number[]>(
() => (Array.isArray(defaultValue) ? defaultValue : [min, max]),
);
const _values = Array.isArray(value) ? value : uncontrolledValues;
const handleValueChange = React.useCallback(
(next: number[]) => {
if (!Array.isArray(value)) setUncontrolledValues(next);
onValueChange?.(next);
},
[value, onValueChange],
);
// A thumb's tooltip is open while it is hovered, keyboard-focused, or
// dragged. Radix closes an uncontrolled tooltip on pointer-down, which is
// exactly when the value starts changing — hence the controlled state.
const [hoveredThumb, setHoveredThumb] = React.useState<number | null>(null);
const [draggedThumb, setDraggedThumb] = React.useState<number | null>(null);
const [focusedThumb, setFocusedThumb] = React.useState<number | null>(null);
React.useEffect(() => {
if (draggedThumb === null) return;
const stopDragging = () => setDraggedThumb(null);
window.addEventListener("pointerup", stopDragging);
window.addEventListener("pointercancel", stopDragging);
return () => {
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [draggedThumb]);
// One ruler mark per step, alternating a 4px major and a 1px minor — so the
// ticks a user can actually land on are the ticks that get drawn.
const rulerTicks = React.useMemo(
() =>
Array.from(
{ length: Math.floor((max - min) / step) + 1 },
(_, index) => index,
),
[min, max, step],
);
const low = _values.length > 1 ? _values[0] : min;
const high = _values[_values.length - 1];
return (
<SliderPrimitive.Root
data-slot="slider"
data-variant={variant}
defaultValue={defaultValue}
value={value}
min={min}
max={max}
step={step}
orientation={orientation}
onValueChange={handleValueChange}
className={cn(
"group/slider relative flex w-full cursor-pointer touch-none flex-col items-start select-none data-disabled:cursor-not-allowed data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-row data-vertical:gap-2",
className,
)}
{...props}
>
<div
data-slot="slider-container"
// Radix only stamps data-orientation on its own parts, so every plain
// element here keys off the root's group instead of its own attribute.
className="relative flex h-6 w-full items-center group-data-vertical/slider:h-full group-data-vertical/slider:w-6 group-data-vertical/slider:flex-col"
>
{showRuler && (
<div
aria-hidden
data-slot="slider-ruler"
className="pointer-events-none absolute inset-x-0 top-0 flex items-end justify-between px-2 group-data-vertical/slider:inset-x-auto group-data-vertical/slider:inset-y-0 group-data-vertical/slider:left-0 group-data-vertical/slider:flex-col group-data-vertical/slider:px-0 group-data-vertical/slider:py-2"
>
{rulerTicks.map((tick) => (
<span
key={tick}
className={cn(
"w-px bg-border-default",
tick % 2 === 0
? "h-1 group-data-vertical/slider:h-px group-data-vertical/slider:w-1"
: "h-px",
)}
/>
))}
</div>
)}
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-bg-surface-1 data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(sliderRangeVariants({ variant }))}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<TooltipProvider key={index} delayDuration={0}>
<Tooltip
open={
hoveredThumb === index ||
focusedThumb === index ||
draggedThumb === index
}
>
<TooltipTrigger asChild>
<SliderPrimitive.Thumb
data-slot="slider-thumb"
className={cn(sliderThumbVariants({ variant }))}
onPointerEnter={() => setHoveredThumb(index)}
onPointerLeave={() => setHoveredThumb(null)}
onPointerDown={() => setDraggedThumb(index)}
// Pointer-down focuses the thumb too, so match :focus-visible
// to keep the tooltip from sticking around after a drag.
onFocus={(event) => {
if (event.currentTarget.matches(":focus-visible"))
setFocusedThumb(index);
}}
onBlur={() => setFocusedThumb(null)}
/>
</TooltipTrigger>
<TooltipContent
data-slot="slider-thumb-tooltip"
side={orientation === "vertical" ? "right" : "top"}
// The thumb owns the pointer during a drag; the tooltip must
// never intercept it or swallow the slider's hit area.
onPointerDownOutside={(event) => event.preventDefault()}
className="pointer-events-none items-center justify-center"
>
{_values[index]}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
{showLabel && (
<div
data-slot="slider-label-range"
className="pointer-events-none flex w-full items-center justify-between text-xs leading-[1.3] font-normal whitespace-nowrap text-text-strong group-data-vertical/slider:h-full group-data-vertical/slider:w-auto group-data-vertical/slider:flex-col-reverse group-data-vertical/slider:items-start"
>
<span>{low}</span>
<span className="text-right">{high}</span>
</div>
)}
</SliderPrimitive.Root>
);
}
export { Slider, sliderRangeVariants, sliderThumbVariants };components/ui/slider.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const sliderRangeVariants = cva(
"absolute rounded-full select-none data-horizontal:h-full data-vertical:w-full",
{
variants: {
variant: {
primary:
"bg-primary-default group-data-disabled/slider:bg-primary-disabled",
accent:
"bg-accent-default group-data-disabled/slider:bg-accent-disabled",
},
},
defaultVariants: {
variant: "primary",
},
},
);
const sliderThumbVariants = cva(
// Hover and press are drawn as a halo OUTSIDE the 16px handle, so they are a
// ring, not a border — the handle measures the same in every state. The
// value tooltip is portalled by Tooltip, so it never reflows the track.
// Base UI routes focus (and :focus-visible) to a hidden range input inside
// the thumb, so the focus rings have a has-… twin that matches the input.
"relative block size-4 shrink-0 rounded-full ring-0 outline-none transition-[background-color,box-shadow] select-none after:absolute after:-inset-2 focus-visible:ring-3 has-[[type=range]:focus-visible]:ring-3 group-data-disabled/slider:pointer-events-none",
{
variants: {
variant: {
primary:
"bg-primary-default hover:ring-2 hover:ring-bg-surface-1 focus-visible:ring-ring-neutral-shadow has-[[type=range]:focus-visible]:ring-ring-neutral-shadow active:bg-primary-hover active:ring-0 group-data-disabled/slider:bg-primary-disabled",
accent:
"bg-accent-default hover:bg-accent-hover hover:ring-2 hover:ring-teal-100 focus-visible:ring-ring-accent-shadow has-[[type=range]:focus-visible]:ring-ring-accent-shadow active:bg-accent-default active:ring-0 group-data-disabled/slider:bg-accent-disabled",
},
},
defaultVariants: {
variant: "primary",
},
},
);
export type SliderProps = SliderPrimitive.Root.Props &
VariantProps<typeof sliderThumbVariants> & {
/** Render the selected range's low/high values under the track. */
showLabel?: boolean;
/** Render the tick ruler above the track. */
showRuler?: boolean;
};
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
step = 1,
orientation = "horizontal",
variant = "primary",
showLabel = false,
showRuler = false,
onValueChange,
...props
}: SliderProps) {
// The thumb tooltips read the live value, so an uncontrolled slider has to
// mirror it in state — defaultValue alone never updates as the user drags.
const [uncontrolledValues, setUncontrolledValues] = React.useState<number[]>(
() =>
Array.isArray(defaultValue)
? [...defaultValue]
: defaultValue !== undefined
? [defaultValue]
: [min, max],
);
const _values = Array.isArray(value) ? value : uncontrolledValues;
const handleValueChange = React.useCallback(
(
next: number | readonly number[],
details: SliderPrimitive.Root.ChangeEventDetails,
) => {
if (!Array.isArray(value)) {
setUncontrolledValues(Array.isArray(next) ? [...next] : [next]);
}
onValueChange?.(next, details);
},
[value, onValueChange],
);
// A thumb's tooltip is open while it is hovered, keyboard-focused, or
// dragged. Leaving the tooltip fully controlled is what keeps it open
// through the pointer-down that starts a drag — exactly when the value
// begins to change.
const [hoveredThumb, setHoveredThumb] = React.useState<number | null>(null);
const [draggedThumb, setDraggedThumb] = React.useState<number | null>(null);
const [focusedThumb, setFocusedThumb] = React.useState<number | null>(null);
React.useEffect(() => {
if (draggedThumb === null) return;
const stopDragging = () => setDraggedThumb(null);
window.addEventListener("pointerup", stopDragging);
window.addEventListener("pointercancel", stopDragging);
return () => {
window.removeEventListener("pointerup", stopDragging);
window.removeEventListener("pointercancel", stopDragging);
};
}, [draggedThumb]);
// One ruler mark per step, alternating a 4px major and a 1px minor — so the
// ticks a user can actually land on are the ticks that get drawn.
const rulerTicks = React.useMemo(
() =>
Array.from(
{ length: Math.floor((max - min) / step) + 1 },
(_, index) => index,
),
[min, max, step],
);
const low = _values.length > 1 ? _values[0] : min;
const high = _values[_values.length - 1];
return (
<SliderPrimitive.Root
data-slot="slider"
data-variant={variant}
defaultValue={defaultValue}
value={value}
min={min}
max={max}
step={step}
orientation={orientation}
// Radix keeps the thumbs inside the track at min and max; Base UI centres them on the ends.
thumbAlignment="edge"
onValueChange={handleValueChange}
className={cn(
"group/slider relative flex w-full cursor-pointer touch-none flex-col items-start select-none data-disabled:cursor-not-allowed data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-row data-vertical:gap-2",
className,
)}
{...props}
>
<SliderPrimitive.Control
data-slot="slider-container"
// Base UI stamps data-orientation on every part, but the container and
// its plain children keep keying off the root's group — the same
// group-data-vertical pairing used by the ruler and range labels.
className="relative flex h-6 w-full items-center group-data-vertical/slider:h-full group-data-vertical/slider:w-6 group-data-vertical/slider:flex-col"
>
{showRuler && (
<div
aria-hidden
data-slot="slider-ruler"
className="pointer-events-none absolute inset-x-0 top-0 flex items-end justify-between px-2 group-data-vertical/slider:inset-x-auto group-data-vertical/slider:inset-y-0 group-data-vertical/slider:left-0 group-data-vertical/slider:flex-col group-data-vertical/slider:px-0 group-data-vertical/slider:py-2"
>
{rulerTicks.map((tick) => (
<span
key={tick}
className={cn(
"w-px bg-border-default",
tick % 2 === 0
? "h-1 group-data-vertical/slider:h-px group-data-vertical/slider:w-1"
: "h-px",
)}
/>
))}
</div>
)}
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-bg-surface-1 data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className={cn(sliderRangeVariants({ variant }))}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<TooltipProvider key={index} delay={0}>
<Tooltip
open={
hoveredThumb === index ||
focusedThumb === index ||
draggedThumb === index
}
>
<TooltipTrigger
render={
<SliderPrimitive.Thumb
data-slot="slider-thumb"
className={cn(sliderThumbVariants({ variant }))}
onPointerEnter={() => setHoveredThumb(index)}
onPointerLeave={() => setHoveredThumb(null)}
onPointerDown={() => setDraggedThumb(index)}
// Pointer-down focuses the thumb's hidden range input, so
// match :focus-visible on either the thumb or the input —
// the tooltip must not stick around after a drag.
onFocus={(event) => {
const target = event.target;
if (
event.currentTarget.matches(":focus-visible") ||
(target instanceof Element &&
target.matches(":focus-visible"))
) {
setFocusedThumb(index);
}
}}
onBlur={() => setFocusedThumb(null)}
/>
}
/>
<TooltipContent
data-slot="slider-thumb-tooltip"
side={orientation === "vertical" ? "right" : "top"}
// The thumb owns the pointer during a drag; the content is
// pointer-events-none, so it can never intercept it or
// swallow the slider's hit area.
className="pointer-events-none items-center justify-center"
>
{_values[index]}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</SliderPrimitive.Control>
{showLabel && (
<div
data-slot="slider-label-range"
className="pointer-events-none flex w-full items-center justify-between text-xs leading-[1.3] font-normal whitespace-nowrap text-text-strong group-data-vertical/slider:h-full group-data-vertical/slider:w-auto group-data-vertical/slider:flex-col-reverse group-data-vertical/slider:items-start"
>
<span>{low}</span>
<span className="text-right">{high}</span>
</div>
)}
</SliderPrimitive.Root>
);
}
export { Slider, sliderRangeVariants, sliderThumbVariants };