Zum Inhalt springen
UI Kit
Accent

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

2 Variants

Variants

Werte

Der Wert im Format der Sprache, für Screenreader genauso vorgelesen. Die Füllung wächst beim Hereinscrollen, der Griff wächst beim Ziehen und eine Blase folgt ihm.

Stufen und Marken

Feste Stufen mit beschrifteten Marken für Entscheidungen, die eigentlich eine Skala sind.

Details

Was Schieberegler mitbringt, aus Registry und Quellcode gelesen.

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

Eingebaute Texte

Diese Texte schreibt Schieberegler selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 2 Texte anzeigen
minEnglisch MinimumDeutsch Minimum
maxEnglisch MaximumDeutsch 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
components/ui/slider.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert