Slider
Continuous value selector on bits-ui Slider. The thumb springs under press with a snappy preset and collapses to an instant snap under reduced-motion — the track fills as a range between thumbs.
Install
import { Slider } from '$lib/components/ui/slider'; Usage
<script lang="ts">
import { Slider } from '$lib/components/ui/slider';
let value = $state(48);
</script>
<Slider type="single" bind:value min={0} max={100} step={1} />Range
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { pressScale } from '$lib/motion';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
class: className,
thumbLabels,
showSteps = false,
tooltip = false,
formatValue = String,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> & {
/** Accessible name(s) for slider thumb(s). Single-thumb: provide one entry; range: one per thumb. */
thumbLabels?: string[];
/** Render dot indicators at every step position along the track. */
showSteps?: boolean;
/** Render `formatValue(value)` above each thumb on hover / focus-visible / drag. */
tooltip?: boolean;
/** Format a numeric thumb value for the tooltip label. */
formatValue?: (v: number) => string;
} = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)}
{...restProps}
>
{#snippet children({ thumbItems, tickItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'relative grow overflow-hidden rounded-full bg-foreground/10 data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2'
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
'absolute bg-primary select-none data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
)}
/>
</span>
{#if showSteps}
{#each tickItems as tick (tick.index)}
<SliderPrimitive.Tick
data-slot="slider-tick"
index={tick.index}
class={cn(
'block size-1 rounded-full bg-foreground/40 transition-colors duration-(--motion-duration-fast) data-[bounded]:bg-primary-foreground data-[disabled]:opacity-50'
)}
/>
{/each}
{/if}
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb data-slot="slider-thumb" index={thumb.index}>
{#snippet child({ props })}
<span
{...props}
aria-label={thumbLabels?.[thumb.index]}
use:pressScale={{ scale: 0.88, preset: 'snappy' }}
class={'group relative block size-5 shrink-0 rounded-full border-2 border-primary bg-card shadow-surface-3 ring-ring/50 transition-colors select-none after:absolute after:-inset-3 after:content-[""] hover:ring-(length:--ring-width) focus-visible:ring-(length:--ring-width) focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:bg-surface-5'}
>
{#if tooltip}
<span
data-slot="slider-tooltip"
aria-hidden="true"
class={cn(
'pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 rounded-md bg-popover px-2 py-1 font-mono text-xs whitespace-nowrap text-popover-foreground opacity-0 shadow-surface-7 transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-out) group-hover:opacity-100 group-focus-visible:opacity-100 group-data-[active]:opacity-100'
)}
>
{formatValue(thumb.value)}
</span>
{/if}
</span>
{/snippet}
</SliderPrimitive.Thumb>
{/each}
{/snippet}
</SliderPrimitive.Root>
Vertical
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { pressScale } from '$lib/motion';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
class: className,
thumbLabels,
showSteps = false,
tooltip = false,
formatValue = String,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> & {
/** Accessible name(s) for slider thumb(s). Single-thumb: provide one entry; range: one per thumb. */
thumbLabels?: string[];
/** Render dot indicators at every step position along the track. */
showSteps?: boolean;
/** Render `formatValue(value)` above each thumb on hover / focus-visible / drag. */
tooltip?: boolean;
/** Format a numeric thumb value for the tooltip label. */
formatValue?: (v: number) => string;
} = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)}
{...restProps}
>
{#snippet children({ thumbItems, tickItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'relative grow overflow-hidden rounded-full bg-foreground/10 data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2'
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
'absolute bg-primary select-none data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
)}
/>
</span>
{#if showSteps}
{#each tickItems as tick (tick.index)}
<SliderPrimitive.Tick
data-slot="slider-tick"
index={tick.index}
class={cn(
'block size-1 rounded-full bg-foreground/40 transition-colors duration-(--motion-duration-fast) data-[bounded]:bg-primary-foreground data-[disabled]:opacity-50'
)}
/>
{/each}
{/if}
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb data-slot="slider-thumb" index={thumb.index}>
{#snippet child({ props })}
<span
{...props}
aria-label={thumbLabels?.[thumb.index]}
use:pressScale={{ scale: 0.88, preset: 'snappy' }}
class={'group relative block size-5 shrink-0 rounded-full border-2 border-primary bg-card shadow-surface-3 ring-ring/50 transition-colors select-none after:absolute after:-inset-3 after:content-[""] hover:ring-(length:--ring-width) focus-visible:ring-(length:--ring-width) focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:bg-surface-5'}
>
{#if tooltip}
<span
data-slot="slider-tooltip"
aria-hidden="true"
class={cn(
'pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 rounded-md bg-popover px-2 py-1 font-mono text-xs whitespace-nowrap text-popover-foreground opacity-0 shadow-surface-7 transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-out) group-hover:opacity-100 group-focus-visible:opacity-100 group-data-[active]:opacity-100'
)}
>
{formatValue(thumb.value)}
</span>
{/if}
</span>
{/snippet}
</SliderPrimitive.Thumb>
{/each}
{/snippet}
</SliderPrimitive.Root>
Steps
showSteps renders a dot at every step position. Bounded
dots (left of the thumb) carry data-bounded for contrast.
<Slider type="single" bind:value min={0} max={10} step={1} showSteps />Value Display
Label on the left, formatted value on the right — the bloom-nx idiom for the label-plus-readout composition.
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { pressScale } from '$lib/motion';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
class: className,
thumbLabels,
showSteps = false,
tooltip = false,
formatValue = String,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> & {
/** Accessible name(s) for slider thumb(s). Single-thumb: provide one entry; range: one per thumb. */
thumbLabels?: string[];
/** Render dot indicators at every step position along the track. */
showSteps?: boolean;
/** Render `formatValue(value)` above each thumb on hover / focus-visible / drag. */
tooltip?: boolean;
/** Format a numeric thumb value for the tooltip label. */
formatValue?: (v: number) => string;
} = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)}
{...restProps}
>
{#snippet children({ thumbItems, tickItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'relative grow overflow-hidden rounded-full bg-foreground/10 data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2'
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
'absolute bg-primary select-none data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
)}
/>
</span>
{#if showSteps}
{#each tickItems as tick (tick.index)}
<SliderPrimitive.Tick
data-slot="slider-tick"
index={tick.index}
class={cn(
'block size-1 rounded-full bg-foreground/40 transition-colors duration-(--motion-duration-fast) data-[bounded]:bg-primary-foreground data-[disabled]:opacity-50'
)}
/>
{/each}
{/if}
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb data-slot="slider-thumb" index={thumb.index}>
{#snippet child({ props })}
<span
{...props}
aria-label={thumbLabels?.[thumb.index]}
use:pressScale={{ scale: 0.88, preset: 'snappy' }}
class={'group relative block size-5 shrink-0 rounded-full border-2 border-primary bg-card shadow-surface-3 ring-ring/50 transition-colors select-none after:absolute after:-inset-3 after:content-[""] hover:ring-(length:--ring-width) focus-visible:ring-(length:--ring-width) focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:bg-surface-5'}
>
{#if tooltip}
<span
data-slot="slider-tooltip"
aria-hidden="true"
class={cn(
'pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 rounded-md bg-popover px-2 py-1 font-mono text-xs whitespace-nowrap text-popover-foreground opacity-0 shadow-surface-7 transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-out) group-hover:opacity-100 group-focus-visible:opacity-100 group-data-[active]:opacity-100'
)}
>
{formatValue(thumb.value)}
</span>
{/if}
</span>
{/snippet}
</SliderPrimitive.Thumb>
{/each}
{/snippet}
</SliderPrimitive.Root>
Format
formatValue shapes the readout — percent, currency, or any
caller-supplied formatter.
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { pressScale } from '$lib/motion';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
class: className,
thumbLabels,
showSteps = false,
tooltip = false,
formatValue = String,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> & {
/** Accessible name(s) for slider thumb(s). Single-thumb: provide one entry; range: one per thumb. */
thumbLabels?: string[];
/** Render dot indicators at every step position along the track. */
showSteps?: boolean;
/** Render `formatValue(value)` above each thumb on hover / focus-visible / drag. */
tooltip?: boolean;
/** Format a numeric thumb value for the tooltip label. */
formatValue?: (v: number) => string;
} = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)}
{...restProps}
>
{#snippet children({ thumbItems, tickItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'relative grow overflow-hidden rounded-full bg-foreground/10 data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2'
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
'absolute bg-primary select-none data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
)}
/>
</span>
{#if showSteps}
{#each tickItems as tick (tick.index)}
<SliderPrimitive.Tick
data-slot="slider-tick"
index={tick.index}
class={cn(
'block size-1 rounded-full bg-foreground/40 transition-colors duration-(--motion-duration-fast) data-[bounded]:bg-primary-foreground data-[disabled]:opacity-50'
)}
/>
{/each}
{/if}
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb data-slot="slider-thumb" index={thumb.index}>
{#snippet child({ props })}
<span
{...props}
aria-label={thumbLabels?.[thumb.index]}
use:pressScale={{ scale: 0.88, preset: 'snappy' }}
class={'group relative block size-5 shrink-0 rounded-full border-2 border-primary bg-card shadow-surface-3 ring-ring/50 transition-colors select-none after:absolute after:-inset-3 after:content-[""] hover:ring-(length:--ring-width) focus-visible:ring-(length:--ring-width) focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:bg-surface-5'}
>
{#if tooltip}
<span
data-slot="slider-tooltip"
aria-hidden="true"
class={cn(
'pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 rounded-md bg-popover px-2 py-1 font-mono text-xs whitespace-nowrap text-popover-foreground opacity-0 shadow-surface-7 transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-out) group-hover:opacity-100 group-focus-visible:opacity-100 group-data-[active]:opacity-100'
)}
>
{formatValue(thumb.value)}
</span>
{/if}
</span>
{/snippet}
</SliderPrimitive.Thumb>
{/each}
{/snippet}
</SliderPrimitive.Root>
Tooltip
Pair tooltip with formatValue to float the value above the thumb on hover,
focus, and drag.
<Slider
type="single"
bind:value
tooltip
formatValue={(v) => `${v}%`}
/>States
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { pressScale } from '$lib/motion';
let {
ref = $bindable(null),
value = $bindable(),
orientation = 'horizontal',
class: className,
thumbLabels,
showSteps = false,
tooltip = false,
formatValue = String,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> & {
/** Accessible name(s) for slider thumb(s). Single-thumb: provide one entry; range: one per thumb. */
thumbLabels?: string[];
/** Render dot indicators at every step position along the track. */
showSteps?: boolean;
/** Render `formatValue(value)` above each thumb on hover / focus-visible / drag. */
tooltip?: boolean;
/** Format a numeric thumb value for the tooltip label. */
formatValue?: (v: number) => string;
} = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="slider"
{orientation}
class={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)}
{...restProps}
>
{#snippet children({ thumbItems, tickItems })}
<span
data-slot="slider-track"
data-orientation={orientation}
class={cn(
'relative grow overflow-hidden rounded-full bg-foreground/10 data-[orientation=horizontal]:h-2 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2'
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
class={cn(
'absolute bg-primary select-none data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
)}
/>
</span>
{#if showSteps}
{#each tickItems as tick (tick.index)}
<SliderPrimitive.Tick
data-slot="slider-tick"
index={tick.index}
class={cn(
'block size-1 rounded-full bg-foreground/40 transition-colors duration-(--motion-duration-fast) data-[bounded]:bg-primary-foreground data-[disabled]:opacity-50'
)}
/>
{/each}
{/if}
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb data-slot="slider-thumb" index={thumb.index}>
{#snippet child({ props })}
<span
{...props}
aria-label={thumbLabels?.[thumb.index]}
use:pressScale={{ scale: 0.88, preset: 'snappy' }}
class={'group relative block size-5 shrink-0 rounded-full border-2 border-primary bg-card shadow-surface-3 ring-ring/50 transition-colors select-none after:absolute after:-inset-3 after:content-[""] hover:ring-(length:--ring-width) focus-visible:ring-(length:--ring-width) focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:bg-surface-5'}
>
{#if tooltip}
<span
data-slot="slider-tooltip"
aria-hidden="true"
class={cn(
'pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 rounded-md bg-popover px-2 py-1 font-mono text-xs whitespace-nowrap text-popover-foreground opacity-0 shadow-surface-7 transition-opacity duration-(--motion-duration-fast) ease-(--motion-ease-out) group-hover:opacity-100 group-focus-visible:opacity-100 group-data-[active]:opacity-100'
)}
>
{formatValue(thumb.value)}
</span>
{/if}
</span>
{/snippet}
</SliderPrimitive.Thumb>
{/each}
{/snippet}
</SliderPrimitive.Root>
API reference
Inherits bits-ui Slider.Root props via spread. Discriminated unions + destructuring force value to be cast to never for type-checker peace — usage is unchanged.
| Prop | Type | Default | Description |
|---|---|---|---|
'single' | 'multiple' | — | Whether the slider has one thumb (single → value is a number) or many (multiple → value is number[]). | |
number | number[] (bindable) | — | Current thumb position(s). Two-way bindable. | |
number | 0 | Lower bound of the track. | |
number | 100 | Upper bound of the track. | |
number | 1 | Snap granularity between min and max. | |
'horizontal' | 'vertical' | 'horizontal' | Track axis. Vertical reverses thumb drag direction. | |
boolean | false | Disables every thumb in the group and drops opacity to 50%. | |
boolean | false | Render a dot at every step. Bounded dots (left of the thumb) read at higher contrast via the bits-ui data-bounded attribute. | |
boolean | false | Float a tooltip above each thumb on hover, focus-visible, and drag. The label uses formatValue. | |
(v: number) => string | String | Shapes the tooltip label. Pair with Intl.NumberFormat for percent / currency / locale formatting. | |
(v) => void | — | Fires on every value change while dragging. Pass-through to bits-ui Slider.Root. | |
(v) => void | — | Fires once on drag end (commit). Pass-through to bits-ui Slider.Root. | |
string[] | — | Accessible names for each thumb (passed through to aria-label). For range sliders provide one entry per thumb. | |
HTMLSpanElement | null | null | Two-way-bindable element reference. | |
string | — | Merged onto the root via tailwind-merge. |