Vertical tabs
Tabs down the left for settings pages, with the content beside them.
npx shadcn@latest add https://ui.goodtabs.co/r/vertical-tabs.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/vertical-tabs
Workspace name, URL and time zone.
Four settings tabs with short content. Narrow the frame to see them stack.
With icons
Priya Nair, priya@northwind.app.
A 16px icon before each label.
Narrow list
2 live keys, last used 3 minutes ago.
The list at 128px instead of 192px, through className on the list.
Usage
import {
VerticalTabs,
VerticalTabsContent,
VerticalTabsList,
VerticalTabsTrigger,
} from "@/components/ui/vertical-tabs"
<VerticalTabs defaultValue="general">
<VerticalTabsList aria-label="Settings">
<VerticalTabsTrigger value="general">General</VerticalTabsTrigger>
<VerticalTabsTrigger value="members">Members</VerticalTabsTrigger>
</VerticalTabsList>
<VerticalTabsContent value="general">...</VerticalTabsContent>
<VerticalTabsContent value="members">...</VerticalTabsContent>
</VerticalTabs>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| icon | ReactNode | 16px icon before the label on VerticalTabsTrigger. | |
| value, defaultValue, onValueChange | radix Tabs props | Controlled or uncontrolled, on VerticalTabs. |
Source
vertical-tabs/vertical-tabs.tsx
"use client";
import * as React from "react";
import { Tabs as TabsPrimitive } from "radix-ui";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
/** Below this container width the list becomes a horizontal row above the content. */
const STACK_BELOW = 448;
type RootProps = Omit<React.ComponentProps<typeof Tabs>, "orientation">;
/**
* Root. Controlled or uncontrolled through radix: value, defaultValue, onValueChange.
* The list sits on the left from 448px of container width and stacks above the content under that,
* switching the radix orientation so arrow keys match the layout.
*/
function VerticalTabs({ className, ...props }: RootProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [stacked, setStacked] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) => setStacked(entry.contentRect.width < STACK_BELOW));
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} data-slot="vertical-tabs" className={cn("@container w-full", className)}>
<Tabs
orientation={stacked ? "horizontal" : "vertical"}
className="flex-col gap-4 @md:flex-row @md:items-start @md:gap-6"
{...props}
/>
</div>
);
}
function VerticalTabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="vertical-tabs-list"
className={cn(
"flex w-full shrink-0 flex-row gap-px overflow-x-auto text-muted-foreground @md:w-48 @md:flex-col @md:overflow-visible",
className,
)}
{...props}
/>
);
}
type TriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger> & {
/** 16px icon before the label. */
icon?: React.ReactNode;
};
function VerticalTabsTrigger({ className, icon, children, ...props }: TriggerProps) {
return (
<TabsPrimitive.Trigger
data-slot="vertical-tabs-trigger"
className={cn(
"flex h-8 shrink-0 items-center gap-2 rounded-sm px-2 text-sm whitespace-nowrap transition-colors duration-100 ease-out outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-muted data-[state=active]:font-medium data-[state=active]:text-foreground @md:w-full @md:justify-start [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
>
{icon && <span className="flex shrink-0 items-center">{icon}</span>}
<span className="truncate">{children}</span>
</TabsPrimitive.Trigger>
);
}
function VerticalTabsContent({ className, ...props }: React.ComponentProps<typeof TabsContent>) {
return (
<TabsContent
data-slot="vertical-tabs-content"
className={cn("min-w-0 focus-visible:ring-2 focus-visible:ring-ring/50", className)}
{...props}
/>
);
}
export { VerticalTabs, VerticalTabsList, VerticalTabsTrigger, VerticalTabsContent };
For AI agents
Hand your agent this page, the markdown at /components/vertical-tabs/prompt.md, or the Copy AI prompt button above.
When to use
- Settings and account pages with four to ten sections on one route
- When the list of sections should stay in view while each panel scrolls
- Any form-heavy page where a horizontal tab row would run out of room
When not to use
- For two or three short sections, underline-tabs or segmented-tabs are lighter
- For links between routes, use sidebar-nav; these are panels, not pages
- Inside a narrow card: it stacks, but a plain tab row reads better there
Agent notes
- Radix Tabs underneath, through the shadcn tabs root and content; value, defaultValue and onValueChange work as usual
- The layout follows the container, not the viewport: under 448px the list becomes a scrolling row above the content and the radix orientation flips so arrow keys still match
- The list is w-48 by default; override with className on VerticalTabsList, for example @md:w-32
- Items are the sidebar-nav spec: 32px, rounded-sm, bg-muted when active, no accent bar