Slider
A styled native range: one value or a range with two thumbs, formatted values and marks.
One of 9 components in Forms and inputs
Details
What Slider brings along, read from the registry and the source.
- Category
- Forms and inputs
- Area
- Elements
- Variants
- 2
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/slider.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- English and German, 2 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Built-in texts
Slider writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 2 texts
| Key | English | German |
|---|---|---|
| min | English Minimum | German Minimum |
| max | English Maximum | German Maximum |
Accessibility
Read from the source of the component.
- Slider handles the keys Home, End and Space itself.
- Sets aria-describedby, aria-label, aria-labelledby and aria-valuetext, so screen readers announce purpose and state.
- Uses the role props.
- Every field has a label, visible or for screen readers.
- Shows a visible focus ring when used with the keyboard.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 221 lines
'use client';
import { useId, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
const TEXTS = { en: { min: 'Minimum', max: 'Maximum' }, de: { min: 'Minimum', max: 'Maximum' } };
/* The thumb and track of a native range input, in both engines */
const RANGE = cn(
'h-11 w-full cursor-pointer appearance-none bg-transparent outline-none disabled:cursor-not-allowed',
'[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent',
'[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent',
'[&::-webkit-slider-thumb]:-mt-[7px] [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-[1.5px] [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-[var(--shadow-md)] [&::-webkit-slider-thumb]:transition-[box-shadow,scale] [&::-webkit-slider-thumb]:duration-300 [&::-webkit-slider-thumb]:ease-[cubic-bezier(0.34,1.56,0.64,1)]',
'[&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-[1.5px] [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-[var(--shadow-md)] [&::-moz-range-thumb]:transition-[box-shadow,scale] [&::-moz-range-thumb]:duration-300',
// The thumb grows while it is held, with a soft ring around it
'hover:[&::-webkit-slider-thumb]:shadow-[0_0_0_5px_color-mix(in_oklab,var(--primary)_14%,transparent),var(--shadow-md)] active:[&::-webkit-slider-thumb]:scale-[1.25] active:[&::-webkit-slider-thumb]:shadow-[0_0_0_7px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-md)] active:[&::-moz-range-thumb]:scale-[1.25]',
'focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_5px_color-mix(in_oklab,var(--primary)_24%,transparent),var(--shadow-md)] focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_5px_color-mix(in_oklab,var(--primary)_24%,transparent)]',
// Keyboard focus: a solid ring on the thumb itself, the part that moves (the soft halo alone was too faint)
'focus-visible:outline-none',
'focus-visible:[&::-webkit-slider-thumb]:outline-2 focus-visible:[&::-webkit-slider-thumb]:outline-offset-2 focus-visible:[&::-webkit-slider-thumb]:outline-solid focus-visible:[&::-webkit-slider-thumb]:outline-primary',
'focus-visible:[&::-moz-range-thumb]:outline-2 focus-visible:[&::-moz-range-thumb]:outline-offset-2 focus-visible:[&::-moz-range-thumb]:outline-solid focus-visible:[&::-moz-range-thumb]:outline-primary',
);
function Track({ from, to, marks, min, max, disabled }: { from: number; to: number; marks?: number[]; min: number; max: number; disabled?: boolean }) {
const pct = (v: number) => round(((v - min) / (max - min)) * 100);
// The fill grows to its value once the slider scrolls into view
const [ref, inView] = useInView<HTMLDivElement>();
return (
// Inset by the thumb radius, so 0 % and 100 % sit under the thumb center
<div ref={ref} aria-hidden className={cn('pointer-events-none absolute inset-x-2.5 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--foreground)_11%,var(--background))]', disabled && 'opacity-60')}>
<div className="absolute inset-y-0 overflow-hidden rounded-full" style={{ left: `${pct(from)}%`, width: `${round(pct(to) - pct(from))}%` }}>
<div className={cn('size-full origin-left rounded-full bg-primary transition-[scale] duration-[900ms] ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'scale-x-0')} />
</div>
{marks?.map((m) => (
<span
key={m}
className={cn(
'absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-[background-color,scale] duration-300',
m >= from && m <= to ? 'scale-125 bg-primary-foreground/70' : 'bg-[color-mix(in_oklab,var(--foreground)_30%,transparent)]',
)}
style={{ left: `${pct(m)}%` }}
/>
))}
</div>
);
}
/** The value in a small bubble over the thumb while it is dragged or moved with the keyboard */
function Bubble({ value, min, max, show, children }: { value: number; min: number; max: number; show: boolean; children: React.ReactNode }) {
const p = (value - min) / (max - min);
return (
<span
aria-hidden
className={cn(
'pointer-events-none absolute bottom-[calc(50%+18px)] z-20 -translate-x-1/2 whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-xs font-medium tabular-nums text-background shadow-[var(--shadow-md)] transition-[opacity,scale,translate] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none',
'after:absolute after:left-1/2 after:top-full after:-translate-x-1/2 after:border-4 after:border-transparent after:border-t-foreground after:content-[""]',
show ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-1 scale-90 opacity-0',
)}
style={{ left: `calc(10px + (100% - 20px) * ${round(p, 4)})`, transformOrigin: '50% 100%' }}
>
{children}
</span>
);
}
interface Base {
label?: React.ReactNode;
hint?: React.ReactNode;
min?: number;
max?: number;
step?: number;
/** Format a value for display and screen readers, e.g. (v) => `${v} €` */
format?: (value: number) => string;
/** Tick marks with labels under the track */
marks?: { value: number; label?: string }[];
/** Show min and max under the track */
showRange?: boolean;
disabled?: boolean;
name?: string;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
export type SliderProps =
| (Base & { range?: false; value?: number; defaultValue?: number; onValueChange?: (v: number) => void })
| (Base & { range: true; value?: [number, number]; defaultValue?: [number, number]; onValueChange?: (v: [number, number]) => void });
/**
* A styled native range input: keyboard, touch and forms for free. One value or a range with two thumbs.
* <Slider label="Budget" min={500} max={10000} step={100} format={(v) => `${v} €`} />
* <Slider range label="Price" defaultValue={[20, 80]} />
*/
export function Slider(props: SliderProps) {
const { label, hint, min = 0, max = 100, step = 1, format, marks, showRange, disabled, name, labels, className } = props;
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const id = useId();
const fmt = format ?? ((v: number) => v.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US'));
const initial: [number, number] = props.range
? props.value ?? props.defaultValue ?? [min, max]
: [min, (props.value as number | undefined) ?? (props.defaultValue as number | undefined) ?? round((min + max) / 2)];
const [inner, setInner] = useState<[number, number]>(initial);
const value: [number, number] = props.range ? props.value ?? inner : [min, (props.value as number | undefined) ?? inner[1]];
const set = (next: [number, number]) => {
setInner(next);
if (props.range) props.onValueChange?.(next);
else (props.onValueChange as ((v: number) => void) | undefined)?.(next[1]);
};
// Which thumb shows its bubble: the one held down, or the one moved with the keyboard
const [held, setHeld] = useState<'a' | 'b' | null>(null);
const bubble = (which: 'a' | 'b') => ({
onPointerDown: () => setHeld(which),
onPointerUp: () => setHeld(null),
onPointerCancel: () => setHeld(null),
onKeyDown: (e: React.KeyboardEvent) => e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End' || e.key.startsWith('Page') ? setHeld(which) : undefined,
onKeyUp: () => setHeld(null),
onBlur: () => setHeld(null),
});
const readout = props.range ? `${fmt(value[0])} ${locale === 'de' ? 'bis' : 'to'} ${fmt(value[1])}` : fmt(value[1]);
const markValues = marks?.map((m) => m.value);
return (
<div className={cn('grid w-full gap-1', className)} role={props.range ? 'group' : undefined} aria-labelledby={props.range && label ? `${id}-label` : undefined}>
{(
<div className="flex items-baseline justify-between gap-4">
{label && (
<label id={`${id}-label`} htmlFor={props.range ? undefined : `${id}-a`} className="text-sm font-medium text-foreground">
{label}
</label>
)}
<output htmlFor={props.range ? `${id}-a ${id}-b` : `${id}-a`} className={cn('ms-auto text-sm font-medium tabular-nums text-foreground transition-colors duration-200', held && 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
{readout}
</output>
</div>
)}
<div className="relative">
{props.range && <Bubble value={value[0]} min={min} max={max} show={held === 'a'}>{fmt(value[0])}</Bubble>}
<Bubble value={value[1]} min={min} max={max} show={held === 'b'}>{fmt(value[1])}</Bubble>
<Track from={props.range ? value[0] : min} to={value[1]} marks={markValues} min={min} max={max} disabled={disabled} />
{props.range ? (
<>
{/* Two inputs on one track. Only the thumbs take pointer events, so both stay reachable. */}
<input
id={`${id}-a`}
type="range"
min={min}
max={max}
step={step}
value={value[0]}
disabled={disabled}
name={name ? `${name}-min` : undefined}
{...bubble('a')}
aria-label={`${t.min}${typeof label === 'string' ? `, ${label}` : ''}`}
aria-valuetext={fmt(value[0])}
onChange={(e) => set([Math.min(Number(e.target.value), value[1] - step), value[1]])}
className={cn(RANGE, 'pointer-events-none absolute inset-0 [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:pointer-events-auto', value[0] > max - (max - min) * 0.1 && 'z-10')}
/>
<input
id={`${id}-b`}
type="range"
min={min}
max={max}
step={step}
value={value[1]}
disabled={disabled}
name={name ? `${name}-max` : undefined}
{...bubble('b')}
aria-label={`${t.max}${typeof label === 'string' ? `, ${label}` : ''}`}
aria-valuetext={fmt(value[1])}
onChange={(e) => set([value[0], Math.max(Number(e.target.value), value[0] + step)])}
className={cn(RANGE, 'pointer-events-none relative [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:pointer-events-auto')}
/>
</>
) : (
<input
id={`${id}-a`}
type="range"
min={min}
max={max}
step={step}
value={value[1]}
disabled={disabled}
name={name}
{...bubble('b')}
aria-valuetext={fmt(value[1])}
aria-describedby={hint ? `${id}-hint` : undefined}
onChange={(e) => set([min, Number(e.target.value)])}
className={cn(RANGE, 'relative')}
/>
)}
</div>
{marks && marks.some((m) => m.label) && (
<div aria-hidden className="relative mx-2.5 h-4 text-xs text-subtle-foreground">
{marks.map((m, i) => (
<span
key={m.value}
className={cn('absolute top-0 whitespace-nowrap', i === 0 ? 'translate-x-[-10px]' : i === marks.length - 1 ? 'translate-x-[calc(-100%+10px)]' : '-translate-x-1/2')}
style={{ left: `${round(((m.value - min) / (max - min)) * 100)}%` }}
>
{m.label}
</span>
))}
</div>
)}
{showRange && !marks && (
<div aria-hidden className="flex justify-between text-xs tabular-nums text-subtle-foreground">
<span>{fmt(min)}</span>
<span>{fmt(max)}</span>
</div>
)}
{hint && (
<p id={`${id}-hint`} className="mt-1 text-[13px] text-muted-foreground">
{hint}
</p>
)}
</div>
);
}Frequent questions
How do I install Slider?
With aiw add @ai-website/slider. The command puts components/ui/slider.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Slider have?
2 variants, Values and Steps and marks.
Can I translate the texts of Slider?
Yes. Slider carries 2 texts of its own in English and German, such as “Minimum” and “Maximum”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Slider?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Slider with aiw_components and install it with aiw_add @ai-website/slider.