Tabs for React
Underline, pills, segmented and vertical tabs with icons and counts, arrow keys and a sliding indicator.
One of 6 components in Navigation elements
Underline, pills or vertical
Tabs switch between views of the same subject without leaving the page. This component follows the tabs pattern of the WAI, arrow keys move between tabs, Home and End jump to the ends, and only the active tab sits in the tab order, so the keyboard reaches the panel in one step.
Underline is the quiet default for page sections and settings. Pills and segmented fit filters and switches between two to four views, segmented works well for Monthly and Yearly. Vertical puts the tabs in a column for settings with many sections. Every style carries icons and counts, and the indicator slides to the new tab instead of jumping.
Use tabs for content of equal weight, the first tab gets most of the views. Keep labels to one or two words. When the content is a sequence, use a stepper, and when people need to compare, show both views at once.
Details
What Tabs brings along, read from the registry and the source.
- Category
- Navigation elements
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/tabs.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TabsProps interface from components/ui/tabs.tsx, with the comments from the code.
itemsrequiredTabItem[]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.
labelrequiredstringAccessible name of the tab list
fullWidthoptionalbooleanStretch tabs over the full width
panelClassNameoptionalstring
Accessibility
Read from the source of the component.
- Tabs handles the keys Home, End and Space itself.
- Sets aria-controls, aria-label, aria-labelledby, aria-orientation and aria-selected, so screen readers announce purpose and state.
- Uses the roles tab, tablist and tabpanel.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 287 lines
'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>
);
}Frequent questions
How do I install Tabs?
With aiw add @ai-website/tabs. The command puts components/ui/tabs.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Tabs have?
3 variants, Underline, Pills and segmented and Vertical. You pick one with the variant prop, for example variant="vertical".
Can an AI assistant build with Tabs?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Tabs with aiw_components and install it with aiw_add @ai-website/tabs. Name the variant in your prompt, for example variant="pills", and the assistant writes your content straight into the props.