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.
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
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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SegmentedControlProps aus components/ui/segmented-control.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
optionsPflichtSegmentOption[]valueoptionalstringdefaultValueoptionalstringonValueChangeoptional(value: string) => voidlabelPflichtstringAccessible name of the group, e.g. "View"
sizeoptionalSegmentedSizesmmdlg
appearanceoptionalSegmentedAppearancesurfacesolidunderline
surface: raised thumb on a quiet track. solid: thumb in the accent color. underline: a line slides under the choice.
orientationoptional'horizontal' | 'vertical'stretchoptionalboolean | 'mobile'Fill the width. mobile: only below 640 px, the segments share the width equally there.
controlsoptionalstringid 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
'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.