Skip to content
Component installs need the registry setup
VegaStack Design

Route Tabs

Tabs whose panels are pages — a named nav of links drawn as pill tabs, one URL per tab, current at once on click with a pending bar while the page loads.

Status
stable
Since
0.25.89
Accessibility pattern
nav landmark; aria-current; aria-busy while loading

Last updated

Agents you connected — Claude, Codex.

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Route Tabs before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify route-tabs

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/route-tabs

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/tabs.

Usage

"use client"; // app/connections/connections-tabs.tsx — it reads the path

import Link from "next/link";
import { usePathname } from "next/navigation";
import {
  RouteTabs,
  RouteTabsPanel,
  RouteTabsProvider,
} from "@/components/ui/route-tabs";

export function ConnectionsFrame({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  return (
    <RouteTabsProvider>
      <RouteTabs
        label="Connections"
        currentHref={pathname}
        linkRender={<Link href="" scroll={false} />}
        tabs={[
          { href: "/connections", label: "Your connections" },
          { href: "/connections/everyone", label: "Everyone", count: 12 },
          { href: "/connections/activity", label: "Activity" },
        ]}
      />
      <RouteTabsPanel>{children}</RouteTabsPanel>
    </RouteTabsProvider>
  );
}

When each tab is its own URL, the tabs are navigation, not a Tabs widget: a nav of links that look like the pill tabs. Put them in the section's layout so a switch swaps only the page below. A clicked tab is current at once and carries a thin pending bar until the router's path moves, and the page below dims meanwhile — so a slow page never leaves the previous tab looking chosen with its old content (or its empty state) on screen.

Scope

  • Owns: the strip's look (Tabs' own recipes), the current-tab match (href or an also path), the optimistic current tab and its pending bar, counts, the sideways scroll that keeps the current tab in view, and the dimmed panel.
  • Does not own: routing — links render through linkRender, the host passes currentHref (and, if it knows better, pendingHref).
  • Compose with: PageHeader's tabs slot, or a section layout; RouteTabsProvider joins the tabs and the panel when they sit apart.

Anatomy

RouteTabs — data-slot="route-tabs" | "route-tabs-pending" | "tabs-list"
RouteTabsPanel — data-slot="route-tabs-panel"
RouteTabsProvider

Examples

Page tabs

Choose a tab: it is current at once, the pending bar runs under it, and the page below dims until the new page arrives (the demo waits 1.2s). A count follows the label in the muted ink; an also path — a record's own page under the tab — keeps the tab current.

Agents you connected — Claude, Codex.

A long strip

overflow="scroll" lets the strip scroll sideways when it outgrows its column, with edge fades, and keeps the current tab in view — counts arriving after the first paint included.

Agents you connected — Claude, Codex.

API Reference

RouteTabs

PropTypeDefaultDescription
label*string—The nav landmark's accessible name ("Connections").
tabs*readonly RouteTab[]—The tabs, in order.
currentHrefstring | null—The page being shown — the router's pathname. The tab whose href (or one of whose also paths) equals it is current.
formatCount((count: number) => string)(n) => n.toLocaleString("en")Formats a tab's count.
linkRenderReact.ReactElement<unknown, string | React.JSXElementConstructor<any>><a />The element each tab renders as — your router's link (<Link scroll={false} />). The tab's href, classes, aria-current, data attributes and children are merged into it; its own onClick still runs.
onPendingHrefChange((href: string | null) => void)—Fired when a tab starts loading (its href) and when loading ends or is cancelled (null). With pendingHref controlled it carries the click's request — pass it back as pendingHref, and clear it yourself once the router lands.
overflow"scroll" | "visible""visible"scroll lets a long strip scroll sideways, keeping the current tab in view; visible never clips.
pendingHrefstring | null—The tab whose page is loading. Omitted, the tabs track it themselves: a click marks its tab until currentHref changes. Pass it (with onPendingHrefChange) to drive it from the router.

Data attributes and CSS variables on RouteTabs

AttributeValues
data-orientation"horizontal"
data-overflowmirrors a prop or state value
data-pending""
data-slot"route-tabs" | "route-tabs-pending" | "tabs-list"
data-variant"default"

Each tab:

PropTypeDefaultDescription
href*string—The page this tab opens.
label*React.ReactNode—The tab's text.
alsoreadonly string[]—Other paths that show this tab as current (a record's dialog route, a nested page).
aria-labelstring—The link's accessible name when the visible text is not enough ("Tasks, 3 open").
countnumber | null—A muted tabular-nums count after the label (null or omitted: none).

RouteTabsPanel

PropTypeDefaultDescription
pendingboolean—The page is being replaced: it dims and is marked busy. Omitted, it follows the nearest RouteTabsProvider.

Data attributes and CSS variables on RouteTabsPanel

AttributeValues
data-pending""
data-slot"route-tabs-panel"

RouteTabsProvider

Holds the loading tab for the RouteTabs and RouteTabsPanel below it; it takes only children.

Accessibility

  • The strip is a nav landmark named by label; the current tab's link carries aria-current="page", and a tab's own aria-label replaces its visible name when that is not enough ("Tasks, 3 open").
  • While a page loads, the nav and the panel are aria-busy; the pending bar is decorative.
  • Modified clicks (⌘, Ctrl, Shift, Alt) open the link as the browser would and start nothing.
KeyAction
TabMoves between the tab links
EnterOpens the focused tab's page
ContractStates tested
Behaviourdefault, current-page, also-match, count, pending, controlled-pending, panel-pending, overflow-scroll
Accessibilitylandmark, labeled, current-page, busy, browser-accessibility-test
Visualdefault, hover, current, pending, dimmed-panel, scroll

Do / Don't

Do
Use route tabs for a section's sub-pages, each with its own URL, in the section's layout.
Don't
Use Tabs for pages with URLs, or leave the old page undimmed while the next one loads.

On this page