Tabs section for content
Content in tabs with picture and text, keyboard ready. A tab row, a vertical list that advances, tab cards or a centered showcase.
Several stories in one place
Content tabs show several features or use cases in the space of one, each with its own picture and text. They are keyboard ready, so they work for everyone who visits the page.
Image and text puts a tab row with a gliding line above a panel with picture and text side by side. Vertical with autoplay lists tabs with their text on the left that advance on their own with a filling line and a pause, and turns into an accordion on phones. Tab cards make tabs into cards with icon and line, with picture, points and an action in the panel below. Showcase centers a switch above a large picture, a caption card with figures overlaps its lower edge.
Three to five tabs work best, name each one by its benefit. Autoplay suits the area near the opening, the pause keeps visitors in control. The showcase fits product areas that each have a strong picture.
Variants
Details
What Content tabs brings along, read from the registry and the source.
- Category
- Content
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/content-tabs.tsx
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ContentTabsProps interface from components/ui/content-tabs.tsx, with the comments from the code.
variantoptionalContentTabsVariantimage-textverticalcardsshowcase
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodetabsrequiredContentTab[]defaultIndexoptionalnumberintervaloptionalnumbervertical: seconds per tab when it advances on its own, 0 turns it off
Built-in texts
Content tabs writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| tabs | English Topics | German Themen |
| pause | English Pause the slideshow | German Wechsel anhalten |
| play | English Play the slideshow | German Wechsel fortsetzen |
Accessibility
Read from the source of the component.
- Content tabs handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-label, aria-orientation, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles presentation and tablist.
- 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. 3 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, 368 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Button } from '@/components/ui/button';
/*
* Content in tabs, each tab with a picture and text. Tabs follow the APG
* pattern: arrow keys move between them, Home and End jump to the ends,
* every panel is labelled by its tab. Without JavaScript all panels show
* one below the other.
*
* image-text a tab row above a panel with picture and text side by side
* vertical a list of tabs with their text on the left that advances
* on its own, with a pause; an accordion on phones
* cards tabs as cards with icon and line, the panel with points
* and an action below
* showcase a centered switch above a large picture with a caption
* card and figures
*/
export type ContentTabsVariant = 'image-text' | 'vertical' | 'cards' | 'showcase';
export interface ContentTab {
label: string;
icon?: IconName;
/** A short line under the label (vertical, cards) */
summary?: string;
title?: string;
text?: string;
points?: string[];
/** showcase: figures beside the caption */
stats?: { value: string; label: string }[];
action?: { label: string; href: string };
media?: React.ReactNode;
}
const TEXTS = {
en: { tabs: 'Topics', pause: 'Pause the slideshow', play: 'Play the slideshow' },
de: { tabs: 'Themen', pause: 'Wechsel anhalten', play: 'Wechsel fortsetzen' },
};
type Texts = (typeof TEXTS)['en'];
export interface ContentTabsProps {
variant?: ContentTabsVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
tabs: ContentTab[];
defaultIndex?: number;
/** vertical: seconds per tab when it advances on its own, 0 turns it off */
interval?: number;
labels?: Partial<Texts>;
className?: string;
}
const CSS = '@keyframes kitfx-ctabs-fill{from{transform:scaleY(0)}to{transform:scaleY(1)}}';
const focus = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring';
const fillImg = '[&>*]:size-full';
/** Roving focus and selection for one tab list */
function useTabs(count: number, start = 0) {
const [index, setIndex] = useState(Math.min(Math.max(0, start), Math.max(0, count - 1)));
const [hydrated, setHydrated] = useState(false);
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const id = useId();
useEffect(() => setHydrated(true), []);
const onKeyDown = (e: React.KeyboardEvent, i: number, vertical = false) => {
const next = { [vertical ? 'ArrowDown' : 'ArrowRight']: (i + 1) % count, [vertical ? 'ArrowUp' : 'ArrowLeft']: (i - 1 + count) % count, Home: 0, End: count - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setIndex(next);
refs.current[next]?.focus();
};
const tab = (i: number, vertical = false) => ({
ref: (el: HTMLButtonElement | null) => void (refs.current[i] = el),
role: 'tab' as const,
id: `${id}-t${i}`,
'aria-selected': index === i,
'aria-controls': `${id}-p${i}`,
tabIndex: index === i ? 0 : -1,
onClick: () => setIndex(i),
onKeyDown: (e: React.KeyboardEvent) => onKeyDown(e, i, vertical),
});
const panel = (i: number) => ({ role: 'tabpanel' as const, id: `${id}-p${i}`, 'aria-labelledby': `${id}-t${i}`, tabIndex: 0, hidden: hydrated ? index !== i : false });
return { index, setIndex, tab, panel, hydrated };
}
function Head({ p, center = false }: { p: ContentTabsProps; center?: boolean }) {
if (!p.title && !p.eyebrow) return null;
return (
<div className={cn('mb-10 max-w-[44rem] sm:mb-12', center && 'mx-auto text-center')}>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
{p.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{p.title}</h2>}
{p.description && <p className={cn('mt-5 text-pretty text-lead text-muted-foreground', center && 'mx-auto max-w-[36rem]')}>{p.description}</p>}
</div>
);
}
function Points({ points, className }: { points?: string[]; className?: string }) {
if (!points?.length) return null;
return (
<ul className={cn('space-y-2.5', className)}>
{points.map((pt, i) => (
<li key={pt} className="flex gap-3 text-[15px] leading-relaxed text-foreground motion-safe:animate-[kit-rise_0.5s_var(--ease-kit)_both]" style={{ animationDelay: `${120 + i * 60}ms` }}>
<span className="mt-1 flex size-5 shrink-0 items-center justify-center rounded-full bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
<Icon name="check" size={12} strokeWidth={2.4} />
</span>
{pt}
</li>
))}
</ul>
);
}
/* ------------------------------------------------------------------ */
function ImageText({ p, t }: { p: ContentTabsProps; t: Texts }) {
const { index, tab, panel } = useTabs(p.tabs.length, p.defaultIndex);
const list = useRef<HTMLDivElement>(null);
const [mark, setMark] = useState<{ left: number; width: number } | null>(null);
useEffect(() => {
const place = () => {
const on = list.current?.querySelector<HTMLElement>('[aria-selected="true"]');
if (!on || !list.current) return;
setMark({ left: on.offsetLeft, width: on.offsetWidth });
const l = list.current;
if (l.scrollWidth > l.clientWidth) l.scrollTo({ left: on.offsetLeft - l.clientWidth / 2 + on.offsetWidth / 2, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
};
place();
window.addEventListener('resize', place);
return () => window.removeEventListener('resize', place);
}, [index]);
return (
<>
<Head p={p} />
<div ref={list} role="tablist" aria-label={t.tabs} className="kit-no-scrollbar max-sm:kit-fade-end relative -mx-6 flex gap-1 overflow-x-auto border-b border-border px-6 sm:mx-0 sm:gap-2 sm:px-0">
{p.tabs.map((tb, i) => (
<button key={tb.label} type="button" {...tab(i)} className={cn('relative inline-flex min-h-12 shrink-0 cursor-pointer items-center gap-2 px-3 text-[14.5px] font-medium transition-colors sm:px-4', index === i ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', focus, 'focus-visible:-outline-offset-2')}>
{tb.icon && <Icon name={tb.icon} size={16} />}
{tb.label}
</button>
))}
{mark && <span aria-hidden className="absolute bottom-0 h-0.5 rounded-full bg-primary transition-[left,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ left: mark.left, width: mark.width }} />}
</div>
{p.tabs.map((tb, i) => (
<div key={tb.label} {...panel(i)} className={cn('pt-10 outline-none sm:pt-12', focus)}>
<div className="grid grid-cols-[minmax(0,1fr)] items-center gap-10 lg:grid-cols-2 lg:gap-16">
<div data-clip-ok className={cn('relative aspect-[4/3] md:aspect-[16/10] lg:aspect-[4/3] overflow-hidden rounded-2xl border border-border bg-muted shadow-[var(--shadow-md)] motion-safe:animate-[kit-rise_0.6s_var(--ease-kit)]', fillImg)}>{tb.media}</div>
<div className="motion-safe:animate-[kit-rise_0.6s_var(--ease-kit)_0.08s_both]">
{tb.title && <h3 className="text-balance text-[clamp(1.5rem,1.25rem+1vw,2rem)] font-semibold leading-tight tracking-[-0.025em] text-foreground">{tb.title}</h3>}
{tb.text && <p className="mt-4 max-w-[34rem] text-pretty text-[16.5px] leading-relaxed text-muted-foreground">{tb.text}</p>}
<Points points={tb.points} className="mt-6" />
{tb.action && (
<div className="mt-8">
<Button href={tb.action.href} variant="outline" icon="arrow-right" className="max-sm:w-full">
{tb.action.label}
</Button>
</div>
)}
</div>
</div>
</div>
))}
</>
);
}
function Vertical({ p, t }: { p: ContentTabsProps; t: Texts }) {
const { index, setIndex, tab, panel, hydrated } = useTabs(p.tabs.length, p.defaultIndex);
const seconds = p.interval ?? 7;
const [paused, setPaused] = useState(false);
const [hover, setHover] = useState(false);
const [ref, inView] = useInView<HTMLDivElement>();
const [reduce, setReduce] = useState(false);
useEffect(() => setReduce(prefersReducedMotion()), []);
const running = hydrated && seconds > 0 && !paused && !hover && inView && !reduce;
useEffect(() => {
if (!running) return;
const timer = window.setTimeout(() => setIndex((i) => (i + 1) % p.tabs.length), seconds * 1000);
return () => window.clearTimeout(timer);
}, [running, index, seconds, p.tabs.length, setIndex]);
return (
<div ref={ref} onPointerEnter={() => setHover(true)} onPointerLeave={() => setHover(false)} onFocus={() => setHover(true)} onBlur={() => setHover(false)}>
<style>{CSS}</style>
<Head p={p} />
<div className="grid grid-cols-[minmax(0,1fr)] gap-8 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-14">
<div>
<div role="tablist" aria-label={t.tabs} aria-orientation="vertical" className="divide-y divide-border border-y border-border">
{p.tabs.map((tb, i) => {
const on = index === i;
return (
<div key={tb.label} role="presentation">
<button type="button" {...tab(i, true)} className={cn('relative block w-full cursor-pointer py-5 pl-5 pr-2 text-left transition-colors', focus, 'focus-visible:-outline-offset-2')}>
<span aria-hidden className="absolute bottom-3 left-0 top-3 w-0.5 overflow-hidden rounded-full bg-track">
<span
key={`${index}-${running}`}
className={cn('block w-full origin-top bg-primary', on ? 'h-full' : 'h-0')}
style={on && running ? { animation: `kitfx-ctabs-fill ${seconds}s linear forwards` } : undefined}
/>
</span>
<span className="flex items-center gap-3">
{tb.icon && <Icon name={tb.icon} size={18} className={cn('shrink-0 transition-colors', on ? 'text-primary' : 'text-muted-foreground')} />}
<span className={cn('text-[17px] font-semibold tracking-[-0.015em] transition-colors', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>{tb.label}</span>
</span>
<span className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<span className={cn('min-h-0 overflow-hidden', !on && 'invisible')}>
{tb.summary && <span className="mt-2 block max-w-[30rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{tb.summary}</span>}
</span>
</span>
</button>
{/* phones: the picture opens under its tab, like an accordion */}
{on && <div data-clip-ok className={cn('relative mb-5 aspect-[16/10] overflow-hidden rounded-xl border border-border bg-muted lg:hidden', fillImg)}>{tb.media}</div>}
</div>
);
})}
</div>
{seconds > 0 && (
<button type="button" onClick={() => setPaused((x) => !x)} aria-pressed={paused} className={cn('mt-4 inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-lg px-3 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground', focus)}>
<Icon name={paused ? 'play' : 'pause'} size={14} />
{paused ? t.play : t.pause}
</button>
)}
</div>
<div className="relative hidden lg:block">
{p.tabs.map((tb, i) => (
<div key={tb.label} {...panel(i)} className={cn('outline-none', focus)}>
<div data-clip-ok className={cn('relative aspect-[4/3] overflow-hidden rounded-2xl border border-border bg-muted shadow-[var(--shadow-lg)] motion-safe:animate-[kit-rise_0.6s_var(--ease-kit)]', fillImg)}>{tb.media}</div>
{(tb.title || tb.text) && (
<div className="mt-6 max-w-[36rem]">
{tb.title && <h3 className="text-[19px] font-semibold tracking-[-0.015em] text-foreground">{tb.title}</h3>}
{tb.text && <p className="mt-2 text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{tb.text}</p>}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
}
function Cards({ p, t }: { p: ContentTabsProps; t: Texts }) {
const { index, tab, panel } = useTabs(p.tabs.length, p.defaultIndex);
return (
<>
<Head p={p} center />
<div role="tablist" aria-label={t.tabs} className={cn('grid grid-cols-[minmax(0,1fr)] gap-3', p.tabs.length === 3 ? 'md:grid-cols-3' : p.tabs.length === 2 ? 'sm:grid-cols-2' : p.tabs.length === 4 ? 'sm:grid-cols-2 lg:grid-cols-4' : 'sm:grid-cols-2 lg:grid-cols-3')}>
{p.tabs.map((tb, i) => {
const on = index === i;
return (
<button
key={tb.label}
type="button"
{...tab(i)}
className={cn(
'group relative flex cursor-pointer items-start gap-4 overflow-hidden rounded-xl border p-5 text-left transition-[border-color,background-color,box-shadow,transform] duration-300 ease-[var(--ease-kit)]',
on ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] shadow-[var(--highlight),var(--shadow-md)]' : 'border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] hover:-translate-y-0.5 hover:border-border-strong motion-reduce:hover:translate-y-0',
focus,
)}
>
{tb.icon && (
<span className={cn('flex size-10 shrink-0 items-center justify-center rounded-lg border transition-colors duration-300', on ? 'border-transparent bg-primary text-primary-foreground' : 'border-border bg-surface text-foreground')}>
<Icon name={tb.icon} size={19} />
</span>
)}
<span className="min-w-0">
<span className="block text-[15.5px] font-semibold text-foreground">{tb.label}</span>
{tb.summary && <span className="mt-1 block text-pretty text-[13.5px] leading-snug text-muted-foreground">{tb.summary}</span>}
</span>
<span aria-hidden className={cn('absolute inset-x-0 bottom-0 h-0.5 origin-left bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', on ? 'scale-x-100' : 'scale-x-0')} />
</button>
);
})}
</div>
{p.tabs.map((tb, i) => (
<div key={tb.label} {...panel(i)} className={cn('mt-6 overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] outline-none', focus)}>
<div className="grid grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,6fr)_minmax(0,5fr)]">
<div className="order-2 p-6 sm:p-10 lg:order-1 lg:p-12">
{tb.title && <h3 className="text-balance text-[clamp(1.5rem,1.25rem+1vw,2rem)] font-semibold leading-tight tracking-[-0.025em] text-foreground motion-safe:animate-[kit-rise_0.5s_var(--ease-kit)]">{tb.title}</h3>}
{tb.text && <p className="mt-4 max-w-[34rem] text-pretty text-[16px] leading-relaxed text-muted-foreground">{tb.text}</p>}
<Points points={tb.points} className="mt-6" />
{tb.action && (
<div className="mt-8">
<Button href={tb.action.href} icon="arrow-right" className="max-sm:w-full">
{tb.action.label}
</Button>
</div>
)}
</div>
<div data-clip-ok className={cn('relative order-1 aspect-[16/10] overflow-hidden border-b border-border bg-muted lg:order-2 lg:aspect-auto lg:min-h-[22rem] lg:border-b-0 lg:border-l', fillImg)}>{tb.media}</div>
</div>
</div>
))}
</>
);
}
function Showcase({ p, t }: { p: ContentTabsProps; t: Texts }) {
const { index, tab, panel } = useTabs(p.tabs.length, p.defaultIndex);
return (
<>
<Head p={p} center />
<div className="flex justify-center">
<div role="tablist" aria-label={t.tabs} className="kit-no-scrollbar max-sm:kit-fade-end -mx-6 flex max-w-[calc(100%+3rem)] gap-1 overflow-x-auto px-6 sm:mx-0 sm:max-w-full sm:rounded-xl sm:border sm:border-border sm:bg-surface sm:p-1">
{p.tabs.map((tb, i) => (
<button
key={tb.label}
type="button"
{...tab(i)}
className={cn('inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-2 rounded-lg border px-4 text-[14px] font-medium transition-[background-color,color,box-shadow,border-color] duration-300 sm:border-transparent', index === i ? 'border-border bg-background text-foreground shadow-[var(--shadow-sm)]' : 'border-border text-muted-foreground hover:text-foreground', focus)}
>
{tb.icon && <Icon name={tb.icon} size={16} />}
{tb.label}
</button>
))}
</div>
</div>
{p.tabs.map((tb, i) => (
<div key={tb.label} {...panel(i)} className={cn('mx-auto mt-8 max-w-[68rem] outline-none', focus)}>
<div className="relative">
<div aria-hidden className="absolute -inset-x-4 -inset-y-6 -z-10 lg:-inset-x-8 rounded-[3rem] bg-[radial-gradient(closest-side,var(--glow),transparent)] blur-2xl" />
<div data-clip-ok className={cn('relative aspect-[4/3] overflow-hidden rounded-2xl border border-border bg-muted shadow-[var(--shadow-lg)] motion-safe:animate-[kit-rise_0.6s_var(--ease-kit)] sm:aspect-[16/9]', fillImg)}>{tb.media}</div>
{(tb.title || tb.text || tb.stats) && (
<div className="relative mx-3 -mt-10 rounded-xl border border-border bg-background/90 p-5 shadow-[var(--shadow-lg)] backdrop-blur-md motion-safe:animate-[kit-rise_0.6s_var(--ease-kit)_0.12s_both] sm:mx-8 sm:-mt-16 sm:p-6 lg:mx-auto lg:max-w-[52rem]">
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-10">
<div>
{tb.title && <h3 className="text-balance text-[19px] font-semibold tracking-[-0.015em] text-foreground">{tb.title}</h3>}
{tb.text && <p className="mt-1.5 text-pretty text-[15px] leading-relaxed text-muted-foreground">{tb.text}</p>}
</div>
{tb.stats?.length ? (
<dl className="flex gap-8 border-t border-border pt-4 md:border-l md:border-t-0 md:pl-8 md:pt-0">
{tb.stats.map((s) => (
<div key={s.label}>
<dt className="text-[12.5px] text-muted-foreground">{s.label}</dt>
<dd className="text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{s.value}</dd>
</div>
))}
</dl>
) : null}
</div>
</div>
)}
</div>
</div>
))}
</>
);
}
/**
* Content in tabs with picture and text: a tab row, a vertical list that
* advances, tab cards with a panel or a centered switch above a showcase.
* <ContentTabs variant="image-text" tabs={[{ label, title, text, points, media }]} />
*/
export function ContentTabs(props: ContentTabsProps) {
const { variant = 'image-text', className, labels } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
{variant === 'vertical' ? <Vertical p={props} t={t} /> : variant === 'cards' ? <Cards p={props} t={t} /> : variant === 'showcase' ? <Showcase p={props} t={t} /> : <ImageText p={props} t={t} />}
</Section>
);
}Frequent questions
How do I install Content tabs?
With aiw add @ai-website/content-tabs. The command puts components/ui/content-tabs.tsx into your project, the code is yours from then on. It brings along icon, section and button. It needs no further npm packages.
Which variants does Content tabs have?
4 variants, Image and text, Vertical with autoplay, Tab cards and Showcase. You pick one with the variant prop, for example variant="vertical".
Can I translate the texts of Content tabs?
Yes. Content tabs carries 3 texts of its own in English and German, such as “Topics”, “Pause the slideshow” and “Play the slideshow”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Content tabs?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Content tabs with aiw_components and install it with aiw_add @ai-website/content-tabs. Name the variant in your prompt, for example variant="image-text", and the assistant writes your content straight into the props.