# Responsive Layout

Adapts Tempo layouts from a 320px phone to a wide desktop with one shared breakpoint set.

## Overview

The same wallet, console, or dashboard opens on a phone, a laptop, and an external display. Components already change at fixed widths, such as `AppShell` moving its sidebar into a drawer below 1024px.

Change your page layouts at those same widths, so the page and its components switch together.

This page combines:

* `Container` for content width and side gutters.
* `AppShell` for the sidebar and content column of an application.
* `Grid`, `Stack`, and `Inline` for arranging content that reflows on its own.
* `Surface` and `Rows` for the cards and lists inside each layout.
* `Dialog` and `Drawer`, which present differently on narrow viewports.

> [!NOTE]
> The live layouts on this page are reference layouts composed from library components and `style`. They are not shipped components, so copy and adapt them in your app.

## Breakpoints

Use these five breakpoints for every layout change. They are Zyzz's default `breakpoint` values.

| Name  | Width          | What changes in components                                                                                                                                   |
| ----- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sm`  | 40rem (640px)  | `Dialog` stops presenting as a bottom sheet. `Toast.Viewport` moves from the bottom edge to the bottom end. `AppShell.Main` padding grows from 16px to 32px. |
| `md`  | 48rem (768px)  | The `Container` gutter grows from 24px to 32px.                                                                                                              |
| `lg`  | 64rem (1024px) | `AppShell.Sidebar` changes from a drawer to a sticky sidebar, and `AppShell.Trigger` hides.                                                                  |
| `xl`  | 80rem (1280px) | A `wide` `Container` reaches its maximum width.                                                                                                              |
| `2xl` | 96rem (1536px) | No component changes. Reserve it for layouts that add a column on very wide displays.                                                                        |

Components write these widths as pixel literals, since media queries can't read CSS variables. At the default 16px font size, the rem breakpoints and the pixel points match.

### Write Breakpoints With Zyzz Aliases

Zyzz turns a breakpoint name into a width query, so you never repeat the numbers.

| Key               | Emits                             |
| ----------------- | --------------------------------- |
| `'@media md'`     | `@media (width >= 48rem)`         |
| `'@media <lg'`    | `@media (width < 64rem)`          |
| `'@media sm..lg'` | `@media (40rem <= width < 64rem)` |

Write the narrow layout as the base style, then add wider layouts with minimum-width keys.

```tsx
namespace styles {
  export const layout = style({
    display: 'grid',
    gap: '6',
    gridTemplateColumns: 'minmax(0, 1fr) !custom',
    '@media lg': { gridTemplateColumns: '20rem minmax(0, 1fr) !custom' },
  })
}
```

## Content Widths

`Container` centers content at a maximum width and adds side gutters of 24px, or 32px from 768px. Pick the narrowest size that fits the content.

| Size     | Maximum width  | Use for                                                         |
| -------- | -------------- | --------------------------------------------------------------- |
| `narrow` | 48rem (768px)  | Settings, forms, focused flows, and long text.                  |
| `medium` | 64rem (1024px) | List and detail pages, and tables with a few columns.           |
| `wide`   | 80rem (1280px) | Dashboards and wide tables. It is the default.                  |
| `full`   | None           | Content that fills the viewport, such as a block explorer feed. |

Inside `AppShell`, `AppShell.Main` already limits the column to 1024px and adds its own padding. Set `gutter={false}` on a `Container` nested there, or the padding doubles. Outside `AppShell`, render the page landmark through `Container` with `render={<main />}`.

## Page Structures

Most Tempo pages use one of four structures. Each one changes only at the shared breakpoints.

| Structure       | Below 640px                                                          | 640 to 1023px                                      | 1024px and up                                             |
| --------------- | -------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------- |
| App shell       | Sidebar in a drawer opened by `AppShell.Trigger`. 16px padding.      | Same drawer. 32px padding.                         | Sticky sidebar beside a content column up to 1024px wide. |
| List and detail | One pane at a time. A row opens the detail, which has a Back action. | Same, with wider gutters.                          | A 20rem list beside the detail.                           |
| Settings        | One column between gutters.                                          | One column up to 768px.                            | Same column, centered.                                    |
| Focused flow    | One column. Actions stack at full width, primary first.              | A centered 28rem column. Actions align to the end. | Same column.                                              |

### App Shell With Content

Use `AppShell` for products with persistent navigation, such as Tempo Console. It handles the sidebar change at 1024px, so your content only needs to fit a column of up to 1024px.

* Below 1024px, `AppShell.Trigger` sits fixed in the top-right corner and opens the sidebar as a drawer from the right edge. `AppShell.Header` reserves space so header actions end before the trigger.
* From 1024px, the sidebar is sticky and `var(--sidebar-width, 16.25rem)` wide.
* Set `align="start"` on `AppShell.Root` for dashboards that should fill the width beside the sidebar.

See [AppShell](/docs/components/app-shell) for a live example.

### List and Detail

Use list and detail for records you scan and then inspect, such as payments, API keys, or webhooks. Resize the preview below 1024px to see one pane at a time.

```tsx
import { Amount, Button, Grid, Hex, Rows, style, Surface, Tag } from '@tempoxyz/ds'
import { ArrowLeft } from '@tempoxyz/ds/icons'
import { useRef, useState } from 'react'
import { flushSync } from 'react-dom'

export const fill = true
const payments = [
  {
    amount: '1200.00',
    hash: '0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060',
    id: 'payroll',
    status: 'Confirmed',
    time: 'Today, 09:41',
    title: 'Payroll',
    to: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
  },
  {
    amount: '48.50',
    hash: '0x91bd07e1c3e05a2277a1c0d94f2a91c3b6a900fa2b585dd299e03d12fa4293bc',
    id: 'hosting',
    status: 'Confirmed',
    time: 'Yesterday, 18:02',
    title: 'Hosting',
    to: '0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc',
  },
  {
    amount: '310.00',
    hash: '0xc3e05a2291bd07e14f2a91c3b6a900fa2b585dd299e03d12fa4293bc77a1c0d9',
    id: 'refund',
    status: 'Pending',
    time: 'Oct 18, 14:30',
    title: 'Refund to Ada',
    to: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
  },
]

export function Demo() {
  const [selected, setSelected] = useState('payroll')
  // Narrow layouts show one pane at a time. From 1024px both panes show and `view` has no effect.
  const [view, setView] = useState<'detail' | 'list'>('list')
  const payment = payments.find((entry) => entry.id === selected) ?? payments[0]!
  const heading = useRef<HTMLHeadingElement>(null)
  const list = useRef<HTMLElement>(null)

  return (
    <Grid data-view={view} gap="6" {...styles.layout()}>
      <section aria-labelledby="payments-heading" data-pane="list" ref={list} {...styles.pane()}>
        <h2 id="payments-heading" {...styles.heading()}>
          Payments
        </h2>
        <Surface size="small" {...styles.list()}>
          <Rows.Root>
            {payments.map((entry) => (
              <Rows.Row
                action="navigation"
                aria-current={entry.id === selected || undefined}
                key={entry.id}
                onClick={() => {
                  flushSync(() => {
                    setSelected(entry.id)
                    setView('detail')
                  })
                  // When the list hides, focus moves to the detail so it isn't lost.
                  if (list.current?.offsetParent === null) heading.current?.focus()
                }}
              >
                <Rows.Info>
                  <Rows.Title>{entry.title}</Rows.Title>
                  <Rows.Description>{entry.time}</Rows.Description>
                </Rows.Info>
                <Amount currency="USD" value={entry.amount} />
              </Rows.Row>
            ))}
          </Rows.Root>
        </Surface>
      </section>
      <section aria-labelledby="payment-heading" data-pane="detail" {...styles.pane()}>
        <div {...styles.back()}>
          <Button
            onClick={() => {
              flushSync(() => setView('list'))
              list.current?.querySelector<HTMLElement>('[aria-current="true"]')?.focus()
            }}
            size="small"
            variant="text"
          >
            <ArrowLeft />
            Payments
          </Button>
        </div>
        <Surface>
          <Surface.Header>
            <Surface.Title id="payment-heading" ref={heading} tabIndex={-1}>
              {payment.title}
            </Surface.Title>
            <Tag intent={payment.status === 'Confirmed' ? 'positive' : 'warning'}>
              <Tag.Dot />
              {payment.status}
            </Tag>
          </Surface.Header>
          <Rows.Root dividers="full">
            <Rows.Row>
              <Rows.Label>Amount</Rows.Label>
              <Amount currency="USD" value={payment.amount} />
            </Rows.Row>
            <Rows.Row>
              <Rows.Label>To</Rows.Label>
              <Hex copy value={payment.to} />
            </Rows.Row>
            <Rows.Row>
              <Rows.Label>Date</Rows.Label>
              {payment.time}
            </Rows.Row>
            <Rows.Row>
              <Rows.Label>Transaction</Rows.Label>
              <Hex copy value={payment.hash} />
            </Rows.Row>
          </Rows.Root>
        </Surface>
      </section>
    </Grid>
  )
}

namespace styles {
  export const back = style({
    marginBlockEnd: '2',
    marginInlineStart: '-12px !custom',
    '@media lg': { display: 'none' },
  })

  export const heading = style({ margin: '0', marginBlockEnd: '3', typography: 'heading.20' })

  export const layout = style({
    alignItems: 'start',
    backgroundColor: 'background.primary',
    gridTemplateColumns: 'minmax(0, 1fr) !custom',
    padding: '4',
    '@media <lg': {
      selectors: {
        '&[data-view="detail"] > [data-pane="list"]': { display: 'none' },
        '&[data-view="list"] > [data-pane="detail"]': { display: 'none' },
      },
    },
    '@media lg': { gridTemplateColumns: '20rem minmax(0, 1fr) !custom' },
  })

  export const list = style({
    selectors: { '& [aria-current="true"]': { backgroundColor: 'grayAlpha.100' } },
  })

  export const pane = style({ minWidth: '0' })
}
```

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

* From 1024px, show the list beside the detail and mark the selected row with `aria-current`.
* Below 1024px, show the list first. Selecting a row hides the list and shows the detail with a Back action.
* Give each detail its own URL, so Back in the browser and a shared link both work at every width.

### Settings

Settings stay in one `narrow` column at every width. Group related settings into a `Surface` per section, with a `Surface.Title` and `Rows` or `Field` with `variant="setting"` inside.

```tsx
import { Container, Field, Inset, Rows, style, Surface, Switch } from '@tempoxyz/ds'

export const fill = true
export function Demo() {
  return (
    <Inset block="8" {...styles.page()}>
      <Container size="narrow" {...styles.column()}>
        <h1 {...styles.title()}>Settings</h1>
        <Surface render={<section aria-labelledby="account-heading" />}>
          <Surface.Header>
            <Surface.Title id="account-heading">Account</Surface.Title>
          </Surface.Header>
          <Rows.Root>
            <Rows.Row action="navigation" render={<a href="#name" />}>
              <Rows.Label>Name</Rows.Label>
              Tempo Labs
            </Rows.Row>
            <Rows.Row action="navigation" render={<a href="#email" />}>
              <Rows.Label>Email</Rows.Label>
              ops@tempo.xyz
            </Rows.Row>
          </Rows.Root>
        </Surface>
        <Surface render={<section aria-labelledby="notifications-heading" />}>
          <Surface.Header>
            <Surface.Title id="notifications-heading">Notifications</Surface.Title>
          </Surface.Header>
          <Field.Root variant="setting">
            <Field.Label>Payment received</Field.Label>
            <Field.Description>Email you when funds arrive.</Field.Description>
            <Switch defaultChecked />
          </Field.Root>
          <Field.Root variant="setting">
            <Field.Label>Failed webhooks</Field.Label>
            <Field.Description>Email you after three failed deliveries.</Field.Description>
            <Switch />
          </Field.Root>
        </Surface>
      </Container>
    </Inset>
  )
}

namespace styles {
  export const column = style({ display: 'flex', flexDirection: 'column', rowGap: '6' })

  export const page = style({ backgroundColor: 'background.primary' })

  export const title = style({ margin: '0', typography: 'heading.24' })
}
```

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

Don't spread settings into several columns on wide screens. A single column keeps each label beside its control and keeps the reading order obvious.

### Focused Flow

Send, deposit, and onboarding flows remove global navigation and center one task in a column of about 28rem (`maxWidth: 'md'`). Show progress with `Stepper` and keep the actions at the end of the form.

```tsx
import { AmountInput, Button, Choicebox, Field, Inset, Stepper, style } from '@tempoxyz/ds'

export const fill = true
export function Demo() {
  return (
    <Inset block="8" inline="4" {...styles.page()}>
      <form onSubmit={(event) => event.preventDefault()} {...styles.column()}>
        <div {...styles.header()}>
          <Stepper.Root aria-label="Add funds" count={3} value={1} />
          <p {...styles.step()}>Step 1 of 3</p>
          <h1 {...styles.title()}>Add funds</h1>
        </div>
        <Field.Root variant="card">
          <Field.Label>Amount</Field.Label>
          <AmountInput.Root defaultValue="250">
            <AmountInput.Prefix>$</AmountInput.Prefix>
            <AmountInput.Control />
            <AmountInput.Suffix>USDC</AmountInput.Suffix>
          </AmountInput.Root>
        </Field.Root>
        <Choicebox.RadioGroup aria-label="Funding method" defaultValue="bank">
          <Choicebox.Radio value="bank">
            <Choicebox.Title>Bank transfer</Choicebox.Title>
            <Choicebox.Description>Arrives in one to two business days</Choicebox.Description>
          </Choicebox.Radio>
          <Choicebox.Radio value="card">
            <Choicebox.Title>Debit card</Choicebox.Title>
            <Choicebox.Description>Arrives in minutes</Choicebox.Description>
          </Choicebox.Radio>
        </Choicebox.RadioGroup>
        <div {...styles.actions()}>
          <Button type="button" variant="secondary">
            Cancel
          </Button>
          <Button type="submit">Continue</Button>
        </div>
      </form>
    </Inset>
  )
}

namespace styles {
  // Below 640px the actions stack at full width with the primary action on top. The DOM keeps
  // Cancel first, so the inline layout's tab order matches its visual order.
  export const actions = style({
    display: 'flex',
    flexDirection: 'column-reverse',
    gap: '3',
    '@media sm': { flexDirection: 'row', justifyContent: 'flex-end' },
  })

  export const column = style({
    display: 'flex',
    flexDirection: 'column',
    marginInline: 'auto !custom',
    maxWidth: 'md',
    rowGap: '6',
  })

  export const header = style({ display: 'flex', flexDirection: 'column', rowGap: '2' })

  export const page = style({ backgroundColor: 'background.primary' })

  export const step = style({ color: 'gray.900', margin: '0', typography: 'label.13' })

  export const title = style({ margin: '0', typography: 'heading.24' })
}
```

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

Below 640px, the actions stack at full width with the primary action on top, where a thumb reaches it. From 640px, they align to the end with the primary action last.

## Guidelines

### Start From 320px

Write the base styles for a 320px viewport, then add wider layouts with `'@media sm'` and larger keys. Check every page at 320px and at 200 percent zoom. Nothing outside a scroll region should scroll sideways.

### Let Content Reflow Before Adding Breakpoints

Many layouts adapt without a media query. `Grid` with `fit` fits as many columns as the width allows, `Inline` with `wrap` moves items to a new line, and `Hex` truncates to the space it gets.

**Do:** Use `Grid` with `fit` so tiles drop to fewer columns as space runs out.

**Don't:** Force a fixed `columns` count that squeezes values until they break.

### Change Structure Only at Shared Breakpoints

Switch between one and two panes at `lg`, the same width as `AppShell`. Don't add one-off breakpoints such as 900px for a single page, since components won't change there and the page drifts out of step with them.

### Keep the Primary Action Within Reach

Below 640px, stack form and dialog actions at full width, primary action first. Keep each label whole rather than shrinking buttons to fit one row.

**Do:** Stack actions at full width on narrow screens, with the primary action first.

**Don't:** Squeeze several actions into one row at phone width.

### Keep Features and Order the Same

A narrow screen shows the same content and actions as a wide one, arranged differently. Move secondary actions into a `Menu` instead of removing them. Keep the DOM order matching the visual order at every width, so focus and reading order follow the layout.

### Scroll Wide Content Inside Its Own Region

Tables, code, and charts that can't reflow scroll horizontally inside their own container. The page itself never scrolls sideways. See [Tables and Lists](/docs/patterns/tables-and-lists) for turning table rows into stacked items instead.

## States

* **Nothing selected.** In list and detail from 1024px, select the first record or show an `Empty` in the detail pane, such as Select a payment. Below 1024px, show the list alone.
* **Loading.** Render `Skeleton` placeholders in the same structure the data will fill, at every width, so the layout doesn't jump when content arrives.
* **Overlays.** Up to 40rem, `Dialog` presents as a bottom sheet. Prefer a `Drawer` over a `Popover` for long content on narrow screens.
* **Navigation open.** Below 1024px, the `AppShell` drawer is modal and closes with Escape, the backdrop, or a swipe right.

## Accessibility

* Reflow content at 320 CSS pixels without horizontal page scrolling, as WCAG 2.2 Reflow requires. Give any horizontally scrolling region a name and `tabIndex={0}` so keyboard users can scroll it.
* Keep DOM order matching visual order. If stacked actions reverse visually below 640px, keep the DOM order Cancel then primary, so the wide layout's tab order matches what you see.
* Hide inactive panes with `display: none`, which also removes them from the accessibility tree. When a pane hides, move focus to the pane that replaced it, as the list and detail example does.
* Render one `main` landmark per page, through `AppShell.Main` or `Container` with `render={<main />}`, and name each `section` with `aria-labelledby`.
* Size widths in rem so layouts respond to the reader's font size setting.
* `AppShell.Trigger` exposes `aria-expanded` and is named Open menu or Close menu.

## Related

* [Container](/docs/components/container) for content width and gutters
* [Grid](/docs/components/grid) for collections that reflow
* [AppShell](/docs/components/app-shell) for the sidebar and content column
* [Layout](/docs/foundations/layout) for spacing and alignment
* [Navigation](/docs/patterns/navigation) for moving between pages
* [Tables and Lists](/docs/patterns/tables-and-lists) for tables on narrow screens
* [Dashboards](/docs/patterns/dashboards) for metric grids that reflow
