Tabs für React
Unterstrichen, Pillen, segmentiert und senkrecht, mit Icons und Zählern, Pfeiltasten und gleitender Markierung.
Eine von 6 Components in Navigation
Unterstrichen, Pillen oder senkrecht
Tabs wechseln zwischen Ansichten desselben Themas, ohne die Seite zu verlassen. Diese Component folgt dem Tabs-Muster der WAI, Pfeiltasten wechseln zwischen Tabs, Pos1 und Ende springen an die Enden, und nur der aktive Tab liegt in der Tab-Reihenfolge, die Tastatur erreicht das Panel also in einem Schritt.
„Unterstrichen“ ist der ruhige Standard für Seitenabschnitte und Einstellungen. „Pillen und segmentiert“ passt zu Filtern und Umschaltern zwischen zwei bis vier Ansichten, die segmentierte Form eignet sich gut für Monatlich und Jährlich. „Senkrecht“ stellt die Tabs in eine Spalte, für Einstellungen mit vielen Abschnitten. Jeder Stil trägt Icons und Zähler, und der Indikator gleitet zum neuen Tab, statt zu springen.
Nutze Tabs für gleichwertige Inhalte, der erste Tab bekommt die meisten Aufrufe. Halte Labels bei ein oder zwei Wörtern. Ist der Inhalt eine Abfolge, nimm einen Stepper, und wenn verglichen werden soll, zeig beide Ansichten zugleich.
Details
Was Reiter mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Navigation
- Bereich
- Elements
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/tabs.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 TabsProps aus components/ui/tabs.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
itemsPflichtTabItem[]valueoptionalstringdefaultValueoptionalstringonValueChangeoptional(value: string) => voidvariantoptional'underline' | 'pills' | 'segmented' | 'vertical'underline: a line under the active tab. pills: a filled pill. segmented: one track with a sliding thumb. vertical: a side list.
labelPflichtstringAccessible name of the tab list
fullWidthoptionalbooleanStretch tabs over the full width
panelClassNameoptionalstring
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Reiter reagiert selbst auf die Tasten Pos1, Ende und Leertaste.
- Setzt aria-controls, aria-label, aria-labelledby, aria-orientation und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen tab, tablist und tabpanel.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 287 Zeilen
'use client';
import { useCallback, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
export interface TabItem {
value: string;
label: React.ReactNode;
icon?: IconName;
/** A small count after the label */
count?: number;
disabled?: boolean;
content: React.ReactNode;
}
export interface TabsProps {
items: TabItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** underline: a line under the active tab. pills: a filled pill. segmented: one track with a sliding thumb. vertical: a side list. */
variant?: 'underline' | 'pills' | 'segmented' | 'vertical';
/** Accessible name of the tab list */
label: string;
/** Stretch tabs over the full width */
fullWidth?: boolean;
className?: string;
panelClassName?: string;
}
/* Tabs rise one after another when the list scrolls into view, the panel follows */
const STYLES =
'.kitfx-tabs-in [role=tab]{animation:kitfx-tabs-in .6s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 55ms)}' +
'.kitfx-tabs-in [role=tabpanel]{animation:kitfx-tabs-in .7s var(--ease-kit) .18s both}' +
'@keyframes kitfx-tabs-in{from{opacity:0;transform:translateY(10px)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-tabs-in [role=tab],.kitfx-tabs-in [role=tabpanel]{animation:none}}';
const SPRING = 'cubic-bezier(0.34, 1.25, 0.64, 1)';
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
type Mark = { kind: 'line' | 'box'; x: number; y: number; w: number; h: number; animate: boolean };
const transformOf = (m: Mark) => (m.kind === 'line' ? `translateX(${m.x}px)` : `translate(${m.x}px, ${m.y}px)`);
/**
* Tabs with full keyboard support (arrows, Home, End), automatic activation and a sliding indicator.
* The indicator glides with a light spring, the new panel slides in from the side of the tab.
* Rendered on the server with the first panel visible, so content is there without JavaScript.
* <Tabs label="Project" items={[{ value: 'overview', label: 'Overview', content: … }]} />
*/
export function Tabs({ items, value: valueProp, defaultValue, onValueChange, variant = 'underline', label, fullWidth, className, panelClassName }: TabsProps) {
const [inner, setInner] = useState(defaultValue ?? items.find((i) => !i.disabled)?.value);
const value = valueProp ?? inner;
const id = useId();
const listRef = useRef<HTMLDivElement>(null);
const markRef = useRef<HTMLSpanElement>(null);
const panelsRef = useRef<HTMLDivElement>(null);
const [mark, setMark] = useState<Mark | null>(null);
const prevMark = useRef<Mark | null>(null);
const prevValue = useRef(value);
const first = useRef(true);
const vertical = variant === 'vertical';
const [rootRef, inView] = useInView<HTMLDivElement>();
const hidden = useRef(false);
if (!inView) hidden.current = true;
const entering = inView && hidden.current;
// Scroll rows fade only on the side where more tabs wait. A vertical list is a row on phones, so it starts faded at the end.
const [fade, setFade] = useState({ start: false, end: vertical });
const updateFade = useCallback(() => {
const l = listRef.current;
if (!l) return;
const rest = l.scrollWidth - l.clientWidth - l.scrollLeft;
setFade((f) => (f.start === l.scrollLeft > 1 && f.end === rest > 1 ? f : { start: l.scrollLeft > 1, end: rest > 1 }));
}, []);
const select = (v: string) => {
setInner(v);
onValueChange?.(v);
};
const measure = useCallback(
(animate: boolean) => {
const el = listRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(value ?? '')}"]`);
if (!el) return;
const x = el.offsetLeft;
const y = el.offsetTop;
const w = el.offsetWidth;
const h = el.offsetHeight;
const wide = !vertical || window.matchMedia('(min-width: 48rem)').matches;
const next: Mark =
variant === 'underline'
? { kind: 'line', x: x + 8, y: 0, w: Math.max(0, w - 16), h: 2, animate }
: vertical && !wide
? { kind: 'line', x, y: 0, w, h: 2, animate }
: { kind: 'box', x, y, w, h, animate };
setMark((m) => (m && m.kind === next.kind && m.x === next.x && m.y === next.y && m.w === next.w && m.h === next.h ? m : next));
// Keep the active tab in view in a scrolling row
const list = listRef.current;
if (list && !(vertical && wide) && list.scrollWidth > list.clientWidth) {
const left = x - 16;
const right = x + w + 16 - list.clientWidth;
if (list.scrollLeft > left) list.scrollTo({ left, behavior: animate ? 'smooth' : 'auto' });
else if (list.scrollLeft < right) list.scrollTo({ left: right, behavior: animate ? 'smooth' : 'auto' });
}
},
[value, vertical, variant],
);
useLayoutEffect(() => {
measure(!first.current);
updateFade();
first.current = false;
const ro = new ResizeObserver(() => {
measure(false);
updateFade();
});
if (listRef.current) ro.observe(listRef.current);
return () => ro.disconnect();
}, [measure, updateFade]);
// The indicator moves from where it was, as a transform only (FLIP)
useLayoutEffect(() => {
const el = markRef.current;
const prev = prevMark.current;
prevMark.current = mark;
if (!el || !mark || !prev || !mark.animate || prev.kind !== mark.kind || prefersReducedMotion()) return;
if (prev.x === mark.x && prev.y === mark.y && prev.w === mark.w && prev.h === mark.h) return;
const sx = mark.w ? prev.w / mark.w : 1;
const sy = mark.h ? prev.h / mark.h : 1;
const from = mark.kind === 'line' ? `translateX(${prev.x}px) scaleX(${sx})` : `translate(${prev.x}px, ${prev.y}px) scale(${sx}, ${sy})`;
el.animate([{ transform: from }, { transform: transformOf(mark) }], { duration: 420, easing: SPRING });
}, [mark]);
// The new panel slides in from the side of the tab that was chosen
useLayoutEffect(() => {
const before = prevValue.current;
prevValue.current = value;
if (before === value || prefersReducedMotion()) return;
const panel = panelsRef.current?.querySelector<HTMLElement>(`[data-panel="${CSS.escape(value ?? '')}"]`);
if (!panel) return;
const dir = Math.sign(items.findIndex((i) => i.value === value) - items.findIndex((i) => i.value === before)) || 1;
const from = vertical ? `translateY(${dir * 10}px)` : `translateX(${dir * 16}px)`;
panel.animate([{ opacity: 0, transform: from, filter: 'blur(3px)' }, { opacity: 1, transform: 'none', filter: 'blur(0)' }], { duration: 420, easing: EASE });
}, [value, items, vertical]);
const onKeyDown = (e: React.KeyboardEvent) => {
const enabled = items.filter((i) => !i.disabled);
const i = enabled.findIndex((x) => x.value === value);
const next: Record<string, number> = vertical ? { ArrowDown: 1, ArrowUp: -1 } : { ArrowRight: 1, ArrowLeft: -1 };
let target: TabItem | undefined;
if (e.key in next) target = enabled[(i + next[e.key] + enabled.length) % enabled.length];
else if (e.key === 'Home') target = enabled[0];
else if (e.key === 'End') target = enabled[enabled.length - 1];
if (!target) return;
e.preventDefault();
select(target.value);
listRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(target.value)}"]`)?.focus();
};
const ready = mark !== null;
const scrolls = variant === 'underline' || variant === 'pills' || vertical;
const mask = scrolls && (fade.start || fade.end) ? `linear-gradient(to right, ${fade.start ? 'transparent, #000 48px' : '#000, #000'}, ${fade.end ? '#000 calc(100% - 48px), transparent' : '#000, #000'})` : undefined;
const list = (
<div
ref={listRef}
role="tablist"
aria-label={label}
aria-orientation={vertical ? 'vertical' : 'horizontal'}
onKeyDown={onKeyDown}
onScroll={scrolls ? updateFade : undefined}
style={mask ? ({ '--kit-tabs-mask': mask } as React.CSSProperties) : undefined}
className={cn(
'relative isolate',
// The base line sits on the wrapper, so the fade never thins it out
variant === 'underline' && 'kit-no-scrollbar -mb-px flex gap-1 overflow-x-auto [mask-image:var(--kit-tabs-mask,none)]',
variant === 'pills' && 'kit-no-scrollbar -my-1 flex gap-1 overflow-x-auto py-1 [mask-image:var(--kit-tabs-mask,none)]',
variant === 'segmented' && 'inline-flex max-w-full rounded-lg bg-muted p-1 shadow-[inset_0_0_0_1px_var(--border)]',
variant === 'segmented' && fullWidth && 'flex w-full',
vertical && 'flex flex-col gap-0.5 max-md:kit-no-scrollbar max-md:-mb-px max-md:flex-row max-md:overflow-x-auto max-md:[mask-image:var(--kit-tabs-mask,none)] md:w-56 md:shrink-0',
)}
>
{mark && (
<span
ref={markRef}
aria-hidden
className={cn(
'pointer-events-none absolute left-0 -z-10 origin-top-left transition-opacity duration-500 ease-[var(--ease-kit)]',
!inView && 'opacity-0',
entering && 'delay-300',
mark.kind === 'line' && 'bottom-0 h-0.5 rounded-full bg-foreground',
mark.kind === 'box' && 'top-0',
mark.kind === 'box' && variant === 'pills' && 'rounded-full bg-primary',
mark.kind === 'box' && variant === 'segmented' && 'rounded-md bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))]',
mark.kind === 'box' && vertical && 'rounded-lg bg-accent',
)}
style={mark.kind === 'line' ? { width: mark.w, transform: transformOf(mark) } : { width: mark.w, height: mark.h, transform: transformOf(mark) }}
/>
)}
{items.map((it, i) => {
const on = it.value === value;
return (
<button
key={it.value}
type="button"
role="tab"
id={`${id}-tab-${it.value}`}
aria-selected={on}
aria-controls={`${id}-panel-${it.value}`}
tabIndex={on ? 0 : -1}
disabled={it.disabled}
data-value={it.value}
onClick={() => select(it.value)}
style={{ '--i': i } as React.CSSProperties}
className={cn(
'group/tab relative inline-flex shrink-0 select-none items-center gap-2 whitespace-nowrap text-sm font-medium outline-none transition-colors duration-200 disabled:pointer-events-none disabled:opacity-40',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
!inView && 'opacity-0',
variant === 'underline' && cn('h-11 rounded-md px-3', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'),
// Pills and segments are 36 px tall, the hit area reaches 44
(variant === 'pills' || variant === 'segmented') && 'after:absolute after:inset-x-0 after:-inset-y-1 after:content-[""]',
variant === 'pills' && cn('h-9 rounded-full px-4', on ? 'text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground', on && !ready && 'bg-primary'),
variant === 'segmented' && cn('h-9 justify-center rounded-md px-4', fullWidth && 'flex-1', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !ready && 'bg-background shadow-[0_0_0_1px_var(--border)]'),
vertical && cn('h-11 rounded-lg px-3 md:h-10 md:w-full md:justify-start', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground md:hover:bg-[color-mix(in_oklab,var(--accent)_60%,transparent)]', on && !ready && 'bg-accent'),
variant === 'underline' && on && !ready && 'after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:rounded-full after:bg-foreground',
)}
>
{it.icon && <Icon name={it.icon} size={16} className={cn('transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', on ? 'scale-110' : 'opacity-80 group-hover/tab:scale-105')} />}
{it.label}
{it.count !== undefined && (
<span
className={cn(
'grid h-5 min-w-5 place-items-center rounded-full px-1.5 text-[11px] font-semibold tabular-nums transition-colors duration-200',
on && variant === 'pills' ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_22%,transparent)] text-primary-foreground' : 'bg-muted text-muted-foreground',
on && variant !== 'pills' && 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--muted))] text-foreground',
)}
>
{it.count}
</span>
)}
{vertical && <Icon name="chevron-right" size={15} className={cn('ms-auto text-subtle-foreground transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] max-md:hidden motion-reduce:transition-none', on ? 'translate-x-0' : '-translate-x-1 opacity-0 group-hover/tab:translate-x-0 group-hover/tab:opacity-100')} />}
</button>
);
})}
</div>
);
const panels = items.map((it) => (
<div
key={it.value}
role="tabpanel"
id={`${id}-panel-${it.value}`}
aria-labelledby={`${id}-tab-${it.value}`}
hidden={it.value !== value}
tabIndex={0}
data-panel={it.value}
className={cn('outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background rounded-md', !inView && 'opacity-0', panelClassName)}
>
{it.content}
</div>
));
const style = (
<style href="kitfx-tabs" precedence="default">
{STYLES}
</style>
);
if (vertical) {
return (
<div ref={rootRef} className={cn('flex w-full flex-col gap-6 md:flex-row md:gap-10', entering && 'kitfx-tabs-in', className)}>
{style}
<div className="min-w-0 max-md:border-b max-md:border-border md:shrink-0">{list}</div>
<div ref={panelsRef} className="min-w-0 flex-1">
{panels}
</div>
</div>
);
}
return (
<div ref={rootRef} className={cn('w-full', entering && 'kitfx-tabs-in', className)}>
{style}
{variant === 'underline' ? <div className="border-b border-border">{list}</div> : list}
<div ref={panelsRef} className="mt-6">
{panels}
</div>
</div>
);
}Häufige Fragen
Wie installiere ich Reiter?
Mit aiw add @ai-website/tabs. Der Befehl legt die Datei components/ui/tabs.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Reiter?
3 Varianten, Unterstrichen, Pillen und segmentiert und Senkrecht. Du wählst sie mit der Prop variant, zum Beispiel variant="vertical".
Kann ein KI-Assistent Reiter einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Reiter mit aiw_components und installieren sie mit aiw_add @ai-website/tabs. Nenn im Prompt die Variante, etwa variant="pills", dann schreibt der Assistent deine Inhalte direkt in die Props.