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
Example
Money out: card payments, bills and transfers you sent.
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>
);
}Tabs that are links
When each view has its own URL:
- Give each
Tabanhref. - Pass
selectedKeyfrom the URL, and noonSelectionChange. 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 throughRouterProvider. - Render one
TabPanelwhoseidis the selected key, holding the route's content, soaria-controlspoints at it.
A modified or middle click opens the link as the browser would.
Example
Example
'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
Example
Your name, your currency and the day your month starts.
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
Example
The archived view.
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
hreffrom 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 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 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 |
isChanged boolean | The tab's panel holds unsaved changes. Shows the clay dot and has a screen reader read |
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.
| LeftRight | Move between tabs in the direction of the row, wrapping and skipping disabled ones. With automatic activation, this also selects. |
|---|---|
| HomeEnd | Jump to the first or last tab. |
| EnterSpace | With manual activation, select the focused tab, or follow its link. |
| Tab | Moves 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.