Tabs
SaveSwitch between related panels with an underline that follows the active tab on a hairline, sliding on click and jumping on keyboard selection.
- Category
- Core
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Tabs preview
Three deploys this week, all healthy.
Installation
npx @seamlessui/cli add tabsPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add tabs --dry-runUsage
tabs-example.tsx
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/seamless/ui/tabs";
export default function TabsExample() {
return (
<Tabs defaultValue="overview" className="w-full max-w-md">
<TabsList aria-label="Project">
<TabsTab value="overview">Overview</TabsTab>
<TabsTab value="activity">Activity</TabsTab>
<TabsTab value="settings">Settings</TabsTab>
</TabsList>
<TabsPanel value="overview">Three deploys this week, all healthy.</TabsPanel>
<TabsPanel value="activity">Ada merged “Add rate limits” two hours ago.</TabsPanel>
<TabsPanel value="settings">Builds run on every push to main.</TabsPanel>
</Tabs>
);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| Tabs.defaultValue | any | 0 | Value of the tab that is active initially, when uncontrolled. |
| Tabs.value | any | — | Value of the active tab, when controlled. Pair with onValueChange. |
| Tabs.onValueChange | (value: any, details) => void | — | Called when the active tab changes. |
| Tabs.orientation | "horizontal" | "vertical" | "horizontal" | Lays the tabs out in a row or a column and sets which arrow keys move between them. |
| TabsList.activateOnFocus | boolean | false | Activates a tab as soon as arrow keys focus it, instead of on Enter or Space. |
| TabsList.loopFocus | boolean | true | Wraps arrow-key focus from the last tab to the first and back. |
| TabsTab.value | any | — | Identifies the tab. Must match a TabsPanel value. |
| TabsTab.disabled | boolean | false | Skips the tab during keyboard navigation and ignores clicks. |
| TabsPanel.value | any | — | Shown while the tab with the same value is active. |
| TabsPanel.keepMounted | boolean | false | Keeps the panel in the DOM, hidden, while another tab is active. |
Accessibility
- Base UI renders the tablist, tab, and tabpanel roles and links each tab to its panel with aria-controls and aria-labelledby.
- Arrow keys move focus between tabs (Left/Right, or Up/Down when vertical); Home and End jump to the first and last tab.
- Only the active tab is in the tab order, so Tab moves from the tab list straight into the panel.
- The active tab is marked with aria-selected and an underline bar, not only a color change.
- Give TabsList an aria-label that names the group.
- The underline slides with a 350ms spring only for pointer selection; keyboard selection moves it without animating, and with reduced motion it never slides. Panels fade in over 150ms, removed with reduced motion.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/ui/tabs.tsxtabs- npm packages: @base-ui/react@^1.9.0React and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/tabs/0.2.0.json.
ui/tabs.tsxShowHide
ui/tabs.tsx
"use client";
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import { type KeyboardEvent, type PointerEvent, useState } from "react";
function cx(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(" ");
}
// A critically damped spring (bounce 0) settling in about 350ms, sampled for CSS linear().
const spring =
"ease-[linear(0,0.044,0.144,0.263,0.384,0.495,0.593,0.675,0.744,0.8,0.844,0.88,0.908,0.929,0.946,0.959,0.969,0.977,0.982,0.987,1)]";
export interface TabsProps extends Omit<BaseTabs.Root.Props, "className"> {
className?: string;
}
/**
* Groups a tab list with its panels. Set `defaultValue` (or `value` + `onValueChange`) to a tab's `value`.
* Tabs use manual activation: arrow keys move focus, Enter or Space selects.
*/
export function Tabs({ className, ...props }: TabsProps) {
return (
<BaseTabs.Root
className={cx("flex flex-col gap-4 data-[orientation=vertical]:flex-row data-[orientation=vertical]:gap-6", className)}
{...props}
/>
);
}
export interface TabsListProps extends Omit<BaseTabs.List.Props, "className"> {
className?: string;
}
/**
* The row of tabs on a hairline. An underline follows the active tab: it slides with a spring
* when a tab is clicked, and jumps without animating when the selection changes from the keyboard.
*/
export function TabsList({ className, children, onPointerDown, onKeyDown, ...props }: TabsListProps) {
const [input, setInput] = useState<"pointer" | "keyboard">("pointer");
return (
<BaseTabs.List
data-input={input}
onPointerDown={(event: PointerEvent<HTMLDivElement>) => {
setInput("pointer");
onPointerDown?.(event as never);
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
setInput("keyboard");
onKeyDown?.(event as never);
}}
className={cx(
"group/tabs relative z-0 flex gap-1 border-zinc-950/10 dark:border-white/10",
"data-[orientation=horizontal]:border-b data-[orientation=horizontal]:pb-1.5",
"data-[orientation=vertical]:flex-col data-[orientation=vertical]:border-l data-[orientation=vertical]:pl-1.5",
className,
)}
{...props}
>
{children}
<BaseTabs.Indicator
className={cx(
"absolute -z-10 rounded-full bg-zinc-950 dark:bg-white",
"transition-none group-data-[input=pointer]/tabs:transition-[translate,width,height] group-data-[input=pointer]/tabs:duration-350",
spring,
"motion-reduce:transition-none group-data-[input=pointer]/tabs:motion-reduce:transition-none",
// Horizontal: a 2px bar on the hairline under the active tab.
"data-[orientation=horizontal]:-bottom-px data-[orientation=horizontal]:left-0 data-[orientation=horizontal]:h-0.5",
"data-[orientation=horizontal]:w-(--active-tab-width) data-[orientation=horizontal]:translate-x-(--active-tab-left)",
// Vertical: a 2px bar on the hairline beside the active tab.
"data-[orientation=vertical]:top-0 data-[orientation=vertical]:-left-px data-[orientation=vertical]:w-0.5",
"data-[orientation=vertical]:h-(--active-tab-height) data-[orientation=vertical]:translate-y-(--active-tab-top)",
)}
/>
</BaseTabs.List>
);
}
export interface TabsTabProps extends Omit<BaseTabs.Tab.Props, "className"> {
className?: string;
}
/** A single tab. Its `value` must match a `TabsPanel`'s `value`. */
export function TabsTab({ className, ...props }: TabsTabProps) {
return (
<BaseTabs.Tab
className={cx(
"relative inline-flex h-8 cursor-pointer items-center justify-center gap-2 rounded-md px-2.5 text-sm font-medium whitespace-nowrap select-none",
"text-zinc-500 transition-[background-color,color] duration-150 ease-out motion-reduce:transition-none",
"hover:bg-zinc-950/[0.04] hover:text-zinc-950 data-active:text-zinc-950",
"dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white dark:data-active:text-white",
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-500",
"data-disabled:pointer-events-none data-disabled:opacity-45",
"data-[orientation=vertical]:justify-start [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
export interface TabsPanelProps extends Omit<BaseTabs.Panel.Props, "className"> {
className?: string;
}
/** Content shown while the tab with the same `value` is active. Fades in quickly when shown. */
export function TabsPanel({ className, ...props }: TabsPanelProps) {
return (
<BaseTabs.Panel
className={cx(
"rounded-lg text-sm leading-relaxed text-zinc-600 dark:text-zinc-400",
"transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
className,
)}
{...props}
/>
);
}