Tabs indicator

Free · MIT

Tabs whose indicator glides between tabs on a shared-layout spring (underline with hover highlight, pill, or segmented with icons), with arrow-key navigation and panels that slide in the direction of travel while the height eases to fit.

MarkdownOpen in v0
Open in v0

Installation

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

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

Usage

import { TabsIndicator } from "@/components/goodui/tabs-indicator"

export function BookingTabs({ upcoming, pending, past }: { upcoming: React.ReactNode; pending: React.ReactNode; past: React.ReactNode }) {
  return (
    <TabsIndicator
      listLabel="Booking status"
      defaultValue="upcoming"
      items={[
        { value: "upcoming", label: "Upcoming", badge: 4, content: upcoming },
        { value: "pending", label: "Pending", badge: 2, content: pending },
        { value: "past", label: "Past", content: past },
        { value: "cancelled", label: "Cancelled", disabled: true },
      ]}
    />
  )
}

When to use

Use it for
  • Switching between views of the same data: Upcoming / Pending / Past bookings.
  • Short tab sets where a gliding indicator and sliding panels make the change easy to follow.
  • Range pickers above a chart (Day / Week / Month / Year) with the pill variant and your own content below.
Reach for something else
  • Choosing a setting or filter value without panels: use segmented-control (radiogroup semantics).
  • Moving between pages of the app: use links in your nav, or bottom-nav on mobile.
  • Needing vertical tabs, enclosed folder tabs or more variants: use the tabs core component.
If you needUse
Section tabs under a headingvariant="underline" (default, with hover highlight)
Chips-like range pickervariant="pill"
Compact switcher with iconsvariant="segmented" with icon on items
Tabs fill the card widthfullWidth
Built-in animated panelscontent on each item
You render the content yourselfno content; read value / onValueChange

Examples

Controlled pill range picker with your own content

const [range, setRange] = React.useState("week")

<TabsIndicator
  variant="pill"
  listLabel="Revenue range"
  value={range}
  onValueChange={setRange}
  items={[
    { value: "day", label: "Day" },
    { value: "week", label: "Week" },
    { value: "month", label: "Month" },
    { value: "year", label: "Year" },
  ]}
/>
<RevenueChart range={range} />

Segmented with icons and panels

<TabsIndicator
  variant="segmented"
  fullWidth
  listLabel="Calendar view"
  defaultValue="day"
  items={[
    { value: "day", label: "Day", icon: CalendarDays, content: <DayView /> },
    { value: "week", label: "Week", icon: CalendarRange, content: <WeekView /> },
    { value: "month", label: "Month", icon: Calendar, content: <MonthView /> },
  ]}
/>

API reference

PropTypeDefault
items*
The tabs, in order. Give items content to get built-in panels.
TabsIndicatorItem[]—
value
Selected tab value (controlled).
string—
defaultValue
Initially selected tab when uncontrolled; defaults to the first enabled item.
string—
onValueChange
Called with the new value on click or arrow-key selection.
(value: string) => void—
variant
underline: line under the active tab plus a hover highlight. pill: filled primary pill. segmented: raised thumb in a muted track.
"underline" | "pill" | "segmented""underline"
fullWidth
Stretch the tabs into equal columns.
booleanfalse
listLabel
Accessible name of the tab list.
string—
listClassName
Classes for the tablist element.
string—
panelClassName
Classes for the tab panel (only rendered when items have content).
string—

Also accepts every <div> attribute.

Accessibility

TabFocuses the selected tab, then the panel (one tab stop for the list).
ArrowRight / ArrowLeftSelects and focuses the next or previous enabled tab, wrapping around.
Home / EndSelects the first or last enabled tab.
Enter / SpaceSelects the focused tab (native <button>).
  • Uses role="tablist" (aria-orientation horizontal, named by listLabel) with role="tab" buttons, aria-selected and a roving tabindex.
  • With content, a role="tabpanel" is rendered, labelled by its tab and linked from the selected tab through aria-controls; the panel is focusable.
  • Without content there is no tabpanel or aria-controls; if you render the panel yourself, add role="tabpanel" to it.
  • Disabled items are skipped by the arrow keys.

Motion

  • The indicator (underline, pill or thumb) is one shared-layout element that glides to the selected tab on a spring (stiffness 420, damping 36, mass 0.8).
  • Underline variant: a muted highlight follows the pointer between tabs on the same spring and fades out in 150 ms when the pointer leaves.
  • Panels slide 28px in the direction of travel with a 2px blur and fade (spring 380 / 34, opacity 180 ms) while the container height eases to the new panel (0.35 s, no bounce).
  • Reduced motion: the indicator jumps, panels swap without sliding or blur, and the height changes instantly.

Notes for AI

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

  • Pass tabs as items={[{ value, label, icon?, badge?, disabled?, content? }]}; there are no Tab or TabPanel child components.
  • icon takes the component (icon: CalendarDays), not an element.
  • Use content for built-in panels; if any item has content, a panel is rendered for the selected one.
  • Always pass listLabel so the tablist has a name.
  • The list scrolls horizontally with a hidden scrollbar when tabs overflow; do not wrap it in your own overflow container.

FAQ

How is this different from the core tabs component?

tabs-indicator is a focused, items-based component with three variants and directional panels. The core tabs component has more variants (enclosed, soft, vertical), tones and sizes.

Can several instances live on one page?

Yes. The indicator's layoutId is scoped with useId, so indicators never jump between instances.

Related