Views of one page as a row of pills, as switching panels or router links.

Basic

A row of pills over one panel per tab. Give each Tab and its TabPanel the same id. Only the selected panel is mounted. The arrow keys select as they move (automatic activation), since showing a panel already in the page is instant.

Example

Money out: card payments, bills and transfers you sent.

Follows the page theme and corners
import { Tab, TabList, TabPanel, Tabs } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function TabsBasic(): ReactElement {
  return (
    <div style={{ width: '100%' }}>
      <Tabs defaultSelectedKey="spending">
        <TabList aria-label="Transactions">
          <Tab id="all">All</Tab>
          <Tab id="spending">Spending</Tab>
          <Tab id="income">Income</Tab>
          <Tab id="transfers">Transfers</Tab>
        </TabList>
        <TabPanel id="all">
          <p>Every transaction this month, newest first.</p>
        </TabPanel>
        <TabPanel id="spending">
          <p>Money out: card payments, bills and transfers you sent.</p>
        </TabPanel>
        <TabPanel id="income">
          <p>Money in: salary, refunds and transfers you received.</p>
        </TabPanel>
        <TabPanel id="transfers">
          <p>Moves between your own accounts, which are neither.</p>
        </TabPanel>
      </Tabs>
    </div>
  );
}

When each view has its own URL:

  • Give each Tab an href.
  • Pass selectedKey from the URL, and no onSelectionChange. The selection moves when the route does.
  • Set keyboardActivation="manual". The arrows move focus without loading a route, and Enter, Space or a press follows the link through RouterProvider.
  • Render one TabPanel whose id is the selected key, holding the route's content, so aria-controls points at it.

A modified or middle click opens the link as the browser would.

Example

Every transaction this month, newest first.

Follows the page theme and corners
'use client';

import { RouterProvider, Tab, TabList, TabPanel, Tabs } from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Suspense, type ReactElement } from 'react';

const VIEWS = [
  { id: 'all', name: 'All', content: 'Every transaction this month, newest first.' },
  { id: 'spending', name: 'Spending', content: 'Money out: card payments, bills and transfers you sent.' },
  { id: 'income', name: 'Income', content: 'Money in: salary, refunds and transfers you received.' },
] as const;

function ViewTabs({ view }: { view: string }): ReactElement {
  const pathname = usePathname();
  const current = VIEWS.find(({ id }) => id === view) ?? VIEWS[0];
  return (
    // The URL holds the selection; the arrows only move focus, and Enter or Space follows the focused tab's link.
    <Tabs selectedKey={current.id} keyboardActivation="manual">
      <TabList aria-label="Transactions">
        {VIEWS.map(({ id, name }) => (
          <Tab key={id} id={id} href={`${pathname}?view=${id}`}>
            {name}
          </Tab>
        ))}
      </TabList>
      {/* One panel, the route's content, with the selected key as its id. */}
      <TabPanel id={current.id}>
        <p>{current.content}</p>
      </TabPanel>
    </Tabs>
  );
}

function ViewFromUrl(): ReactElement {
  return <ViewTabs view={useSearchParams().get('view') ?? 'all'} />;
}

export default function TabsLinks(): ReactElement {
  const router = useRouter();
  // An app wraps its whole tree once; the example wraps only itself.
  return (
    <div style={{ width: '100%' }}>
      <RouterProvider
        navigate={(href) => {
          router.push(href, { scroll: false });
        }}
      >
        {/* The page is static, so the server renders the fallback: the same tabs at the first view. */}
        <Suspense fallback={<ViewTabs view="all" />}>
          <ViewFromUrl />
        </Suspense>
      </RouterProvider>
    </div>
  );
}

Tabs are for views of one page. For top navigation between whole pages, use plain links with aria-current="page" instead.

Unsaved edits

Set isChanged to show a clay dot after the name of a tab whose panel holds unsaved edits. Use it for nothing else: clay means unsaved. A screen reader reads "Changed", or your changedLabel, after the tab's name. On the selected ink pill the dot gets a canvas ring so it stays visible.

For a count or a "new" marker, put a Badge in the tab's name. It takes the tab's color, so it reads on the ink pill and on hover.

Example

Your name, your currency and the day your month starts.

Follows the page theme and corners
import { Badge, Tab, TabList, TabPanel, Tabs } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function TabsChanged(): ReactElement {
  return (
    <div style={{ width: '100%' }}>
      <Tabs defaultSelectedKey="general">
        <TabList aria-label="Settings">
          <Tab id="general">General</Tab>
          {/* The panel holds unsaved edits: the clay dot, read as "Changed". */}
          <Tab id="rules" isChanged>
            Rules
          </Tab>
          {/* A count is part of the tab's name, in a Badge, never in clay. */}
          <Tab id="review">
            To review <Badge>3</Badge>
          </Tab>
        </TabList>
        <TabPanel id="general">
          <p>Your name, your currency and the day your month starts.</p>
        </TabPanel>
        <TabPanel id="rules">
          <p>Two rules edited and not saved yet.</p>
        </TabPanel>
        <TabPanel id="review">
          <p>Three transactions no rule has sorted.</p>
        </TabPanel>
      </Tabs>
    </div>
  );
}

A row that does not fit

The row scrolls inside its track, and the page never scrolls sideways. Keep names short and this rarely shows.

  • A focused tab scrolls into view, as the browser does for focus.
  • After hydration, the row scrolls itself (never the page) to the selected tab. The server's HTML shows the start of the row.
  • It also scrolls when the selection moves while the selected tab is not focused, as Back does with link tabs.
  • A row mounted hidden waits until it is shown.

Example

The archived view.

Follows the page theme and corners
import { Tab, TabList, TabPanel, Tabs } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

const VIEWS = ['All', 'Spending', 'Income', 'Transfers', 'Bills', 'Subscriptions', 'Refunds', 'Archived'];

export default function TabsOverflow(): ReactElement {
  return (
    <div style={{ width: '100%', maxWidth: '24rem' }}>
      <Tabs defaultSelectedKey="archived">
        <TabList aria-label="Transactions">
          {VIEWS.map((view) => (
            <Tab key={view} id={view.toLowerCase()}>
              {view}
            </Tab>
          ))}
        </TabList>
        {VIEWS.map((view) => (
          <TabPanel key={view} id={view.toLowerCase()}>
            <p>{`The ${view.toLowerCase()} view.`}</p>
          </TabPanel>
        ))}
      </Tabs>
    </div>
  );
}

Usage

Keep the view in the URL so it can be shared, reloaded and reached with Back. With the Next App Router, the server page reads the search parameter and passes it to a client component that renders the tabs. The route's content goes in the one panel:

// app/transactions/page.tsx, a server component
import { TransactionList } from './transaction-list';
import { TransactionViews } from './transaction-views';

const VIEWS = ['all', 'spending', 'income'];

export default async function TransactionsPage({ searchParams }: { searchParams: Promise<{ view?: string }> }) {
  const { view = 'all' } = await searchParams;
  const current = VIEWS.includes(view) ? view : 'all';
  return (
    <TransactionViews view={current}>
      <TransactionList view={current} />
    </TransactionViews>
  );
}
// app/transactions/transaction-views.tsx
'use client';

import type { ReactNode } from 'react';
import { Tab, TabList, TabPanel, Tabs } from '@snapshot-ui/react';

export function TransactionViews({ view, children }: { view: string; children: ReactNode }) {
  return (
    <Tabs selectedKey={view} keyboardActivation="manual">
      <TabList aria-label="Transactions">
        <Tab id="all" href="?view=all" routerOptions={{ scroll: false }}>
          All
        </Tab>
        <Tab id="spending" href="?view=spending" routerOptions={{ scroll: false }}>
          Spending
        </Tab>
        <Tab id="income" href="?view=income" routerOptions={{ scroll: false }}>
          Income
        </Tab>
      </TabList>
      <TabPanel id={view}>{children}</TabPanel>
    </Tabs>
  );
}

The links go through the RouterProvider at the app's root, set up in Routing, so each tab is a soft navigation.

  • routerOptions={{ scroll: false }} stops Next's router from scrolling to the top of the segment on every push.
  • If the URL has other parameters, such as a search or a page number, build each href from them so choosing a view keeps them.

Props

Every React Aria Tabs, TabList, Tab and TabPanel prop is accepted except orientation, since the row is horizontal only. These are the main ones.

className
string

A class name merged after the library's own class.

defaultSelectedKey
Key

The initial selected keys in the collection (uncontrolled).

disabledKeys
Iterable<Key>

The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with.

isDisabled
boolean

Whether the TabList is disabled. Shows that a selection exists, but is not available in that circumstance.

keyboardActivation
"automatic" | "manual"

Whether tabs are activated automatically on focus or manually.

Default: 'automatic'

onSelectionChange
((key: Key) => void)

Handler that is called when the selection changes.

selectedKey
Key

The currently selected key in the collection (controlled).

aria-label
string

Defines a string value that labels the current element. A name for assistive technology. Give it, or aria-labelledby, where no label or text is shown.

aria-labelledby
string

Identifies the element (or elements) that labels the current element. The id of an element that holds the name, such as a label shown elsewhere.

className
string

A class name merged after the library's own class.

changedLabel
string

The words a screen reader reads after the tab's name while isChanged is set, in the page's language.

Default: 'Changed'

children
ReactNode

The tab's name: a word or two.

className
string

A class name merged after the library's own class.

href
string

A URL to link to. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href).

id
Key

The tab's key: the panel with the same id is its panel, and selectedKey names it.

isChanged
boolean

The tab's panel holds unsaved changes. Shows the clay dot and has a screen reader read changedLabel.

isDisabled
boolean

Whether the tab is disabled.

className
string

A class name merged after the library's own class.

id
Key

The unique id of the tab.

shouldForceMount
boolean

Whether to mount the tab panel in the DOM even when it is not currently selected. Inactive tab panels are inert and cannot be interacted with. They must be styled appropriately so this is clear to the user visually.

Default: false

Accessibility

A tablist of tabs with aria-selected, named by aria-label or aria-labelledby on the TabList. Each tabpanel is named by its tab.

  • A panel with nothing focusable is itself the next Tab stop after the row.
  • The changed dot is hidden and read as the tab's description, so the tab's name is unchanged.
  • Link tabs keep the tab roles and arrow keys. With manual activation, a key press never loads a page.
  • A disabled tab is skipped by the arrows and cannot be selected.
LeftRightMove between tabs in the direction of the row, wrapping and skipping disabled ones. With automatic activation, this also selects.
HomeEndJump to the first or last tab.
EnterSpaceWith manual activation, select the focused tab, or follow its link.
TabMoves into the panel.

Design rules

What keeps tabs in the system.

Use two to six short names, a word or two each.

Use tabs for views of one page, with a panel for each.

Keep the view in the URL, with link tabs and manual activation.

Never use the clay dot for a count or a "new" marker. It means unsaved edits.

Never use tabs for the steps of a flow.

Never stand tabs on their side. The row is horizontal only.

esc

↑ ↓ to move↵ to open