# Navigation

Move between product areas, views of a page, and nested pages with the same frame on every screen.

## Overview

Tempo products share one navigation model with four levels. Each level has its own component, so you always know whether a control takes you to another area, another view, or back up a level.

| Level        | Question it answers                     | Web component                 |
| ------------ | --------------------------------------- | ----------------------------- |
| Product area | Which part of the product am I in?      | `AppShell` with `SidebarMenu` |
| Page         | Which page is this?                     | `AppShell.Title`              |
| Depth        | How did I get here, and how do I go up? | `Breadcrumbs`                 |
| View         | Which view of this page am I seeing?    | `Tabs`                        |

## Components

| Component                  | Role                                                                    |
| -------------------------- | ----------------------------------------------------------------------- |
| `AppShell`                 | The frame: a sticky sidebar from 1024px, and a drawer below that.       |
| `SidebarMenu`              | Links to each product area, with the current one marked `active`.       |
| `AppShell.Trigger`         | Opens and closes the sidebar drawer below 1024px.                       |
| `AppShell.Header`          | Holds the page's `AppShell.Title` (its `h1`), actions, and the trigger. |
| `Breadcrumbs`              | The path from a nested page back to its product area.                   |
| `Tabs`                     | Switches between views of the same object, such as an API key.          |
| `Pagination` and `Stepper` | Pages of a long list, and your place in a fixed sequence of steps.      |

## Structure

1. `AppShell.Sidebar` holds a `SidebarMenu` with one item per product area, such as Home, API Keys, Webhooks, Team, and Settings.
2. `AppShell.Main` starts with `Breadcrumbs` on nested pages, then an `AppShell.Header` with one `AppShell.Title`.
3. `Tabs` sit under the header when a page has several views of the same object.
4. Page content follows, in the order the views and sections appear.

```tsx
import { AppShell, Breadcrumbs, Field, Rows, SidebarMenu, style, Surface, Switch, Tabs } from '@tempoxyz/ds'
import { Grid, Key, Link, Settings, Users } from '@tempoxyz/ds/icons'

export const fill = true
const pages = [
  ['Home', Grid],
  ['API Keys', Key],
  ['Webhooks', Link],
  ['Team', Users],
  ['Settings', Settings],
] as const

export function Demo() {
  return (
    <div {...styles.frame()}>
      <AppShell.Root style={{ height: '100%', minHeight: 0 }}>
        <AppShell.Sidebar aria-label="Console" style={{ height: '100%' }}>
          <AppShell.SidebarHeader>
            <span {...styles.product()}>Tempo Console</span>
          </AppShell.SidebarHeader>
          <AppShell.SidebarBody>
            <SidebarMenu.Root>
              {pages.map(([label, Icon]) => (
                <SidebarMenu.Item active={label === 'API Keys'} href="#" key={label}>
                  <Icon />
                  {label}
                </SidebarMenu.Item>
              ))}
            </SidebarMenu.Root>
          </AppShell.SidebarBody>
        </AppShell.Sidebar>
        <AppShell.Main style={{ overflowY: 'auto' }}>
          <Breadcrumbs>
            <Breadcrumbs.Item href="#">API Keys</Breadcrumbs.Item>
            <Breadcrumbs.Item current>Production</Breadcrumbs.Item>
          </Breadcrumbs>
          <AppShell.Header>
            <AppShell.Title>Production</AppShell.Title>
            <AppShell.Trigger />
          </AppShell.Header>
          <Tabs.Root defaultValue="overview">
            <Tabs.List aria-label="Key details">
              <Tabs.Tab value="overview">Overview</Tabs.Tab>
              <Tabs.Tab value="usage">Usage</Tabs.Tab>
              <Tabs.Tab value="settings">Settings</Tabs.Tab>
              <Tabs.Indicator />
            </Tabs.List>
            <Tabs.Panel value="overview" {...styles.panel()}>
              <Surface size="medium">
                <Rows.Root>
                  <Rows.Row>
                    <Rows.Label>Created</Rows.Label>
                    March 3, 2026
                  </Rows.Row>
                  <Rows.Row>
                    <Rows.Label>Last used</Rows.Label>2 minutes ago
                  </Rows.Row>
                  <Rows.Row>
                    <Rows.Label>Network</Rows.Label>
                    Mainnet
                  </Rows.Row>
                </Rows.Root>
              </Surface>
            </Tabs.Panel>
            <Tabs.Panel value="usage" {...styles.panel()}>
              <Surface size="medium">
                <Rows.Root>
                  <Rows.Row>
                    <Rows.Label>Requests, last 24 hours</Rows.Label>
                    12,480
                  </Rows.Row>
                  <Rows.Row>
                    <Rows.Label>Errors</Rows.Label>3
                  </Rows.Row>
                </Rows.Root>
              </Surface>
            </Tabs.Panel>
            <Tabs.Panel value="settings" {...styles.panel()}>
              <Surface size="medium">
                <Field.Root variant="setting">
                  <Field.Label>Allow testnet requests</Field.Label>
                  <Field.Description>
                    Use this key against testnet as well as mainnet.
                  </Field.Description>
                  <Switch />
                </Field.Root>
              </Surface>
            </Tabs.Panel>
          </Tabs.Root>
        </AppShell.Main>
      </AppShell.Root>
    </div>
  )
}

namespace styles {
  // The transform makes the frame the containing block for the drawer below 1024px.
  export const frame = style({
    borderRadius: '3xl',
    height: '32rem !custom',
    overflow: 'hidden',
    transform: 'translateZ(0) !custom',
    selectors: {
      ':root[data-maximized] &': { borderRadius: '0px !custom', height: '100vh !custom' },
    },
  })

  export const panel = style({ marginBlockStart: '4' })

  export const product = style({ paddingInline: '3', typography: 'label.14.strong' })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/ConsoleNavigation.tsx)

This reference layout is built from `AppShell`, `SidebarMenu`, `Breadcrumbs`, and `Tabs`. Below 1024px the sidebar waits in a drawer behind the menu button in the top-right corner. Open the preview full screen on a wide display to see it as a sticky sidebar.

## Guidelines

### List Product Areas in the Sidebar

* Give each item an icon and a short label that fits on one line, since items don't wrap.
* Mark exactly one item `active`, the area of the current page. It sets `aria-current="page"`.
* Keep the same items in the same order on every page.
* Split long lists into `SidebarMenu.Group` sections with a `SidebarMenu.GroupLabel`, such as Project and Organization.
* Use links for destinations. Put actions, such as New organization or Log out, in a `Menu` in `AppShell.SidebarFooter`.

**Do:** Mark the current area `active`, with short labels and icons.

**Don't:** Leave the current area unmarked, which leaves you guessing where you are.

### Move the Sidebar Into a Drawer Below 1024px

Below 1024px, `AppShell.Sidebar` renders as a modal drawer that opens from the right edge. `AppShell.Trigger` toggles it from a fixed button in the top-right corner, and hides itself from 1024px.

* Render `AppShell.Trigger` inside `AppShell.Header`, so header actions leave room for it.
* Keep the page's main action in the header, not in the drawer, so it's still one press away.
* Keep the drawer's items and order the same as the wide sidebar.
* Server rendering always emits the wide layout, so the drawer appears after hydration on narrow screens.

### Use Tabs for Views of One Page

Use `Tabs` when a page shows several views of one object, such as an API key's Overview, Usage, and Settings. Use `SidebarMenu` or links to reach another page, and `ChoiceGroup` to switch a mode of the same content, such as a chart's time range.

* Name the list with `aria-label`, such as Key details.
* Write short, parallel labels, and keep the count to what fits on one line. The list scrolls horizontally when tabs overflow.
* Set `expand` on `Tabs.List` to stretch tabs across a narrow column.
* Control `value` from the URL when people share links to a specific view.

**Do:** Use one or two words per tab, in parallel form.

**Don't:** Write long or mismatched tab labels.

### Show Depth With Breadcrumbs

Use `Breadcrumbs` on pages nested below a product area, such as a key's page under API Keys or a webhook under Settings. Start with the product area, and mark the last item `current`, which renders it as text instead of a link.

**Do:** Show the path from the product area down to the current page.

**Don't:** Show breadcrumbs on a top-level page, where they hold only the current page.

* Reflect the hierarchy, not the pages you visited.
* Keep the default Breadcrumb name, or pass a distinct `aria-label`, so the landmark stays apart from the sidebar.
* Below 640px, show only the parent page as a back link when the full path would wrap.

### Keep Navigation Stable

* Render one `AppShell.Title` per page, since it's the page's `h1`.
* Keep navigation on screen while page content loads or fails. Only `AppShell.Main` changes.
* Add a skip link to `AppShell.Main` when the sidebar holds many links.
* Use `Pagination` for long lists that load page by page, and `Stepper` with Back and Continue buttons for a linear flow, such as account setup.

## States

| State    | What you show                                                                                                              |
| -------- | -------------------------------------------------------------------------------------------------------------------------- |
| Default  | The sidebar with one `active` item, breadcrumbs on nested pages, and the page title.                                       |
| Loading  | Navigation renders at once. Placeholders appear only in `AppShell.Main`.                                                   |
| Error    | Navigation stays usable when the page's content fails. Show Couldn't load in the main column.                              |
| Empty    | A product area with no content still appears in the sidebar. Its page shows an [empty state](/docs/patterns/empty-states). |
| Disabled | Leave out areas you can't use. Disable a `Tabs.Tab` only when its view doesn't apply yet, and say why on the page.         |
| Success  | After a flow such as creating a key, land on the new item's page with its area marked `active`.                            |

## Accessibility

* `AppShell.Sidebar` renders an `aside` named by its `aria-label`. Below 1024px it becomes a modal dialog that takes focus on open, returns it to the trigger on close, and closes on Escape, the backdrop, or a swipe right.
* `AppShell.Trigger` is named Open menu or Close menu and exposes `aria-expanded`.
* `SidebarMenu.Root` renders a `nav` named Main unless you pass `aria-label`. Give each landmark on a page a distinct name.
* Submenu triggers are buttons that expose `aria-expanded`, so a submenu reads as a disclosure, not a menu.
* In `Tabs`, arrow keys move focus between tabs and wrap at the ends. Enter or Space activates a tab unless `activateOnFocus` is set, and Tab moves into the open panel.
* `Breadcrumbs` renders a `nav` named Breadcrumb around an ordered list. Separators are hidden from assistive technology, and the current item has `aria-current="page"`.
* Sidebar rows and tab lists are 32, 40, or 56px tall, set by `size`, above the WCAG 2.2 minimum target size of 24px.

## Related

* [AppShell](/docs/components/app-shell) and [SidebarMenu](/docs/components/sidebar-menu) for the frame
* [Tabs](/docs/components/tabs) for views of a page
* [Breadcrumbs](/docs/components/breadcrumbs) for depth
* [Pagination](/docs/components/pagination) and [Stepper](/docs/components/stepper) for lists and flows
* [Responsive Layout](/docs/patterns/responsive-layout) for breakpoints
* [Tempo Console](/docs/products/tempo-console) for the console's pages
