Slider
Pick a value or a range by dragging thumbs along a track.
Preview
Nightly rate
$80 – $240 · 193 stays
$0$400+
Output volume
Malik’s headphonesBitrate
128 kbps · locked on FreeEqualizer
Priya’s studio preset
60
230
910
3.6k
14k
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/slider.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number[] | — | Controlled; one thumb per entry |
| defaultValue | number[] | [min] | Uncontrolled; one thumb per entry |
| onValueChange | (value: number[]) => void | — | Every change |
| onValueCommit | (value: number[]) => void | — | At the end of a drag or key press |
| min / max / step | number | 0 / 100 / 1 | Range and increment |
| orientation | "horizontal" | "vertical" | "horizontal" | Vertical needs a parent height or uses min-h-44 |
| thumbLabels | string[] | — | Accessible name per thumb, in order |
| showValue | "interact" | "always" | — | Value bubble above each thumb: on hover, focus and drag, or always |
| formatValue | (value: number, index: number) => string | — | Formats the bubble text; also becomes the thumb's aria-valuetext |
| aria-label / aria-labelledby | string | — | Applied to the thumb when there is a single thumb |
| disabled | boolean | false | Not interactive, 50% opacity |
Build notes
Radix Slider primitive handles pointer capture, keyboard, RTL and value clamping. We render `Thumb`s from `value ?? defaultValue ?? [min]`, so the thumb count always matches the value array (shadcn defaults to `[min, max]`; we default to a single thumb). `aria-label` / `aria-labelledby` are pulled out of the root props and put on every thumb, because a label on the role-less root span names nothing. A range adds a position suffix to each thumb: with `aria-labelledby`, the thumb gets `aria-labelledby="<shared> <thumbId>"` and its own `aria-label` is the suffix; otherwise `aria-label` is `"<label> <suffix>"`. When both are given, `aria-labelledby` wins and the caller's `aria-label` is ignored, as ARIA naming does. An empty `thumbLabels[i]` counts as missing. Layout swaps on `data-orientation`: track `h-1.5 w-full` horizontally, `w-1.5 h-full` vertically; the range fills the cross axis. The root's size (`h-5`, or `h-full min-h-44 w-5` vertically) is chosen from the `orientation` prop as plain classes, not `data-[orientation]` variants, so a consumer `h-*` / `min-h-0` in `className` replaces it. Forced colors: track gets a `CanvasText` border, range is painted `Highlight`, thumb `Canvas` with a `CanvasText` edge. **Pebble (2026-11-03).** Track is a recessed groove (`bg-muted`, `border-control` edge, `inset-shadow-xs`, 6 px); range is `bg-primary`. Thumb is an 18 px raised pebble (`bg-card`, `shadow-raised-sm`, `inset-shadow-highlight`) that lifts to `raised-md` on hover and scales to 95% on press; in dark mode it becomes the paper key so it reads on ink. Added optional `showValue` (`"interact"` bubble on hover, focus-visible and drag; `"always"` pinned) and `formatValue`, which also feeds `aria-valuetext`. The uncontrolled value is mirrored in state through a wrapped `onValueChange` so the bubble follows it. The bubble is `aria-hidden`, sits above the thumb (beside it when vertical) and fades/scales with `duration-(--motion-base) ease-enter`; reduced motion keeps only the fade. <!-- PEBBLE-LICENSE -->
View source
registry/input/slider/slider.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: slider.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.
Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
export type SliderProps = React.ComponentProps<typeof SliderPrimitive.Root> & {
/** Accessible name for each thumb, in order. */
thumbLabels?: string[];
/**
* Show each thumb's value in a small bubble above it: `"interact"` on
* hover, focus and drag; `"always"` at all times. Omit for no bubble.
*/
showValue?: "interact" | "always";
/**
* Formats a thumb's value for the bubble. When given it also becomes the
* thumb's `aria-valuetext`, so screen readers hear "$240", not "240".
*/
formatValue?: (value: number, index: number) => string;
};
// Without thumbLabels, each range thumb gets a position suffix so no two share a name.
function thumbSuffix(index: number, count: number) {
if (count === 1) return undefined;
if (count === 2) return index === 0 ? "minimum" : "maximum";
return `${index + 1} of ${count}`;
}
export function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
orientation = "horizontal",
thumbLabels,
showValue,
formatValue,
onValueChange,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
...props
}: SliderProps) {
// Mirror the uncontrolled value so the bubble can show it; a controlled
// `value` always wins. One thumb per value, or one at min with neither.
const [inner, setInner] = React.useState(() => defaultValue ?? [min]);
const values = value ?? inner;
const baseId = React.useId();
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
orientation={orientation}
onValueChange={(next) => {
setInner(next);
onValueChange?.(next);
}}
className={cn(
"group/slider relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50",
// Plain (not data-variant) size classes, so tailwind-merge lets a
// consumer's h-* / min-h-* / w-* replace them instead of losing on specificity.
orientation === "vertical" ? "h-full min-h-44 w-5 flex-col" : "h-5",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
// A recessed groove: muted well, inner shade and the control edge.
className="relative grow overflow-hidden rounded-full border border-border-control bg-muted inset-shadow-xs data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5 forced-colors:border-[CanvasText]"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full forced-colors:bg-[Highlight]!"
/>
</SliderPrimitive.Track>
{values.map((current, index) => {
// An empty string counts as missing, so the thumb keeps its suffix.
const explicit = thumbLabels?.[index] || undefined;
const suffix = thumbSuffix(index, values.length);
const thumbId = `${baseId}-thumb-${index}`;
// aria-labelledby wins over aria-label, so a range points it at the shared
// label and then at the thumb itself, whose aria-label is the suffix.
const labelledBy =
explicit || !ariaLabelledBy
? undefined
: suffix
? `${ariaLabelledBy} ${thumbId}`
: ariaLabelledBy;
const label =
explicit ??
(ariaLabelledBy ? suffix : suffix && ariaLabel ? `${ariaLabel} ${suffix}` : ariaLabel);
const text = formatValue ? formatValue(current, index) : String(current);
return (
<SliderPrimitive.Thumb
key={index}
id={thumbId}
data-slot="slider-thumb"
aria-label={label}
aria-labelledby={labelledBy}
aria-valuetext={formatValue ? text : undefined}
className={cn(
"group/thumb relative block size-[18px] shrink-0 cursor-grab rounded-full active:cursor-grabbing",
// A raised pebble: card face, control edge, low shadow and a top
// light. In dark mode it becomes the paper key so it reads on ink.
"border border-border-control bg-card shadow-raised-sm inset-shadow-highlight dark:border-key-edge dark:bg-linear-to-b dark:from-key-top dark:to-key-bottom",
"transition-[box-shadow,scale] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
"hover:shadow-raised-md active:scale-95 motion-reduce:active:scale-100",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"data-[disabled]:pointer-events-none",
"forced-colors:border-[CanvasText] forced-colors:bg-[Canvas]!",
)}
>
{showValue && (
<span
aria-hidden
data-slot="slider-value"
className={cn(
"pointer-events-none absolute rounded-md bg-card px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-foreground tabular-nums shadow-raised-md ring-1 inset-shadow-highlight ring-hairline",
"bottom-full left-1/2 mb-2 origin-bottom -translate-x-1/2",
"group-data-[orientation=vertical]/thumb:start-full group-data-[orientation=vertical]/thumb:top-1/2 group-data-[orientation=vertical]/thumb:bottom-auto group-data-[orientation=vertical]/thumb:left-auto group-data-[orientation=vertical]/thumb:ms-2.5 group-data-[orientation=vertical]/thumb:mb-0 group-data-[orientation=vertical]/thumb:origin-left group-data-[orientation=vertical]/thumb:translate-x-0 group-data-[orientation=vertical]/thumb:-translate-y-1/2",
showValue === "interact" &&
"scale-[0.96] opacity-0 transition-[opacity,scale] duration-(--motion-base) ease-enter group-hover/thumb:scale-100 group-hover/thumb:opacity-100 group-focus-visible/thumb:scale-100 group-focus-visible/thumb:opacity-100 group-active/slider:scale-100 group-active/slider:opacity-100 motion-reduce:scale-100 motion-reduce:transition-opacity",
)}
>
{text}
</span>
)}
</SliderPrimitive.Thumb>
);
})}
</SliderPrimitive.Root>
);
}
