Tabs

Free · MIT

Six variants (underline, pill, segmented, enclosed folder tabs, soft, vertical line), a sliding, fading or static indicator, fade or directional slide panels, tones, sizes, shapes, shadows and orientation, with icons, count badges, arrow keys and a one-line items API.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/tabs.json

The file lands in components/goodui/tabs.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/tabs works too.

Usage

import { CalendarDays, FileText, StickyNote } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/goodui/tabs"

export function ClientTabs() {
  return (
    <Tabs defaultValue="bookings" transition="slide">
      <TabsList aria-label="Client details">
        <TabsTrigger value="bookings" icon={<CalendarDays />} count={3}>
          Bookings
        </TabsTrigger>
        <TabsTrigger value="invoices" icon={<FileText />}>
          Invoices
        </TabsTrigger>
        <TabsTrigger value="notes" icon={<StickyNote />}>
          Notes
        </TabsTrigger>
      </TabsList>
      <TabsContent value="bookings">3 upcoming appointments, next on Friday at 09:30.</TabsContent>
      <TabsContent value="invoices">2 paid invoices, $340 total this year.</TabsContent>
      <TabsContent value="notes">Prefers Maya. Sensitive scalp, patch test before color.</TabsContent>
    </Tabs>
  )
}

When to use

Use it for
  • Switching between views of the same thing without leaving the page: a client's Bookings, Invoices and Notes.
  • Settings pages with sections in a side list: variant="vertical-line" or orientation="vertical".
  • Filtering one list by status with counts: All 24, Upcoming 9, Cancelled 2.
  • A compact view switch (Day / Week / Month) above a calendar: variant="segmented".
Reach for something else
  • Navigating between pages with their own URLs: use links in your nav, or bottom-nav on mobile.
  • A stand-alone two or three option toggle that is not tied to panels: use segmented-control.
  • Steps that must be done in order: use stepper.
If you needUse
Page or card sectionsvariant="underline" (default) with indicator="slide"
View switch above a calendar or chartvariant="segmented" size="sm"
Status filters with countsvariant="pill" or "soft" with count on each tab
Folder-style tabs attached to a panelvariant="enclosed" (the panel gets the border and card background)
Settings sidebarvariant="vertical-line"
Equal-width tabs on mobilefullWidth
Calm UI with no motionindicator="none" transition="none"

Examples

One-liner with items

<Tabs
  variant="segmented"
  size="sm"
  listLabel="Calendar view"
  items={[
    { value: "day", label: "Day", content: <DayView /> },
    { value: "week", label: "Week", content: <WeekView /> },
    { value: "month", label: "Month", content: <MonthView /> },
  ]}
/>

Controlled status filter

const [status, setStatus] = React.useState("upcoming")

<Tabs value={status} onValueChange={setStatus} variant="pill" tone="neutral" transition="none">
  <TabsList aria-label="Booking status">
    <TabsTrigger value="upcoming" count={9}>Upcoming</TabsTrigger>
    <TabsTrigger value="past" count={124}>Past</TabsTrigger>
    <TabsTrigger value="cancelled" count={2}>Cancelled</TabsTrigger>
  </TabsList>
</Tabs>

Settings sidebar

<Tabs variant="vertical-line" defaultValue="profile">
  <TabsList aria-label="Settings">
    <TabsTrigger value="profile">Profile</TabsTrigger>
    <TabsTrigger value="billing">Billing</TabsTrigger>
    <TabsTrigger value="team">Team</TabsTrigger>
  </TabsList>
  <TabsContent value="profile"><ProfileForm /></TabsContent>
  <TabsContent value="billing"><BillingForm /></TabsContent>
  <TabsContent value="team"><TeamForm /></TabsContent>
</Tabs>

API reference

Tabs

PropTypeDefault
value
Active tab value (controlled).
string—
defaultValue
Initially active tab when uncontrolled; defaults to the first enabled tab.
string—
onValueChange
Called with the new value when a tab is clicked or picked with the keyboard.
(value: string) => void—
variant
Look of the list and marker: underline, pill, segmented, enclosed (folder tabs), soft or vertical-line.
"underline" | "pill" | "segmented" | "enclosed" | "soft" | "vertical-line""underline"
tone
Color of the active marker, text and count.
"primary" | "neutral" | "success" | "warning" | "danger" | "info""primary"
indicator
How the active marker moves between tabs.
"none" | "slide" | "fade""slide"
transition
How a panel enters when its tab is selected.
"none" | "fade" | "slide""fade"
size"sm" | "md" | "lg""md"
shape
Corner style of tabs and marker. "auto" picks the natural one for the variant.
"auto" | "square" | "rounded" | "pill""auto"
shadow
Shadow under the active marker.
"none" | "sm" | "md" | "glow""none"
orientation
Lays the list out vertically beside the panel. Ignored by enclosed; vertical-line is always vertical.
"horizontal" | "vertical""horizontal"
fullWidth
Stretch the tabs into equal columns.
booleanfalse
sound
Plays "tick" when a tab is selected and the visitor has sounds on (lib/goodui-sounds); pass a sound name to pick another. Off by default.
boolean | SoundName—
items
Shortcut: renders the list and the panels for you.
TabsItem[]—
listLabel
Accessible name of the tab list when using `items`.
string—

Also accepts every <div> attribute.

TabsList

Also accepts every <div> attribute.

TabsTrigger

PropTypeDefault
value*
Matches the TabsContent with the same value.
string—
icon
Icon before the label.
React.ReactNode—
count
Small count after the label.
React.ReactNode—

Also accepts every <button> attribute.

TabsContent

PropTypeDefault
value*
Shown when the TabsTrigger with this value is active.
string—
keepMounted
Keep the panel in the DOM (hidden) when inactive, e.g. to preserve form state.
boolean—

Also accepts every <div> attribute.

Accessibility

Arrow Right / Arrow DownFocuses and selects the next tab, wrapping to the first; disabled tabs are skipped.
Arrow Left / Arrow UpFocuses and selects the previous tab, wrapping to the last.
Home / EndFocuses and selects the first or last enabled tab.
TabMoves focus from the active tab into its panel (only the active tab is in the tab order).
  • WAI-ARIA tabs pattern: role="tablist" with aria-orientation, role="tab" with aria-selected and aria-controls, role="tabpanel" with aria-labelledby. Ids come from useId. aria-controls is only set while that panel is in the DOM (the active one, or keepMounted panels), so it never points at a missing element.
  • Arrow keys follow reading direction: under dir="rtl" ArrowLeft moves to the next tab. The underline, vertical-line marker and slide transition mirror too.
  • Roving tabindex: only the active tab is focusable with Tab; arrows move between tabs and select them (automatic activation).
  • Give the list a name: aria-label on TabsList, or listLabel when using items.
  • Panels are focusable (tabIndex 0) with a visible focus ring, so keyboard users can reach panels that have no focusable content.

Motion

  • indicator="slide" (default): the active marker moves between tabs as a shared layout element on a spring (stiffness 520, damping 40, mass 0.9).
  • indicator="fade": the marker appears in place, fading and scaling from 0.94 in 220 ms. Tab text and background colors transition in 200 ms.
  • transition="fade" (default): the new panel fades and rises 4px in 240 ms. "slide": it slides in 14px from the side you moved toward, in 280 ms.
  • Nothing animates on first render; panel and fade animations start after the first tab change.
  • Reduced motion: the marker jumps instead of sliding and panels appear without animation.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • Use either the parts (Tabs, TabsList, TabsTrigger, TabsContent) or the items prop, not both; children win over items.
  • value strings must match between TabsTrigger and TabsContent. Use value + onValueChange for controlled tabs, defaultValue otherwise; without either, the first enabled tab is selected.
  • Put icons in icon and counts in count, not inside children, so spacing and the active count color work.
  • variant="vertical-line" is always vertical; orientation="vertical" is ignored by enclosed. Vertical tabs stack above the panel below the sm breakpoint.
  • Panels unmount when inactive; add keepMounted on TabsContent to keep form state. Tabs has no built-in text, so there is no labels prop.

FAQ

Can I use tabs only as a filter, without panels?

Yes. Render Tabs and TabsList with triggers and no TabsContent, and react to onValueChange.

Does it scroll when there are too many tabs?

Horizontal lists scroll sideways with the scrollbar hidden; tabs keep their width and do not wrap.

Related