Slider Comfortable
Settings-panel-style discrete selector. Pips mode trades the continuous track for dot indicators at every step — pick-a-value-from-N affordance. Scrubber mode swaps the same labeled layout onto a continuous track.
Install
import { SliderComfortable } from '$lib/components/ui/slider-comfortable'; Pips
Default variant. Pips on the left of the thumb fill with --primary; the current step reads as --foreground; pips to the
right stay muted. Hover the container to see the label lift from muted to foreground.
<script lang="ts">
import { SliderComfortable } from '$lib/components/ui/slider-comfortable';
let value = $state(2);
</script>
<SliderComfortable
label="Roundness"
bind:value
min={0}
max={6}
step={1}
/>Pips with Format
Pair with formatValue to map numeric steps to labels — useful
for enumerated settings like quality presets.
<SliderComfortable
label="Quality"
bind:value
min={0}
max={3}
step={1}
formatValue={(v) => ['Low', 'Medium', 'High', 'Ultra'][v]}
/>Scrubber
variant="scrubber" swaps the pips for a continuous track
+ range + thumb — drag-anywhere on the track. Same labeled layout, denser values.
<SliderComfortable
variant="scrubber"
label="Volume"
bind:value
min={0}
max={100}
step={1}
formatValue={(v) => `${v}%`}
/>Disabled
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const sliderComfortableVariants = tv({
slots: {
root: 'flex w-full flex-col gap-2 select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
header: 'flex items-baseline justify-between text-sm',
label:
'text-muted-foreground transition-colors duration-(--motion-duration-fast) group-hover/sc:text-foreground',
value: 'font-mono text-xs tabular-nums text-foreground',
root_inner: 'group/sc relative flex w-full touch-none items-center'
},
variants: {
variant: {
pips: {},
scrubber: {}
}
},
defaultVariants: {
variant: 'pips'
}
});
export type SliderComfortableVariant = NonNullable<
VariantProps<typeof sliderComfortableVariants>['variant']
>;
</script>
<script lang="ts">
import { Slider as SliderPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { pressScale } from '$lib/motion';
let {
ref = $bindable(null),
value = $bindable(0),
min = 0,
max = 100,
step = 1,
variant = 'pips',
label,
formatValue = String,
disabled = false,
class: className,
onValueChange,
onValueCommit
}: {
ref?: HTMLSpanElement | null;
value?: number;
min?: number;
max?: number;
step?: number;
variant?: SliderComfortableVariant;
label?: string;
formatValue?: (v: number) => string;
disabled?: boolean;
class?: string;
onValueChange?: (v: number) => void;
onValueCommit?: (v: number) => void;
} = $props();
const styles = $derived(sliderComfortableVariants({ variant }));
</script>
<div
data-slot="slider-comfortable"
class={cn(styles.root(), className)}
data-disabled={disabled ? '' : undefined}
>
{#if label !== undefined || formatValue}
<div class={styles.header()}>
{#if label !== undefined}
<span data-slot="slider-comfortable-label" class={styles.label()}>{label}</span>
{:else}
<span></span>
{/if}
<span data-slot="slider-comfortable-value" class={styles.value()}>{formatValue(value)}</span>
</div>
{/if}
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
type="single"
data-slot="slider-comfortable-root"
data-variant={variant}
{min}
{max}
{step}
{disabled}
onValueChange={onValueChange as never}
onValueCommit={onValueCommit as never}
class={styles.root_inner()}
>
{#snippet children({ thumbItems, tickItems })}
{#if variant === 'pips'}
<!-- Pips variant: invisible track, no range, pips ARE the affordance -->
<span data-slot="slider-comfortable-track" class="relative h-5 grow bg-transparent">
<SliderPrimitive.Range
data-slot="slider-comfortable-range"
class="absolute bg-transparent"
/>
</span>
{#each tickItems as tick (tick.index)}
<SliderPrimitive.Tick
data-slot="slider-comfortable-tick"
index={tick.index}
class="block size-1.5 rounded-full bg-foreground/25 transition-colors duration-(--motion-duration-fast) data-[bounded]:bg-primary data-[disabled]:bg-foreground/15 data-[selected]:bg-foreground"
/>
{/each}
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb data-slot="slider-comfortable-thumb" index={thumb.index}>
{#snippet child({ props })}
<span
{...props}
aria-label={label}
class={'pointer-events-none absolute block size-0 rounded-full ring-ring/70 ring-offset-2 ring-offset-background focus-visible:ring-(length:--ring-width) focus-visible:outline-hidden'}
></span>
{/snippet}
</SliderPrimitive.Thumb>
{/each}
{:else}
<!-- Scrubber variant: track + range + draggable thumb, identical to <Slider> -->
<span
data-slot="slider-comfortable-track"
class="relative h-2 grow overflow-hidden rounded-full bg-foreground/10"
>
<SliderPrimitive.Range
data-slot="slider-comfortable-range"
class="absolute h-full bg-primary select-none"
/>
</span>
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb data-slot="slider-comfortable-thumb" index={thumb.index}>
{#snippet child({ props })}
<span
{...props}
aria-label={label}
use:pressScale={{ scale: 0.88, preset: 'snappy' }}
class={'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'}
></span>
{/snippet}
</SliderPrimitive.Thumb>
{/each}
{/if}
{/snippet}
</SliderPrimitive.Root>
</div>
API reference
Comfortable owns its own labeled layout — diverges from the thin-wrapper <Slider> on purpose. Single-thumb only; range and vertical orientations are deferred.
| Prop | Type | Default | Description |
|---|---|---|---|
'pips' | 'scrubber' | 'pips' | Interaction model. Pips renders a dot at each step (discrete pick). Scrubber renders a continuous track + thumb. | |
number (bindable) | min | Current step or position. Two-way bindable. | |
number | 0 | Lower bound. | |
number | 100 | Upper bound. | |
number | 1 | Increment between snapped values. In pips mode, determines the dot count. | |
string | — | Left-aligned text label. Also passed as aria-label to the thumb. | |
(v: number) => string | String | Right-aligned value formatter. Map numeric steps to labels for enumerated settings. | |
boolean | false | Disables interaction; root drops to 50% opacity and pointer-events go to none. | |
(v: number) => void | — | Fires on every value change while dragging or stepping. | |
(v: number) => void | — | Fires once on drag end (commit). | |
HTMLSpanElement | null | null | Two-way-bindable reference to the inner bits-ui Slider.Root element. | |
string | — | Merged onto the outer container via tailwind-merge. |