Zum Inhalt springen
UI Kit
Accent

Umschalter

Knöpfe, die gedrückt bleiben, Gruppen für einen oder mehrere Werte und ein Segment-Schalter mit gleitendem Daumen.

Eine von 6 Components in Buttons

3 Variants

Variants

Segmentiert

Ein Wert aus wenigen. Der Daumen gleitet mit leichtem Überschwingen, Pfeiltasten bewegen ihn, die Gruppe ist ein Tab-Stopp.

Werkzeugleiste

Mehrere Werte zugleich fürs Format, ein Wert für die Ausrichtung mit gleitender Markierung. Probier es, der Satz folgt.

Einzelne Umschalter

Ein Knopf, zwei Zustände, angesagt über aria-pressed. Ruhig oder umrandet.

Details

Was Umschalter mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Buttons
Bereich
Elements
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/toggle.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle ToggleProps aus components/ui/toggle.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> an.

  • pressedoptional
    boolean
  • defaultPressedoptional
    boolean
  • onPressedChangeoptional
    (pressed: boolean) => void
  • iconoptional
    IconName
  • labeloptional
    string

    Required when there is no visible text

  • sizeoptional
    ToggleSize

    smmdlg

  • variantoptional
    ToggleVariant

    ghostoutline

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Umschalter reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-checked, aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio und segmented.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 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, 257 Zeilen
components/ui/toggle.tsx
'use client';

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

// useLayoutEffect warns on the server, the measurement only matters in the browser
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/** A spring pop on the icon of a toggle that was just pressed */
function popIcon(button: HTMLElement) {
  const svg = button.querySelector('svg');
  if (!svg || prefersReducedMotion() || typeof svg.animate !== 'function') return;
  svg.animate([{ transform: 'scale(1)' }, { transform: 'scale(0.72)', offset: 0.25 }, { transform: 'scale(1.16)', offset: 0.65 }, { transform: 'scale(1)' }], { duration: 440, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}
import { Icon, type IconName } from '@/components/ui/icon';

export type ToggleSize = 'sm' | 'md' | 'lg';
export type ToggleVariant = 'ghost' | 'outline';

const SIZE: Record<ToggleSize, { box: string; square: string; icon: number }> = {
  sm: { box: 'h-9 gap-1.5 rounded-md px-2.5 text-[13px] after:-inset-y-1', square: 'w-9 px-0', icon: 16 },
  md: { box: 'h-11 gap-2 rounded-lg px-3.5 text-sm', square: 'w-11 px-0', icon: 18 },
  lg: { box: 'h-12 gap-2 rounded-lg px-4 text-[15px]', square: 'w-12 px-0', icon: 19 },
};

const VARIANT: Record<ToggleVariant, string> = {
  ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground aria-pressed:bg-accent aria-pressed:text-foreground',
  outline:
    'border border-border-strong bg-background text-muted-foreground shadow-[var(--shadow-sm)] hover:text-foreground aria-pressed:border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))] aria-pressed:bg-[color-mix(in_oklab,var(--primary)_8%,var(--background))] aria-pressed:text-foreground',
};

export interface ToggleProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
  pressed?: boolean;
  defaultPressed?: boolean;
  onPressedChange?: (pressed: boolean) => void;
  icon?: IconName;
  /** Required when there is no visible text */
  label?: string;
  size?: ToggleSize;
  variant?: ToggleVariant;
}

/** A button that stays pressed: bold on/off, favorite, mute. Uses aria-pressed. */
export function Toggle({ pressed, defaultPressed, onPressedChange, icon, label, size = 'md', variant = 'ghost', className, children, onClick, ...rest }: ToggleProps) {
  const [inner, setInner] = useState(defaultPressed ?? false);
  const on = pressed ?? inner;
  return (
    <button
      type="button"
      {...rest}
      aria-pressed={on}
      aria-label={children ? undefined : label}
      onClick={(e) => {
        onClick?.(e);
        if (!on) popIcon(e.currentTarget);
        setInner(!on);
        onPressedChange?.(!on);
      }}
      className={cn(
        'relative inline-flex shrink-0 select-none items-center justify-center font-medium transition-[background-color,color,border-color,box-shadow,scale] duration-200 ease-[var(--ease-kit)] after:absolute after:inset-x-0 after:content-[""] active:scale-[0.94] active:duration-100 disabled:pointer-events-none disabled:opacity-45 motion-reduce:active:scale-100',
        SIZE[size].box,
        !children && SIZE[size].square,
        VARIANT[variant],
        className,
      )}
    >
      {icon && <Icon name={icon} size={SIZE[size].icon} strokeWidth={on ? 1.9 : 1.6} />}
      {children}
    </button>
  );
}

export interface ToggleOption {
  value: string;
  label: string;
  icon?: IconName;
  /** Show only the icon, the label becomes the accessible name */
  iconOnly?: boolean;
  disabled?: boolean;
}

interface GroupBase {
  options: ToggleOption[];
  /** Accessible name of the group */
  label: string;
  size?: ToggleSize;
  /** segmented: one track with a sliding thumb. outline: separate pressed buttons. ghost: quiet toolbar (single choice gets a sliding mark too). */
  variant?: 'segmented' | 'outline' | 'ghost';
  /** Stretch over the full width, options share it equally */
  fullWidth?: boolean;
  className?: string;
}

export type ToggleGroupProps =
  | (GroupBase & { type?: 'single'; value?: string; defaultValue?: string; onValueChange?: (value: string) => void })
  | (GroupBase & { type: 'multiple'; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void });

const SEG_SIZE: Record<ToggleSize, { track: string; item: string; square: string; icon: number }> = {
  sm: { track: 'h-9 rounded-md p-[3px]', item: 'h-full gap-1.5 rounded-[5px] px-2.5 text-[13px]', square: 'w-[30px] px-0', icon: 15 },
  md: { track: 'h-11 rounded-lg p-1', item: 'h-full gap-2 rounded-md px-3.5 text-sm', square: 'w-9 px-0', icon: 17 },
  lg: { track: 'h-12 rounded-lg p-1', item: 'h-full gap-2 rounded-md px-4 text-[15px]', square: 'w-10 px-0', icon: 18 },
};

/**
 * A set of toggles. single: like radio buttons (view switch, alignment). multiple: like checkboxes (bold, italic).
 * Arrow keys move between options, the group is one tab stop.
 */
export function ToggleGroup(props: ToggleGroupProps) {
  const { options, label, size = 'md', variant = 'segmented', fullWidth, className } = props;
  const multiple = props.type === 'multiple';
  const [inner, setInner] = useState<string[]>(() => {
    const d = props.defaultValue;
    return d === undefined ? (multiple ? [] : options[0] ? [options[0].value] : []) : Array.isArray(d) ? d : [d];
  });
  const controlled = props.value !== undefined;
  const selected = controlled ? (Array.isArray(props.value) ? props.value : [props.value as string]) : inner;

  const trackRef = useRef<HTMLDivElement>(null);
  const [thumb, setThumb] = useState<{ x: number; w: number; ready: boolean; animate: boolean } | null>(null);
  const [focusIndex, setFocusIndex] = useState(() => Math.max(0, options.findIndex((o) => selected.includes(o.value))));

  const set = (value: string) => {
    let next: string[];
    if (multiple) next = selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value];
    else next = [value];
    if (!controlled) setInner(next);
    if (multiple) (props.onValueChange as ((v: string[]) => void) | undefined)?.(next);
    else (props.onValueChange as ((v: string) => void) | undefined)?.(value);
  };

  const segmented = variant === 'segmented' && !multiple;
  // One mark slides between the options: the thumb of the segmented control, the pressed surface of a single ghost group
  const sliding = !multiple && (variant === 'segmented' || variant === 'ghost');
  const active = sliding ? selected[0] : undefined;

  const measure = useCallback(
    (animate: boolean) => {
      const el = trackRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(active ?? '')}"]`);
      if (!el) return setThumb(null);
      setThumb({ x: el.offsetLeft, w: el.offsetWidth, ready: true, animate });
    },
    [active],
  );
  const first = useRef(true);
  useIsoLayoutEffect(() => {
    if (!sliding) return;
    measure(!first.current);
    first.current = false;
    const ro = new ResizeObserver(() => measure(false));
    if (trackRef.current) ro.observe(trackRef.current);
    return () => ro.disconnect();
  }, [sliding, measure]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const keys = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'];
    if (!keys.includes(e.key)) return;
    e.preventDefault();
    const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);
    const pos = enabled.indexOf(focusIndex);
    const next =
      e.key === 'Home' ? enabled[0] : e.key === 'End' ? enabled[enabled.length - 1] : enabled[(pos + (e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1) + enabled.length) % enabled.length];
    setFocusIndex(next);
    trackRef.current?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
    if (segmented) set(options[next].value);
  };

  const s = SEG_SIZE[size];
  return (
    <div
      ref={trackRef}
      role={segmented ? 'radiogroup' : 'group'}
      aria-label={label}
      onKeyDown={onKeyDown}
      className={cn(
        'relative isolate inline-flex items-center',
        fullWidth && 'flex w-full',
        variant === 'segmented' && cn('bg-muted shadow-[inset_0_0_0_1px_var(--border)]', s.track),
        variant === 'outline' && 'gap-2',
        variant === 'ghost' && 'gap-1',
        className,
      )}
    >
      {sliding && thumb?.ready && (
        <span
          aria-hidden
          className={cn(
            'absolute left-0 -z-10',
            segmented
              ? cn(
                  'bottom-[var(--pad)] top-[var(--pad)] bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))]',
                  size === 'sm' ? 'rounded-[5px] [--pad:3px]' : 'rounded-md [--pad:4px]',
                )
              : cn('inset-y-0 bg-accent', size === 'sm' ? 'rounded-md' : 'rounded-lg'),
            // A light overshoot, the thumb arrives like a physical slider
            thumb.animate && 'transition-[translate,width] duration-[420ms] ease-[cubic-bezier(0.3,1.3,0.5,1)] motion-reduce:transition-none',
          )}
          style={{ translate: `${thumb.x}px 0`, width: thumb.w }}
        />
      )}
      {options.map((o, i) => {
        const on = selected.includes(o.value);
        const iconOnly = o.iconOnly && o.icon;
        if (variant !== 'segmented' || multiple) {
          return (
            <Toggle
              key={o.value}
              data-value={o.value}
              pressed={on}
              onPressedChange={() => set(o.value)}
              icon={o.icon}
              label={o.label}
              size={size}
              variant={variant === 'outline' ? 'outline' : 'ghost'}
              disabled={o.disabled}
              tabIndex={i === focusIndex ? 0 : -1}
              onFocus={() => setFocusIndex(i)}
              // The sliding mark carries the pressed surface once it is measured
              className={cn(fullWidth && 'flex-1', sliding && thumb?.ready && 'aria-pressed:bg-transparent')}
            >
              {iconOnly ? undefined : o.label}
            </Toggle>
          );
        }
        return (
          <button
            key={o.value}
            type="button"
            role="radio"
            aria-checked={on}
            aria-label={iconOnly ? o.label : undefined}
            data-value={o.value}
            disabled={o.disabled}
            tabIndex={i === focusIndex ? 0 : -1}
            onFocus={() => setFocusIndex(i)}
            onClick={(e) => {
              if (!on) popIcon(e.currentTarget);
              set(o.value);
            }}
            className={cn(
              'relative inline-flex select-none items-center justify-center whitespace-nowrap font-medium text-muted-foreground outline-none transition-[color,scale] duration-200 hover:text-foreground active:scale-[0.96] active:duration-100 disabled:pointer-events-none disabled:opacity-45 motion-reduce:active:scale-100',
              'focus-visible:ring-2 focus-visible:ring-ring',
              s.item,
              iconOnly && s.square,
              fullWidth && 'flex-1',
              on && 'text-foreground',
              // Until the thumb is measured (server render, no JavaScript) the selected item carries the surface itself
              on && !thumb?.ready && 'bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)]',
            )}
          >
            {o.icon && <Icon name={o.icon} size={s.icon} />}
            {!iconOnly && o.label}
          </button>
        );
      })}
    </div>
  );
}

Häufige Fragen

Wie installiere ich Umschalter?

Mit aiw add @ai-website/toggle. Der Befehl legt die Datei components/ui/toggle.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Umschalter?

3 Varianten, Segmentiert, Werkzeugleiste und Einzelne Umschalter. Du wählst sie mit der Prop variant, zum Beispiel variant="outline".

Kann ein KI-Assistent Umschalter einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Umschalter mit aiw_components und installieren sie mit aiw_add @ai-website/toggle. Nenn im Prompt die Variante, etwa variant="ghost", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert