Schieberegler
Ein gestylter nativer Regler: ein Wert oder eine Spanne mit zwei Griffen, formatierte Werte und Marken.
Eine von 9 Components in Forms und Inputs
Details
Was Schieberegler mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Forms und Inputs
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.1 · seit 1.0.0
- Datei
- components/ui/slider.tsx
- Bringt mit
- utilskit
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 2 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Eingebaute Texte
Diese Texte schreibt Schieberegler selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 2 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| min | Englisch Minimum | Deutsch Minimum |
| max | Englisch Maximum | Deutsch Maximum |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Schieberegler reagiert selbst auf die Tasten Pos1, Ende und Leertaste.
- Setzt aria-describedby, aria-label, aria-labelledby und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle props.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 221 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Schieberegler?
Mit aiw add @ai-website/slider. Der Befehl legt die Datei components/ui/slider.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Schieberegler?
2 Varianten, Werte und Stufen und Marken.
Kann ich die Texte von Schieberegler übersetzen?
Ja. Schieberegler bringt 2 eigene Texte auf Deutsch und Englisch mit, etwa „Minimum“ und „Maximum“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Schieberegler einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Schieberegler mit aiw_components und installieren sie mit aiw_add @ai-website/slider.