Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
iconReactNode16px icon before the label on VerticalTabsTrigger.
value, defaultValue, onValueChangeradix Tabs propsControlled 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