Tabs
A shadcn-style tabs component built with Ark UI primitives.
Make changes to your account here.
Change your password here.
Manage your billing and payment details.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsBasicDemo = () => (
<Tabs
defaultValue="account"
variant="default"
className="w-full max-w-md grid justify-center"
>
<TabsList className="w-full justify-start">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent
value="account"
className="min-w-xl border rounded-md p-3 min-h-32"
>
<div className="text-muted-foreground text-sm">
Make changes to your account here.
</div>
</TabsContent>
<TabsContent
value="password"
className="min-w-xl border rounded-md p-3 min-h-32"
>
<div className="text-muted-foreground text-sm">
Change your password here.
</div>
</TabsContent>
<TabsContent
value="billing"
className="min-w-xl border rounded-md p-3 min-h-32"
>
<div className="text-muted-foreground text-sm">
Manage your billing and payment details.
</div>
</TabsContent>
</Tabs>
);
export default TabsBasicDemo;
Installation
npx shadcn@latest add @ark-cn/tabsInstall the dependency required by this primitive:
npm install @ark-ui/reactCopy the component source into your app:
TSXcomponents/ui/tabs.tsx
"use client";
import {
Tabs as TabsPrimitive,
useTabs,
useTabsContext,
} from "@ark-ui/react/tabs";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import { cn } from "@/lib/utils";
export { useTabs, useTabsContext };
export type TabsVariant = "default" | "underline";
type TabsChrome = {
variant: TabsVariant;
orientation: "horizontal" | "vertical";
};
const TabsChromeContext = createContext<TabsChrome | null>(null);
const useTabsChrome = (): TabsChrome => {
const ctx = useContext(TabsChromeContext);
if (!ctx) {
throw new Error(
"Tabs components must be used within <Tabs> or <TabsRootProvider>.",
);
}
return ctx;
};
export type TabsProps = TabsPrimitive.RootProps & { variant?: TabsVariant };
export const Tabs = ({
orientation = "horizontal",
variant = "default",
className,
children,
...props
}: TabsProps) => (
<TabsPrimitive.Root
data-slot="tabs"
data-variant={variant}
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex w-full data-[orientation=horizontal]:flex-col data-[orientation=vertical]:flex-row",
className,
)}
{...props}
>
<TabsChromeContext.Provider value={{ variant, orientation }}>
{children}
</TabsChromeContext.Provider>
</TabsPrimitive.Root>
);
export type TabsRootProviderProps = TabsPrimitive.RootProviderProps & {
variant?: TabsVariant;
orientation?: TabsPrimitive.RootProps["orientation"];
};
export const TabsRootProvider = ({
variant = "default",
orientation = "horizontal",
className,
children,
...props
}: TabsRootProviderProps) => (
<TabsPrimitive.RootProvider
data-slot="tabs-root-provider"
data-variant={variant}
data-orientation={orientation}
className={cn(
"flex w-full data-[orientation=horizontal]:flex-col data-[orientation=vertical]:flex-row",
className,
)}
{...props}
>
<TabsChromeContext.Provider
value={{
variant,
orientation: orientation as TabsChrome["orientation"],
}}
>
{children}
</TabsChromeContext.Provider>
</TabsPrimitive.RootProvider>
);
export type TabsListProps = TabsPrimitive.ListProps & {
withIndicator?: boolean;
indicatorClassName?: string;
};
export const TabsList = ({
className,
withIndicator = true,
indicatorClassName,
children,
...props
}: TabsListProps) => {
const { variant, orientation } = useTabsChrome();
return (
<TabsPrimitive.List
{...props}
data-slot="tabs-list"
data-variant={variant}
data-orientation={orientation}
className={cn(
"relative isolate inline-flex w-fit items-center gap-1 text-muted-foreground",
"data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
"data-[variant=default]:rounded-lg data-[variant=default]:bg-muted data-[variant=default]:p-1",
"data-[variant=underline]:rounded-none data-[variant=underline]:bg-transparent data-[variant=underline]:p-0 data-[variant=underline]:data-[orientation=horizontal]:border-b data-[variant=underline]:data-[orientation=vertical]:border-s data-[variant=underline]:border-border",
className,
)}
>
{children}
{withIndicator ? (
<TabsPrimitive.Indicator
data-slot="tabs-indicator"
data-variant={variant}
data-orientation={orientation}
className={cn(
"absolute z-0",
"start-(--left) top-(--top) h-(--height) w-(--width)",
"transition-[width,height,left,top] duration-200 ease-in-out motion-reduce:transition-none",
"data-[variant=default]:rounded-md data-[variant=default]:bg-background data-[variant=default]:shadow-sm/5 data-[variant=default]:dark:bg-input",
"data-[variant=underline]:rounded-full data-[variant=underline]:bg-primary",
"data-[variant=underline]:data-[orientation=horizontal]:top-auto data-[variant=underline]:data-[orientation=horizontal]:bottom-0 data-[variant=underline]:data-[orientation=horizontal]:h-0.5 data-[variant=underline]:data-[orientation=horizontal]:translate-y-px",
"data-[variant=underline]:data-[orientation=vertical]:start-0 data-[variant=underline]:data-[orientation=vertical]:w-0.5 data-[variant=underline]:data-[orientation=vertical]:-translate-x-px rtl:data-[variant=underline]:data-[orientation=vertical]:translate-x-px",
indicatorClassName,
)}
/>
) : null}
</TabsPrimitive.List>
);
};
export const TabsTrigger = ({
className,
...props
}: TabsPrimitive.TriggerProps) => {
const { variant } = useTabsChrome();
return (
<TabsPrimitive.Trigger
{...props}
data-slot="tabs-trigger"
data-variant={variant}
className={cn(
"cursor-pointer disabled:cursor-default data-disabled:cursor-default relative z-10 inline-flex min-h-8 items-center justify-center whitespace-nowrap rounded-md px-3 font-medium text-sm outline-none transition-[color,background-color,box-shadow] select-none",
"data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-[orientation=vertical]:px-3 data-[orientation=vertical]:py-2",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
"data-disabled:pointer-events-none data-disabled:opacity-64",
"data-[variant=default]:text-muted-foreground data-[variant=default]:data-selected:text-foreground",
"data-[variant=underline]:rounded-none data-[variant=underline]:text-muted-foreground data-[variant=underline]:data-selected:text-foreground",
className,
)}
/>
);
};
export const TabsContent = ({
className,
...props
}: TabsPrimitive.ContentProps) => (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn(
"mt-3 w-full rounded-lg text-sm outline-none",
"data-[orientation=vertical]:mt-0 data-[orientation=vertical]:ms-4",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
className,
)}
{...props}
/>
);
export const TabsIndicator = ({
className,
...props
}: TabsPrimitive.IndicatorProps) => (
<TabsPrimitive.Indicator
data-slot="tabs-indicator-raw"
className={cn(className)}
{...props}
/>
);
export const TabsContext = (props: TabsPrimitive.ContextProps) => (
<TabsPrimitive.Context {...props} />
);
export const TabsRoot = ({
children,
className,
}: {
children: ReactNode;
className?: string;
}) => (
<div className={cn("flex w-full flex-col gap-3", className)}>{children}</div>
);
Update import aliases to match your project setup.
Usage
import * as Tabs from "@/components/ui/tabs"Read exported parts in src/components/ui/tabs.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Basic
Make changes to your account here.
Change your password here.
Manage your billing and payment details.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsBasicDemo = () => (
<Tabs
defaultValue="account"
variant="default"
className="w-full max-w-md grid justify-center"
>
<TabsList className="w-full justify-start">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent
value="account"
className="min-w-xl border rounded-md p-3 min-h-32"
>
<div className="text-muted-foreground text-sm">
Make changes to your account here.
</div>
</TabsContent>
<TabsContent
value="password"
className="min-w-xl border rounded-md p-3 min-h-32"
>
<div className="text-muted-foreground text-sm">
Change your password here.
</div>
</TabsContent>
<TabsContent
value="billing"
className="min-w-xl border rounded-md p-3 min-h-32"
>
<div className="text-muted-foreground text-sm">
Manage your billing and payment details.
</div>
</TabsContent>
</Tabs>
);
export default TabsBasicDemo;
Underline (vertical)
Underline variant uses a sliding indicator bar.
Keyboard: Arrow keys move focus, Enter selects in manual mode.
Supports vertical orientation too.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsUnderlineVerticalDemo = () => (
<Tabs
defaultValue="account"
orientation="vertical"
variant="underline"
className="w-full max-w-md justify-center"
>
<TabsList className="w-min justify-start">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="account" className="border rounded-lg p-3">
<div className="text-muted-foreground text-sm">
Underline variant uses a sliding indicator bar.
</div>
</TabsContent>
<TabsContent value="password" className="border rounded-lg p-3">
<div className="text-muted-foreground text-sm">
Keyboard: Arrow keys move focus, Enter selects in manual mode.
</div>
</TabsContent>
<TabsContent value="billing" className="border rounded-lg p-3">
<div className="text-muted-foreground text-sm">
Supports vertical orientation too.
</div>
</TabsContent>
</Tabs>
);
export default TabsUnderlineVerticalDemo;
Controlled
current: account
Controlled: manage
value + onValueChange.Tabs can be deselectable too.
Indicator animation is driven by Ark CSS vars.
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsControlledDemo = () => {
const [value, setValue] = useState<string | null>("account");
return (
<Tabs
value={value}
onValueChange={(e) => setValue(e.value)}
variant="default"
className="w-full max-w-md justify-center gap-3"
>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
type="button"
variant="outline"
onClick={() => setValue("account")}
>
Select Account
</Button>
<Button
size="sm"
type="button"
variant="outline"
onClick={() => setValue("password")}
>
Select Password
</Button>
<Button
size="sm"
type="button"
variant="outline"
onClick={() => setValue("billing")}
>
Select Billing
</Button>
<div className="text-muted-foreground text-xs">
current: <span className="font-medium text-foreground">{value}</span>
</div>
</div>
<TabsList className="w-full justify-start">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div className="text-muted-foreground text-sm">
Controlled: manage <code className="text-foreground">value</code> +{" "}
<code className="text-foreground">onValueChange</code>.
</div>
</TabsContent>
<TabsContent value="password">
<div className="text-muted-foreground text-sm">
Tabs can be <span className="text-foreground">deselectable</span> too.
</div>
</TabsContent>
<TabsContent value="billing">
<div className="text-muted-foreground text-sm">
Indicator animation is driven by Ark CSS vars.
</div>
</TabsContent>
</Tabs>
);
};
export default TabsControlledDemo;
Disabled
Disabled triggers get
data-disabled.This should never activate.
Focusable + accessible by default.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsDisabledDemo = () => (
<Tabs
defaultValue="account"
variant="default"
className="w-full max-w-md justify-center gap-3"
>
<TabsList className="w-full justify-start">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger disabled value="password">
Password (disabled)
</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div className="text-muted-foreground text-sm">
Disabled triggers get{" "}
<code className="text-foreground">data-disabled</code>.
</div>
</TabsContent>
<TabsContent value="password">
<div className="text-muted-foreground text-sm">
This should never activate.
</div>
</TabsContent>
<TabsContent value="billing">
<div className="text-muted-foreground text-sm">
Focusable + accessible by default.
</div>
</TabsContent>
</Tabs>
);
export default TabsDisabledDemo;
Vertical
Vertical orientation changes arrow-key navigation (Up/Down).
Triggers stretch to full width.
Content shifts to the right.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsVerticalDemo = () => (
<Tabs
defaultValue="account"
orientation="vertical"
variant="default"
className="w-full max-w-md justify-center gap-3"
>
<TabsList className="w-min">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="account" className="border rounded-lg p-3">
<div className="text-muted-foreground text-sm">
Vertical orientation changes arrow-key navigation (Up/Down).
</div>
</TabsContent>
<TabsContent value="password" className="border rounded-lg p-3">
<div className="text-muted-foreground text-sm">
Triggers stretch to full width.
</div>
</TabsContent>
<TabsContent value="billing" className="border rounded-lg p-3">
<div className="text-muted-foreground text-sm">
Content shifts to the right.
</div>
</TabsContent>
</Tabs>
);
export default TabsVerticalDemo;
Lazy mount
Lazy mounted content.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsLazyMountDemo = () => (
<Tabs
defaultValue="account"
lazyMount
unmountOnExit
variant="underline"
className="w-full max-w-md justify-center gap-3"
>
<TabsList className="w-full justify-start">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div className="text-muted-foreground text-sm">Lazy mounted content.</div>
</TabsContent>
<TabsContent value="password">
<div className="text-muted-foreground text-sm">
Unmounted when you switch away.
</div>
</TabsContent>
<TabsContent value="billing">
<div className="text-muted-foreground text-sm">
Mounted again when revisited.
</div>
</TabsContent>
</Tabs>
);
export default TabsLazyMountDemo;
Manual activation
Manual activation: focus doesn't select until Enter/Space.
Try moving focus with arrow keys first.
Then press Enter to select.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsManualActivationDemo = () => (
<Tabs
activationMode="manual"
defaultValue="account"
variant="default"
className="w-full max-w-md justify-center gap-3"
>
<TabsList className="w-full justify-start">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div className="text-muted-foreground text-sm">
Manual activation: focus doesn't select until Enter/Space.
</div>
</TabsContent>
<TabsContent value="password">
<div className="text-muted-foreground text-sm">
Try moving focus with arrow keys first.
</div>
</TabsContent>
<TabsContent value="billing">
<div className="text-muted-foreground text-sm">
Then press Enter to select.
</div>
</TabsContent>
</Tabs>
);
export default TabsManualActivationDemo;
Links
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsLinksDemo = () => (
<Tabs
defaultValue="account"
variant="underline"
className="w-full max-w-md justify-center gap-3"
>
<TabsList className="w-full justify-start">
<TabsTrigger asChild value="account">
<a className="no-underline" href="#account">
Account
</a>
</TabsTrigger>
<TabsTrigger asChild value="password">
<a className="no-underline" href="#password">
Password
</a>
</TabsTrigger>
<TabsTrigger asChild value="billing">
<a className="no-underline" href="#billing">
Billing
</a>
</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div className="text-muted-foreground text-sm">
Triggers can render as links with{" "}
<code className="text-foreground">asChild</code>.
</div>
</TabsContent>
<TabsContent value="password">
<div className="text-muted-foreground text-sm">
Useful for router integration + SEO patterns.
</div>
</TabsContent>
<TabsContent value="billing">
<div className="text-muted-foreground text-sm">
You can also use the Ark navigate API.
</div>
</TabsContent>
</Tabs>
);
export default TabsLinksDemo;
Root provider
selected: account
RootProvider: control from outside via
useTabs.Expose state anywhere you need.
No prop drilling required.
import {
TabsContent,
TabsList,
TabsRootProvider,
TabsTrigger,
useTabs,
} from "@/components/ui/tabs";
const TabsRootProviderDemo = () => {
const api = useTabs({ defaultValue: "account" });
return (
<div className="flex w-full flex-col gap-3 max-w-md justify-center">
<div className="text-muted-foreground text-xs">
selected:{" "}
<span className="font-medium text-foreground">{api.value}</span>
</div>
<TabsRootProvider value={api} variant="default">
<TabsList className="w-full justify-start">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
</TabsList>
<TabsContent value="account">
<div className="text-muted-foreground text-sm">
RootProvider: control from outside via{" "}
<code className="text-foreground">useTabs</code>.
</div>
</TabsContent>
<TabsContent value="password">
<div className="text-muted-foreground text-sm">
Expose state anywhere you need.
</div>
</TabsContent>
<TabsContent value="billing">
<div className="text-muted-foreground text-sm">
No prop drilling required.
</div>
</TabsContent>
</TabsRootProvider>
</div>
);
};
export default TabsRootProviderDemo;
Nested
Nested tabs inside tab content.
Inner content A
Inner content B
Inner content C
Outer tab two content.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TabsNestedDemo = () => (
<Tabs
defaultValue="one"
variant="underline"
className="w-full max-w-md justify-center gap-3"
>
<TabsList className="w-full justify-start">
<TabsTrigger value="one">Outer · One</TabsTrigger>
<TabsTrigger value="two">Outer · Two</TabsTrigger>
</TabsList>
<TabsContent value="one">
<div className="flex flex-col gap-3">
<div className="text-muted-foreground text-sm">
Nested tabs inside tab content.
</div>
<Tabs defaultValue="a" variant="default">
<TabsList>
<TabsTrigger value="a">Inner A</TabsTrigger>
<TabsTrigger value="b">Inner B</TabsTrigger>
<TabsTrigger value="c">Inner C</TabsTrigger>
</TabsList>
<TabsContent value="a">
<div className="text-muted-foreground text-sm">Inner content A</div>
</TabsContent>
<TabsContent value="b">
<div className="text-muted-foreground text-sm">Inner content B</div>
</TabsContent>
<TabsContent value="c">
<div className="text-muted-foreground text-sm">Inner content C</div>
</TabsContent>
</Tabs>
</div>
</TabsContent>
<TabsContent value="two">
<div className="text-muted-foreground text-sm">
Outer tab two content.
</div>
</TabsContent>
</Tabs>
);
export default TabsNestedDemo;
API reference
This component mirrors the upstream Ark UI primitive. All props and DOM behavior are defined by Ark unless you see an ark-cn-only row below.
Tabs
| Prop | Type | Description |
|---|---|---|
| variant? | "default" | "underline" | Chrome variant for list and triggers. |
TabsList
| Prop | Type | Description |
|---|---|---|
| withIndicator? | boolean | Renders the sliding indicator. |
| indicatorClassName? | string | Extra class on the indicator. |
See the ARK UI documentation for the full API.
Accessibility
See the Ark UI documentation for clarification.