Shadcn Tabs for React and Tailwind
Mutually exclusive panels in one region.
Profile
Manage your profile information
Installation
bunx --bun shadcn@latest add https://kit.dev/r/tabs.jsonpnpm dlx shadcn@latest add https://kit.dev/r/tabs.jsonnpx shadcn@latest add https://kit.dev/r/tabs.jsonyarn shadcn@latest add https://kit.dev/r/tabs.jsonInstall the following dependencies:
bun add @ark-ui/react tailwind-variantspnpm add @ark-ui/react tailwind-variantsnpm install @ark-ui/react tailwind-variantsyarn add @ark-ui/react tailwind-variantsCopy and paste the following code into your project.
"use client";
import {
Tabs as ArkTabs,
useTabs as useArkTabs,
useTabsContext as useArkTabsContext,
} from "@ark-ui/react/tabs";
import type React from "react";
import { tv, type VariantProps } from "tailwind-variants";
import { cn } from "@/lib/utils";
export const useTabs = useArkTabs;
export const useTabsContext = useArkTabsContext;
export const TabsContext = ArkTabs.Context;
const tabsRootClassName = cn(
"flex flex-col gap-2",
"data-[orientation=vertical]:flex-row"
);
export const Tabs = (props: React.ComponentProps<typeof ArkTabs.Root>) => {
const { lazyMount = true, unmountOnExit = true, className, ...rest } = props;
return (
<ArkTabs.Root
className={cn(tabsRootClassName, className)}
data-slot="tabs"
lazyMount={lazyMount}
unmountOnExit={unmountOnExit}
{...rest}
/>
);
};
export const TabsRootProvider = (
props: React.ComponentProps<typeof ArkTabs.RootProvider>
) => {
const { lazyMount = true, unmountOnExit = true, className, ...rest } = props;
return (
<ArkTabs.RootProvider
className={cn(tabsRootClassName, className)}
data-slot="tabs"
lazyMount={lazyMount}
unmountOnExit={unmountOnExit}
{...rest}
/>
);
};
const tabsListVariants = tv({
defaultVariants: {
variant: "default",
},
slots: {
base: [
"relative z-0",
"w-fit",
"text-muted-foreground",
"flex items-center justify-center gap-x-0.5",
"data-[orientation=vertical]:flex-col",
],
indicator: [
"absolute inset-s-0 bottom-0",
"h-(--height) w-(--width)",
// zag-js drives the transition inline; these vars are its contract.
"[--transition-duration:200ms] [--transition-timing-function:ease-in-out]",
"motion-reduce:[--transition-duration:0ms]",
],
},
variants: {
variant: {
default: {
base: ["rounded-lg"],
indicator: ["-z-1 rounded-lg bg-accent"],
},
underline: {
base: [
"data-[orientation=vertical]:px-1",
"data-[orientation=horizontal]:py-1",
"*:data-[slot=tabs-tab]:hover:bg-accent",
],
indicator: [
"z-10",
"absolute bottom-0",
"bg-primary",
"data-[orientation=horizontal]:h-0.5",
"data-[orientation=vertical]:w-0.5",
],
},
},
},
});
interface TabsListProps
extends React.ComponentProps<typeof ArkTabs.List>,
VariantProps<typeof tabsListVariants> {}
export const TabsList = (props: TabsListProps) => {
const { variant = "default", className, children, ...rest } = props;
const { base, indicator } = tabsListVariants({ variant });
return (
<ArkTabs.List
className={cn(base(), className)}
data-slot="tabs-list"
{...rest}
>
{children}
{variant === "underline" ? (
<ArkTabs.Indicator
className={cn(indicator())}
data-slot="tab-indicator"
/>
) : null}
</ArkTabs.List>
);
};
export const TabsTrigger = (
props: React.ComponentProps<typeof ArkTabs.Trigger>
) => {
const { className, ...rest } = props;
return (
<ArkTabs.Trigger
className={cn(
"relative",
"h-9 sm:h-8",
"flex shrink-0 grow items-center justify-center gap-1.5",
"px-[calc(--spacing(2.5)-1px)]",
"whitespace-nowrap font-medium text-sm",
"rounded-lg border border-transparent",
"cursor-pointer",
"transition-[color,box-shadow]",
"data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start",
"hover:text-foreground/72",
"aria-selected:bg-accent aria-selected:text-foreground",
"data-selected:bg-accent data-selected:text-foreground",
"outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-ring/32",
"data-disabled:pointer-events-none data-disabled:opacity-64",
"[&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0",
"motion-reduce:transition-none!",
className
)}
data-slot="tabs-trigger"
{...rest}
/>
);
};
export const TabsContent = (
props: React.ComponentProps<typeof ArkTabs.Content>
) => {
const { className, ...rest } = props;
return (
<ArkTabs.Content
className={cn("flex-1 outline-none", className)}
data-slot="tabs-content"
{...rest}
/>
);
};Update the import paths to match your project setup.
Anatomy
Tabs
├── TabsList
│ ├── TabsTrigger
│ └── Indicator (built in when variant="underline")
└── TabsContentUsage
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs";<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="profile">Profile content</TabsContent>
<TabsContent value="settings">Settings content</TabsContent>
</Tabs>Each trigger value must match a content value.
Controlled
Use value and onValueChange to control the active tab.
Root Provider
Use useTabs with TabsRootProvider when you need the API outside the tree. Pass machine options (defaultValue, orientation, activationMode, …) to useTabs(), not to the provider.
Variant
Set variant on TabsList. The underline variant renders the selection indicator.
Default
Underline
Orientation
Set orientation on Tabs. Horizontal uses Left/Right keys; vertical uses Up/Down.
Horizontal
Vertical
States
Disabled
Disable a trigger with disabled. It is not focusable or selectable.
Examples
Vertical with underline
Combine orientation="vertical" on Tabs with variant="underline" on TabsList.
With icons
Context
Read the selected value with TabsContext or useTabsContext.
Links
Render triggers as links with asChild. Pair with navigate on the root for in-app routing.
Lazy mount
shadcn.io defaults lazyMount and unmountOnExit to true. Panels mount the first time they are selected.
Manual activation
activationMode="manual" moves focus with the arrow keys but only selects on Enter or Space (or pointer).
Guides
Bottom navigation
For a mobile bar fixed to the viewport, use Bottom Navigation. It is Tabs with different parts and styles.
Router links
<Tabs
navigate={(details) => {
router.push(details.node.href);
}}
>NavigateDetails is { value: string; node: HTMLAnchorElement; href: string }.
API Reference
shadcn.io wraps Ark UI Tabs. Defaults below are shadcn.io values. lazyMount and unmountOnExit default to true (Ark: false).
asChild merges props onto a single child element.
Tabs
Root. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
activationMode | "automatic" | "manual" | "automatic" | automatic selects on focus. manual selects on click / Enter / Space. |
asChild | boolean | false | Render the child element instead of a div. |
className | string | - | Class names on the root. |
composite | boolean | - | Treat as composed with other composite widgets. |
defaultValue | string | null | - | Uncontrolled selected tab. |
deselectable | boolean | - | Allow clicking the active tab to clear selection. |
hideMode | "display-none" | "activity" | "display-none" | How to hide mounted-but-inactive content. activity needs React 19+. |
id | string | - | Unique id for the machine. |
ids | Partial<{ root: string; trigger: (value: string) => string; list: string; content: (value: string) => string; indicator: string }> | - | Element ids for composition. |
lazyMount | boolean | true | Mount a panel the first time it is selected. |
loopFocus | boolean | true | Loop keyboard focus from last to first trigger. |
navigate | (details: NavigateDetails) => void | - | Called when a link trigger is chosen. { value, node, href }. |
onFocusChange | (details: FocusChangeDetails) => void | - | Called when focus moves. { focusedValue: string }. |
onValueChange | (details: ValueChangeDetails) => void | - | Called when the selected tab changes. { value: string }. |
orientation | "horizontal" | "vertical" | "horizontal" | Layout and arrow-key direction. |
translations | { listLabel?: string } | - | Localized strings for the tablist. |
unmountOnExit | boolean | true | Unmount a panel after it is hidden. |
value | string | null | - | Controlled selected tab. |
| Attribute | Description |
|---|---|
data-slot | tabs |
data-scope | tabs |
data-part | root |
data-orientation | "horizontal" or "vertical" |
data-focus | Present when the tablist is focused |
TabsList
Container for triggers. Renders a div with role="tablist". The underline variant includes the indicator.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "underline" | "default" | default is a pill on the selected trigger. underline draws a bar via --width / --height. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the list. |
| Attribute | Description |
|---|---|
data-slot | tabs-list |
data-scope | tabs |
data-part | list |
data-orientation | "horizontal" or "vertical" |
Underline indicator:
| Attribute | Description |
|---|---|
data-slot | tab-indicator |
data-scope | tabs |
data-part | indicator |
data-orientation | "horizontal" or "vertical" |
| CSS variable | Description |
|---|---|
--width | Width of the selected trigger |
--height | Height of the selected trigger |
--left / --top | Indicator position (also exposed as translate) |
TabsTrigger
Selects a tab. Renders a button with role="tab".
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | required | Id that must match a TabsContent. |
disabled | boolean | - | Disable this trigger. |
asChild | boolean | false | Merge onto a single child (for example a or Link). |
className | string | - | Class names on the trigger. |
| Attribute | Description |
|---|---|
data-slot | tabs-trigger |
data-scope | tabs |
data-part | trigger |
data-selected | Present when this tab is selected |
data-disabled | Present when disabled |
data-focus | Present when focused |
data-orientation | "horizontal" or "vertical" |
aria-selected | "true" or "false" |
TabsContent
Panel shown when its trigger is selected. Renders a div with role="tabpanel".
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | required | Id that must match a TabsTrigger. |
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the panel. |
| Attribute | Description |
|---|---|
data-slot | tabs-content |
data-scope | tabs |
data-part | content |
data-selected | Present when this panel is selected |
data-orientation | "horizontal" or "vertical" |
TabsRootProvider
Root alternative that takes the API from useTabs. Renders a div.
| Prop | Type | Default | Description |
|---|---|---|---|
value | UseTabsReturn | required | Return value of useTabs(). |
asChild | boolean | false | Render the child element instead of a div. |
className | string | - | Class names on the root. |
hideMode | "display-none" | "activity" | "display-none" | How to hide mounted-but-inactive content. |
lazyMount | boolean | true | Mount a panel the first time it is selected. |
unmountOnExit | boolean | true | Unmount a panel after it is hidden. |
Pass defaultValue, orientation, activationMode, and other machine options to useTabs(), not to TabsRootProvider.
| Attribute | Description |
|---|---|
data-slot | tabs |
data-scope | tabs |
data-part | root |
useTabs
Creates the tabs API for TabsRootProvider. Accepts the same options as Tabs except layout-only props.
const tabs = useTabs({ defaultValue: "profile" });
tabs.setValue("settings");TabsContext / useTabsContext
Render-prop or hook access to tabs state. Use inside Tabs or TabsRootProvider.
| Property | Type | Description |
|---|---|---|
value | string | null | Selected tab. |
focusedValue | string | null | Focused trigger. |
setValue | (value: string) => void | Select a tab. |
clearValue | () => void | Clear the selection. |
focus | () => void | Focus the selected trigger. |
selectNext | (fromValue?: string) => void | Select the next tab. |
selectPrev | (fromValue?: string) => void | Select the previous tab. |
getTriggerState | (props: { value: string; disabled?: boolean }) => TriggerState | State for one trigger (selected, disabled, focused). |
TabsContext children: (context) => ReactNode.
Accessibility
Complies with the Tabs WAI-ARIA design pattern. Only the selected panel is in the tab order (unless you keep others mounted and visible).
Keyboard support
| Key | Description |
|---|---|
Tab | Moves into the tablist (active trigger) or out to the next control. |
ArrowRight | Next trigger (horizontal). Selects it when activationMode is automatic. |
ArrowLeft | Previous trigger (horizontal). |
ArrowDown | Next trigger (vertical). |
ArrowUp | Previous trigger (vertical). |
Home | First trigger. |
End | Last trigger. |
Enter / Space | Select the focused trigger (manual activation, and always via pointer). |