# Vertical Tabs

> Vertical tabs with arrow-key navigation, icons and badges following the ARIA tabs pattern.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/vertical-tabs
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/vertical-tabs.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/vertical-tabs-demo.tsx"
"use client"

import { Bell, CreditCard, Shield, User } from "lucide-react"

import { VerticalTabs, type VerticalTab } from "@/components/vertical-tabs"

function Panel({ title, text }: { title: string; text: string }) {
  return (
    <div className="grid gap-2 rounded-xl border bg-card p-5">
      <h3 className="text-sm font-semibold">{title}</h3>
      <p className="text-sm text-muted-foreground">{text}</p>
      <div className="mt-2 h-20 rounded-lg bg-muted/60" />
    </div>
  )
}

const tabs: VerticalTab[] = [
  { id: "profile", label: "Profile", icon: <User />, content: <Panel title="Profile" text="Update your name, avatar and public bio." /> },
  { id: "security", label: "Security", icon: <Shield />, content: <Panel title="Security" text="Password, two-factor authentication and active sessions." /> },
  { id: "notifications", label: "Notifications", icon: <Bell />, badge: "3", content: <Panel title="Notifications" text="Choose what lands in your inbox and what stays quiet." /> },
  { id: "billing", label: "Billing", icon: <CreditCard />, content: <Panel title="Billing" text="Plan, invoices and payment methods." /> },
]

export default function VerticalTabsDemo() {
  return <VerticalTabs tabs={tabs} className="w-full max-w-2xl" />
}
```

## Source

```tsx title="components/vertical-tabs.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type VerticalTab = {
  id: string
  label: string
  icon?: React.ReactNode
  badge?: string
  content: React.ReactNode
}

export interface VerticalTabsProps extends Omit<React.ComponentProps<"div">, "children" | "defaultValue" | "onChange"> {
  tabs: VerticalTab[]
  value?: string
  defaultValue?: string
  onValueChange?: (id: string) => void
}

export function VerticalTabs({ tabs, value, defaultValue, onValueChange, className, ...props }: VerticalTabsProps) {
  const [inner, setInner] = React.useState(defaultValue ?? tabs[0]?.id)
  const current = value ?? inner
  const base = React.useId()
  const list = React.useRef<HTMLDivElement>(null)

  const select = (id: string) => {
    if (value === undefined) setInner(id)
    onValueChange?.(id)
  }

  const onKeyDown = (event: React.KeyboardEvent) => {
    const index = tabs.findIndex((tab) => tab.id === current)
    let next = index
    if (event.key === "ArrowDown") next = (index + 1) % tabs.length
    else if (event.key === "ArrowUp") next = (index - 1 + tabs.length) % tabs.length
    else if (event.key === "Home") next = 0
    else if (event.key === "End") next = tabs.length - 1
    else return
    event.preventDefault()
    select(tabs[next].id)
    list.current?.querySelectorAll<HTMLElement>("[role=tab]")[next]?.focus()
  }

  return (
    <div className={cn("flex gap-6", className)} {...props}>
      <div ref={list} role="tablist" aria-orientation="vertical" onKeyDown={onKeyDown} className="flex w-48 shrink-0 flex-col gap-1">
        {tabs.map((tab) => {
          const selected = tab.id === current
          return (
            <button
              key={tab.id}
              id={`${base}-tab-${tab.id}`}
              type="button"
              role="tab"
              aria-selected={selected}
              aria-controls={`${base}-panel-${tab.id}`}
              tabIndex={selected ? 0 : -1}
              onClick={() => select(tab.id)}
              className="relative flex h-10 items-center gap-2.5 rounded-lg px-3 text-left text-sm font-medium text-muted-foreground transition-colors hover:text-foreground aria-selected:bg-accent aria-selected:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              <span
                aria-hidden
                className={cn(
                  "absolute top-2 bottom-2 left-0 w-0.5 rounded-full bg-primary transition-opacity duration-200",
                  selected ? "opacity-100" : "opacity-0"
                )}
              />
              {tab.icon && <span className="[&_svg]:size-4">{tab.icon}</span>}
              <span className="flex-1">{tab.label}</span>
              {tab.badge && <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">{tab.badge}</span>}
            </button>
          )
        })}
      </div>
      <div className="min-w-0 flex-1">
        {tabs.map((tab) =>
          tab.id === current ? (
            <div
              key={tab.id}
              id={`${base}-panel-${tab.id}`}
              role="tabpanel"
              aria-labelledby={`${base}-tab-${tab.id}`}
              tabIndex={0}
              className="animate-in rounded-lg duration-200 fade-in slide-in-from-right-2 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              {tab.content}
            </div>
          ) : null
        )}
      </div>
    </div>
  )
}
```
