Zum Inhalt springen
UI Kit
Accent

Segmented Control für React

Eine Wahl aus wenigen mit gleitendem Daumen, in drei Flächen, mit Symbolen, Zählern und zweiter Zeile, aufrecht für Seitenleisten und am Telefon in voller Breite. Eine Radiogruppe für die Pfeiltasten.

5 VariantsEine von 9 Components in Buttons

Eine Wahl aus wenigen, auf einen Blick

Eine Segment-Steuerung wählt eine Option aus wenigen, mit einem Daumen, der zur gewählten gleitet. Sie arbeitet als APG-Radiogruppe, die Pfeiltasten wechseln also und Screenreader sagen die Wahl an, und jedes Segment erreicht am Telefon 44 px.

„Ansicht wechseln“ schaltet Liste, Raster oder Karte für dieselben Ergebnisse, nennt die Fläche, die sie steuert, und füllt am Telefon die Breite. „Mit Zählern“ zeigt einen Zähler in jedem Segment, beim gewählten eingefärbt, auf der ruhigen Unterstrich-Fläche über einer Liste. „Abrechnung umschalten“ setzt den Daumen in der Akzentfarbe über eine Preistabelle, die Preise zählen zum neuen Wert und die Ersparnis erscheint mit der jährlichen Wahl. „Aufrecht mit Details“ steht ab 1024 px aufrecht mit zweiter Zeile je Option neben dem Detail und wird darunter eine Reihe in voller Breite. „Größen und Flächen“ zeigt drei Größen, reine Symbole mit Namen für Screenreader und die drei Flächen nebeneinander.

Nimm eine Segment-Steuerung für zwei bis fünf Optionen, die eine Ansicht sofort umschalten. Für mehr Optionen oder eine Wahl mit Absenden passen Select oder Radio Group besser. Halte die Beschriftungen möglichst gleich lang, dann gleitet der Daumen gleichmäßig.

Variants

Ansicht wechselnNeu in 1.2

Liste, Raster oder Karte für dieselben Ergebnisse. Die Steuerung nennt die Fläche, die sie umschaltet (aria-controls), am Telefon füllt sie die Breite.

Mit ZählernNeu in 1.2

Zähler in jedem Segment, beim gewählten eingefärbt. Die Unterstrich-Fläche sitzt ruhig über einer Liste, und die Reihe scrollt seitlich, wenn der Platz endet.

Abrechnung umschaltenNeu in 1.2

Der Daumen in der Akzentfarbe über einer Preistabelle. Die Preise zählen zum neuen Wert, die Ersparnis erscheint mit der jährlichen Wahl.

Aufrecht mit DetailsNeu in 1.2

Ab 1024 px eine aufrechte Steuerung mit zweiter Zeile je Option neben dem Detail. Darunter wird sie eine Reihe in voller Breite, am Telefon nur mit Symbolen.

Größen und FlächenNeu in 1.2

Drei Größen, nur Symbole mit Namen für Screenreader und die drei Flächen nebeneinander. Kleine Segmente erreichen am Telefon trotzdem 44 px.

Details

Was Segment-Steuerung mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle SegmentedControlProps aus components/ui/segmented-control.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • optionsPflicht
    SegmentOption[]
  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • labelPflicht
    string

    Accessible name of the group, e.g. "View"

  • sizeoptional
    SegmentedSize

    smmdlg

  • appearanceoptional
    SegmentedAppearance

    surfacesolidunderline

    surface: raised thumb on a quiet track. solid: thumb in the accent color. underline: a line slides under the choice.

  • orientationoptional
    'horizontal' | 'vertical'
  • stretchoptional
    boolean | 'mobile'

    Fill the width. mobile: only below 640 px, the segments share the width equally there.

  • controlsoptional
    string

    id of the panel the control switches, for aria-controls

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Segment-Steuerung reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-checked, aria-controls, aria-label und aria-orientation, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio und radiogroup.
  • 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. 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, 243 Zeilen
components/ui/segmented-control.tsx
'use client';

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, useLocale } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

/*
 * Segmented control: one value out of two to six, as a single track with a
 * thumb that glides to the choice. More than the plain segmented toggle:
 * three surfaces (raised, accent, underline), counts that stay readable on
 * the thumb, options with a second line, an upright form for side panels and
 * a track that fills the width on phones. APG radio group: one tab stop,
 * the arrow keys move and choose, Home and End jump to the ends.
 *
 * Without JavaScript the chosen segment carries the surface itself, so the
 * server render already shows the choice.
 */

const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

export type SegmentedSize = 'sm' | 'md' | 'lg';
export type SegmentedAppearance = 'surface' | 'solid' | 'underline';

export interface SegmentOption {
  value: string;
  label: string;
  icon?: IconName;
  /** Small number after the label, e.g. unread items */
  count?: number;
  /** Show only the icon, the label becomes the accessible name */
  iconOnly?: boolean;
  /** Second line, for upright controls */
  description?: string;
  disabled?: boolean;
}

export interface SegmentedControlProps {
  options: SegmentOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Accessible name of the group, e.g. "View" */
  label: string;
  size?: SegmentedSize;
  /** surface: raised thumb on a quiet track. solid: thumb in the accent color. underline: a line slides under the choice. */
  appearance?: SegmentedAppearance;
  orientation?: 'horizontal' | 'vertical';
  /** Fill the width. mobile: only below 640 px, the segments share the width equally there. */
  stretch?: boolean | 'mobile';
  /** id of the panel the control switches, for aria-controls */
  controls?: string;
  className?: string;
}

const SIZE: Record<SegmentedSize, { track: string; item: string; square: string; icon: number; text: string; pad: string }> = {
  sm: { track: 'rounded-lg p-[3px]', item: 'min-h-[30px] gap-1.5 rounded-[6px] px-2.5 max-sm:min-h-[38px]', square: 'w-[30px] px-0 max-sm:w-[38px]', icon: 15, text: 'text-[13px]', pad: '[--pad:3px]' },
  md: { track: 'rounded-[10px] p-1', item: 'min-h-9 gap-2 rounded-[7px] px-3.5', square: 'w-9 px-0', icon: 16, text: 'text-sm', pad: '[--pad:4px]' },
  lg: { track: 'rounded-xl p-1', item: 'min-h-11 gap-2 rounded-[9px] px-4', square: 'w-11 px-0', icon: 18, text: 'text-[15px]', pad: '[--pad:4px]' },
};

interface Thumb {
  x: number;
  y: number;
  w: number;
  h: number;
  animate: boolean;
}

/** A choice of one value from a few, with a gliding thumb. Radio group semantics, arrow keys move and choose. */
export function SegmentedControl({
  options,
  value,
  defaultValue,
  onValueChange,
  label,
  size = 'md',
  appearance = 'surface',
  orientation = 'horizontal',
  stretch,
  controls,
  className,
}: SegmentedControlProps) {
  const locale = useLocale();
  const [inner, setInner] = useState(defaultValue ?? options.find((o) => !o.disabled)?.value ?? '');
  const current = value ?? inner;
  const trackRef = useRef<HTMLDivElement>(null);
  const [thumb, setThumb] = useState<Thumb | null>(null);
  const vertical = orientation === 'vertical';
  const underline = appearance === 'underline';
  const s = SIZE[size];

  const choose = (v: string) => {
    if (value === undefined) setInner(v);
    onValueChange?.(v);
  };

  const measure = useCallback(
    (animate: boolean) => {
      const el = trackRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(current)}"]`);
      if (!el) return setThumb(null);
      setThumb({ x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight, animate });
    },
    [current],
  );
  const first = useRef(true);
  useIsoLayoutEffect(() => {
    measure(!first.current);
    first.current = false;
  }, [measure]);
  useEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const ro = new ResizeObserver(() => measure(false));
    ro.observe(track);
    // Late web fonts change the widths once more
    document.fonts?.ready.then(() => measure(false));
    return () => ro.disconnect();
  }, [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);
    if (!enabled.length) return;
    const pos = Math.max(0, enabled.indexOf(options.findIndex((o) => o.value === current)));
    const forward = e.key === 'ArrowRight' || e.key === 'ArrowDown';
    const next = e.key === 'Home' ? enabled[0] : e.key === 'End' ? enabled[enabled.length - 1] : enabled[(pos + (forward ? 1 : -1) + enabled.length) % enabled.length];
    choose(options[next].value);
    trackRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next]?.focus();
  };

  const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US');
  const ready = thumb !== null;
  const full = stretch === true;
  const phoneFull = stretch === 'mobile';

  return (
    <div
      ref={trackRef}
      role="radiogroup"
      aria-label={label}
      aria-orientation={vertical ? 'vertical' : undefined}
      onKeyDown={onKeyDown}
      className={cn(
        'relative isolate max-w-full',
        vertical ? 'flex flex-col items-stretch' : 'inline-flex items-stretch',
        !vertical && full && 'flex w-full',
        !vertical && phoneFull && 'max-sm:flex max-sm:w-full',
        underline
          ? cn(vertical ? 'gap-0.5 border-s border-border ps-0' : 'gap-1 border-b border-border')
          : cn('bg-muted shadow-[inset_0_0_0_1px_var(--border)]', s.track, vertical && 'gap-0.5'),
        s.pad,
        className,
      )}
    >
      {ready && (
        <span
          aria-hidden
          className={cn(
            'pointer-events-none absolute left-0 top-0 -z-10',
            appearance === 'surface' && 'rounded-[inherit] bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_11%,var(--background))]',
            appearance === 'solid' && 'bg-primary shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)]',
            appearance === 'underline' && 'bg-primary',
            !underline && (size === 'sm' ? 'rounded-[6px]' : size === 'md' ? 'rounded-[7px]' : 'rounded-[9px]'),
            thumb.animate && 'transition-[translate,width,height] duration-[420ms] ease-[cubic-bezier(0.3,1.25,0.5,1)] motion-reduce:transition-none',
          )}
          style={
            underline
              ? vertical
                ? { translate: `-1px ${thumb.y}px`, width: 2, height: thumb.h }
                : { translate: `${thumb.x}px ${thumb.y + thumb.h - 1}px`, width: thumb.w, height: 2 }
              : { translate: `${thumb.x}px ${thumb.y}px`, width: thumb.w, height: thumb.h }
          }
        />
      )}
      {options.map((o) => {
        const on = o.value === current;
        const iconOnly = o.iconOnly && o.icon;
        const onSolid = appearance === 'solid' && on;
        return (
          <button
            key={o.value}
            type="button"
            role="radio"
            aria-checked={on}
            aria-label={iconOnly ? o.label : undefined}
            aria-controls={controls}
            title={iconOnly ? o.label : undefined}
            data-value={o.value}
            disabled={o.disabled}
            tabIndex={on ? 0 : -1}
            onClick={() => choose(o.value)}
            className={cn(
              'relative inline-flex cursor-pointer select-none items-center whitespace-nowrap font-medium outline-none transition-[color,scale,background-color] duration-200 ease-[var(--ease-kit)] active:scale-[0.97] active:duration-100 disabled:pointer-events-none disabled:opacity-45 motion-reduce:active:scale-100',
              'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring',
              s.item,
              s.text,
              // The track padding counts toward the finger target: 36 px segment plus 4 px above and below
              !underline && 'after:absolute after:inset-x-0 after:-inset-y-1 after:content-[""]',
              vertical ? 'justify-start text-start' : 'justify-center',
              iconOnly && !vertical && s.square,
              !vertical && (full || phoneFull) && (full ? 'flex-1' : 'max-sm:flex-1'),
              underline && (vertical ? 'min-h-11 rounded-none ps-4 max-sm:min-h-11' : 'min-h-11 rounded-none px-3 pb-px max-sm:min-h-11'),
              on ? (onSolid ? 'text-primary-foreground' : 'text-foreground') : 'text-muted-foreground hover:text-foreground',
              !on && !underline && 'hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)]',
              // Until the thumb is measured (server render, no JavaScript) the chosen segment carries the surface itself
              on && !ready && appearance === 'surface' && 'bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)]',
              on && !ready && appearance === 'solid' && 'bg-primary',
              on && !ready && underline && (vertical ? 'shadow-[inset_2px_0_0_var(--primary)]' : 'shadow-[inset_0_-2px_0_var(--primary)]'),
              o.description && vertical && 'py-2.5',
            )}
          >
            {o.icon && <Icon name={o.icon} size={s.icon} className={cn('shrink-0', !on && !onSolid && 'opacity-80', o.description && vertical && 'self-start mt-0.5')} />}
            {!iconOnly &&
              (o.description && vertical ? (
                <span className="min-w-0 flex-1 whitespace-normal">
                  <span className="block leading-5">{o.label}</span>
                  <span className={cn('mt-0.5 block text-[12px] font-normal leading-4', onSolid ? 'text-primary-foreground/80' : 'text-muted-foreground')}>{o.description}</span>
                </span>
              ) : (
                <span className={cn(vertical && 'flex-1')}>{o.label}</span>
              ))}
            {o.count !== undefined && (
              <span
                className={cn(
                  'inline-grid h-5 min-w-5 shrink-0 place-items-center rounded-md px-1.5 text-[11px] font-semibold tabular-nums leading-none transition-[background-color,color] duration-200',
                  onSolid
                    ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_22%,transparent)] text-primary-foreground'
                    : on
                      ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]'
                      : 'bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)] text-muted-foreground',
                )}
              >
                {nf.format(o.count)}
              </span>
            )}
          </button>
        );
      })}
    </div>
  );
}

Häufige Fragen

Wie installiere ich Segment-Steuerung?

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

Welche Varianten hat Segment-Steuerung?

5 Varianten, Ansicht wechseln, Mit Zählern, Abrechnung umschalten, Aufrecht mit Details und Größen und Flächen.

Kann ein KI-Assistent Segment-Steuerung einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Segment-Steuerung mit aiw_components und installieren sie mit aiw_add @ai-website/segmented-control.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert