Tabs
Tabs with 3 list variants (solid segmented, line underline and pill) - triggers pick up the variant automatically.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export default function TabsDemo() {
return (
<Tabs defaultValue="overview" className="max-w-md">
<TabsList variant="line">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="pt-2 text-sm text-muted-foreground">
Requests, latency and uptime at a glance.
</TabsContent>
<TabsContent value="logs" className="pt-2 text-sm text-muted-foreground">
Live tail of your deployment logs.
</TabsContent>
<TabsContent value="settings" className="pt-2 text-sm text-muted-foreground">
Region and scaling configuration.
</TabsContent>
</Tabs>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/tabsDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install class-variance-authority@^0.7.1 radix-ui@^1.6.1Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/tabs.tsx"use client"
import * as React from "react"
import { Tabs as TabsPrimitive } from "@/components/ui/primitives"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
const tabsListVariants = cva("inline-flex w-fit items-center text-muted-foreground", {
variants: {
variant: {
solid: "justify-center gap-1 rounded-lg bg-surface-3 p-1",
line: "gap-4 border-b border-border",
pill: "justify-center gap-1",
},
size: {
sm: "h-8",
md: "h-9",
},
},
defaultVariants: { variant: "solid", size: "md" },
})
type TabsSize = "sm" | "md"
type TabsVariant = "solid" | "line" | "pill"
const TabsContext = React.createContext<{ variant: TabsVariant; size: TabsSize }>({
variant: "solid",
size: "md",
})
interface TabsListProps
extends React.ComponentProps<typeof TabsPrimitive.List>,
VariantProps<typeof tabsListVariants> {}
/* WCAG 1.4.4 (200% text): the tab strip is `w-fit`, so as text grew it widened
the PAGE - at 375px the body went to 429px and the whole page scrolled
horizontally. The fix is to move scrolling INSIDE the strip itself.
Why a wrapper div: putting `overflow-x-auto` on the list itself is not
enough. Per CSS, once one axis is `auto` the other cannot stay `visible`
and also becomes `auto`; the VERTICAL axis would be clipped too, and in the
`line` variant the trigger and the list share the same height (h-9), so the
focus ring (3px) would be cut off. The `-m-1 p-1` pair on the wrapper opens
4px on all four sides (the smallest step on the scale, enough for a 3px
ring) and leaves the outer measurements THE SAME - the negative margin
cancels the padding.
The scrollbar is hidden: if `overflow-x-auto` painted a visible bar the
strip height would change and the fixed h-8/h-9 layout would break. Keyboard
access is unaffected - Radix moves with arrow keys and scrolls the focused
tab into view; mouse and trackpad scrolling still work. */
function TabsList({ className, variant, size, ...props }: TabsListProps) {
const v = variant ?? "solid"
const s = size ?? "md"
return (
<TabsContext.Provider value={{ variant: v, size: s }}>
<div
data-slot="tabs-list-viewport"
className="-m-1 max-w-full overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={v}
data-size={s}
className={cn(tabsListVariants({ variant, size, className }))}
{...props}
/>
</div>
</TabsContext.Provider>
)
}
const tabsTriggerVariants = cva(
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium outline-none transition-colors duration-(--motion-fast) focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:shrink-0 [&_i]:text-base [&_i]:leading-none",
{
variants: {
variant: {
solid:
"rounded-md px-3 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
line: "-mb-px border-b-2 border-transparent px-1 data-[state=active]:border-brand data-[state=active]:text-foreground",
pill: "rounded-full px-3.5 data-[state=active]:bg-brand data-[state=active]:text-brand-foreground",
},
size: {
sm: "text-xs",
md: "text-sm",
},
},
compoundVariants: [
{ variant: "solid", size: "sm", class: "h-6" },
{ variant: "solid", size: "md", class: "h-7" },
{ variant: "line", size: "sm", class: "h-8" },
{ variant: "line", size: "md", class: "h-9" },
{ variant: "pill", size: "sm", class: "h-6" },
{ variant: "pill", size: "md", class: "h-7" },
],
defaultVariants: { variant: "solid", size: "md" },
}
)
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
const { variant, size } = React.useContext(TabsContext)
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(tabsTriggerVariants({ variant, size, className }))}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, type TabsListProps }Manual installs skip the @ai2/tokens theme - add the token CSS from the theming guide or the tone colors will be missing.
Usage
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
<Tabs defaultValue="overview">
<TabsList variant="line">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="overview">Overview content</TabsContent>
<TabsContent value="settings">Settings content</TabsContent>
</Tabs>Set variant once on TabsList; every trigger inside inherits it. Each TabsContent pairs with a trigger through its value.
Examples
Solid
The default segmented style - a raised active tab on a surface-3 track.
Line
A brand-colored underline on the active tab - the docs-page classic.
Pill
Disabled tab
Sizes
size sets the list height and trigger text scale. md is the default; sm suits dense toolbars. Like variant, it flows from the list to every trigger via context.
Props
variant and size live on TabsList. All four parts also accept their radix Tabs props - defaultValue, value / onValueChange on the root, value on triggers and content.
TabsList props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "solid" | "line" | "pill" | "solid" | Visual style of the tab list. Triggers inherit the variant from their list via context - no prop needed on TabsTrigger. |
size | "sm" | "md" | "md" | Scale of the tab list. md is the default height with text-sm; sm shrinks the list and triggers to text-xs for dense toolbars. Triggers inherit the size from their list via context. |
ai2 Tabs: three tab styles for React, one variant prop
The ai2 Tabs is a shadcn-compatible tabs component for React, built on the radix-ui Tabs primitive and styled with Tailwind CSS v4. One variant prop on the list switches between three complete looks: a solid segmented control on a raised track, a line style with a brand-colored underline, and a pill style with a filled brand active tab. Triggers read the variant from the list through React context, so you never repeat it per tab.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Tabs?
It is a four-part composition: Tabs, TabsList, TabsTrigger and TabsContent. The anatomy matches shadcn/ui exactly, so existing snippets, muscle memory and AI agents keep working without changes; the only additions are the variant and size props on the list.
The root supports controlled and uncontrolled state through radix value, onValueChange and defaultValue, plus full keyboard navigation. Styling comes from the ai2 token file, so all three variants follow your theme in both light and dark mode.
Why use it
- Accessible by construction: The radix-ui Tabs primitive implements the WAI-ARIA tabs pattern: tablist, tab and tabpanel roles, aria-selected state and focus management come for free.
- Keyboard activation: Arrow keys move focus between triggers and activate them, Home and End jump to the first and last tab, and disabled triggers are skipped automatically.
- Three variants, one prop: solid, line and pill cover segmented controls, docs-style underline navigation and filter chips. Set variant once on TabsList; every trigger inherits it via context.
- Token-driven styling: The solid track uses surface-3, the line and pill actives use the brand token, and the color transition runs on --motion-fast, so tabs match the rest of your theme automatically.
- Agent-readable metadata: The registry item describes the variant matrix and intended use in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds the component, its dependencies and the @ai2/tokens theme to your project.
- Context-based variant inheritance: TabsList provides the variant through React context and TabsTrigger consumes it, so switching a whole tab set from solid to line is a one-word change.
- Controlled and uncontrolled state: Use defaultValue for simple cases or drive value and onValueChange from React state to sync tabs with the URL or other UI.
- Disabled triggers: Any TabsTrigger accepts disabled, which removes it from pointer events and mutes it while keyboard navigation skips it.
- Focus-visible ring: Triggers show a 3px ring on keyboard focus only, so mouse users get a clean look and keyboard users never lose their place.
- Data attributes for styling: The list exposes data-slot, data-variant and data-size, and triggers expose data-state, so you can restyle active and inactive tabs from CSS without forking the component.
Production tips
- Pick the variant by context: Use solid for app-like view switchers, line for page sections and settings screens, and pill for filter rows. Mixing variants on one screen usually reads as noise.
- Sync tabs with the URL: For page-level tabs, drive value from the router query and update it in onValueChange so deep links and the back button land on the right tab.
- Keep trigger labels short: Triggers are whitespace-nowrap, so long labels widen the list instead of wrapping. One or two words per tab keeps the row scannable.
- Do not overload the tab count: Past five or six tabs, users stop scanning. Group related content or switch to a Select for overflow instead of shrinking the labels.
- Put spacing on TabsContent: The content part is unstyled apart from layout. Add pt-2 or similar per content panel, as the examples on this page do, rather than padding the root.
Works with the rest of ai2
Tabs frame other registry components well. Wrap a tab set in an ai2 Card when the panels need their own surface, add an ai2 Badge inside a trigger for counts like Errors 14, and render an ai2 Table inside a panel for per-tab datasets.
When sections are a list rather than peers, reach for the ai2 Accordion instead, and use the ai2 Toggle Group when the choice changes a setting rather than revealing a panel. Everything shares one token source, so combinations stay visually consistent in both modes.