# Dashboards

Summarizes system metrics, such as throughput and block time, in tiles, filters, and charts you can scan at a glance.

## Overview

A dashboard answers one question quickly, such as how the latest benchmark run performed. Tempo Performance (perf.tempo.xyz) shows the pattern: a run header, filters for the run and scenario, a row of metric tiles, and sections of charts.

This page combines:

* `Surface` for metric tiles and chart containers.
* `Grid` for layouts that reflow on small screens.
* `Select`, `Combobox`, and `ChoiceGroup` for filters.
* `Popover` with an icon `Button` for metric definitions.
* `Skeleton`, `Empty`, and `Callout` for loading, empty, and error states.
* `Table` for exact values behind a chart.

> [!NOTE]
> The library has no chart component. The examples draw placeholder bars with `style` to show the container around a chart. Render the plot itself with your charting library.

## Recommended Structure

1. **Header.** The page title, the run's metadata (date, commit, duration), and a `Tag` for its type, such as Nightly.
2. **Filters.** One row of labelled controls that apply as soon as they change.
3. **Metric tiles.** Four to eight headline numbers, most important first.
4. **Chart sections.** One `Surface` per chart, grouped under section headings such as Throughput or Txpool.
5. **Detail tables.** Exact values, such as p50, p95, and p99 timings, for readers who need numbers rather than shapes.

```tsx
import { Button, ChoiceGroup, Field, Grid, Popover, Select, Skeleton, Stack, style, Surface, Tag } from '@tempoxyz/ds'
import { InfoCircle } from '@tempoxyz/ds/icons'
import { useState } from 'react'

export const fill = true
const runs = {
  'main-2026-10-17-8c1d04ef': 'main-2026-10-17-8c1d04ef',
  'main-2026-10-18-a41f9e20': 'main-2026-10-18-a41f9e20',
  'main-2026-10-19-296be2ab': 'main-2026-10-19-296be2ab',
}

const scenarios = { mix: 'Public mix, multi-region', transfers: 'Transfers, single region' }

const metrics = [
  ['Settled TPS', '14,960', 'TPS', 'Median settled throughput across 142 measured blocks.'],
  ['Block time', '633', 'ms', 'Median time between consecutive blocks.'],
  ['Blocks', '142', undefined, 'Blocks produced during the measured window.'],
  ['Avg gas/s', '1.38', 'Ggas', 'Mean gas processed per second.'],
  ['Peak gas/s', '2.00', 'Ggas', 'Highest gas processed in any one second.'],
  ['Accounts', '1,000', undefined, 'Accounts sending transactions during the run.'],
] as const

const throughput = [14.2, 14.8, 15.1, 14.6, 15.4, 14.9, 15.0, 14.4, 15.2, 14.7, 15.3, 14.9]

function Metric(props: { info: string; label: string; unit?: string | undefined; value: string }) {
  return (
    <Surface size="small">
      <dt {...styles.label()}>
        {props.label}
        <Popover.Root>
          <Popover.Trigger
            aria-label={`About ${props.label}`}
            openOnHover
            render={<Button shape="icon" size="small" variant="text" />}
          >
            <InfoCircle />
          </Popover.Trigger>
          <Popover.Popup>
            <Popover.Title>{props.label}</Popover.Title>
            <Popover.Description>{props.info}</Popover.Description>
          </Popover.Popup>
        </Popover.Root>
      </dt>
      <dd {...styles.value()}>
        {props.value}
        {props.unit && <span {...styles.unit()}> {props.unit}</span>}
      </dd>
    </Surface>
  )
}

export function Demo() {
  const [quantile, setQuantile] = useState('p50')

  return (
    <Stack gap="6" {...styles.page()}>
      <header {...styles.header()}>
        <Tag size="small">Nightly</Tag>
        <h1 {...styles.title()}>Benchmark run</h1>
        <p {...styles.meta()}>
          Oct 20, 2026, 3:01 AM · Commit <code {...styles.mono()}>296be2a</code> · 2m 35s
        </p>
      </header>
      <div {...styles.filters()}>
        <Field.Root {...styles.filter()}>
          <Field.Label>Run</Field.Label>
          <Select.Root defaultValue="main-2026-10-19-296be2ab" items={runs}>
            <Select.Trigger size="small">
              <Select.Value {...styles.mono()} />
              <Select.Icon />
            </Select.Trigger>
            <Select.Popup size="small">
              {Object.entries(runs).map(([value, label]) => (
                <Select.Item key={value} value={value}>
                  <Select.ItemText {...styles.mono()}>{label}</Select.ItemText>
                </Select.Item>
              ))}
            </Select.Popup>
          </Select.Root>
        </Field.Root>
        <Field.Root {...styles.filter()}>
          <Field.Label>Scenario</Field.Label>
          <Select.Root defaultValue="mix" items={scenarios}>
            <Select.Trigger size="small">
              <Select.Value />
              <Select.Icon />
            </Select.Trigger>
            <Select.Popup size="small">
              {Object.entries(scenarios).map(([value, label]) => (
                <Select.Item key={value} value={value}>
                  <Select.ItemText>{label}</Select.ItemText>
                </Select.Item>
              ))}
            </Select.Popup>
          </Select.Root>
        </Field.Root>
        <div {...styles.quantile()}>
          <span id="quantile-label" {...styles.legend()}>
            Timing quantile
          </span>
          <ChoiceGroup.Root
            aria-labelledby="quantile-label"
            onValueChange={(value) => setQuantile(String(value))}
            size="small"
            value={quantile}
          >
            <ChoiceGroup.Item value="p50">p50</ChoiceGroup.Item>
            <ChoiceGroup.Item value="p95">p95</ChoiceGroup.Item>
            <ChoiceGroup.Item value="p99">p99</ChoiceGroup.Item>
          </ChoiceGroup.Root>
        </div>
      </div>
      <dl {...styles.tiles()}>
        {metrics.map(([label, value, unit, info]) => (
          <Metric info={info} key={label} label={label} unit={unit} value={value} />
        ))}
      </dl>
      <Grid fit="large" gap="4">
        <Surface render={<section aria-labelledby="tps-title" />} size="medium">
          <Surface.Header>
            <Surface.Title id="tps-title">Settled TPS</Surface.Title>
          </Surface.Header>
          <div
            aria-label="Settled TPS per block, between 14,200 and 15,400"
            role="img"
            {...styles.plot()}
          >
            {throughput.map((value, index) => (
              <span key={index} {...styles.bar({ style: { height: `${(value - 13) * 40}%` } })} />
            ))}
          </div>
          <p {...styles.caption()}>Thousands of transactions per second, per block</p>
        </Surface>
        <Surface render={<section aria-labelledby="validation-title" />} size="medium">
          <Surface.Header>
            <Surface.Title id="validation-title">Block validation ({quantile})</Surface.Title>
          </Surface.Header>
          <Skeleton {...styles.plot()} />
          <p role="status" {...styles.caption()}>
            Loading chart data
          </p>
        </Surface>
      </Grid>
    </Stack>
  )
}

namespace styles {
  export const bar = style({
    backgroundColor: 'blue.700',
    borderTopLeftRadius: 'sm',
    borderTopRightRadius: 'sm',
    flex: '1 !custom',
  })

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

  export const filter = style({ flex: '1 1 12rem !custom', minWidth: '0' })

  export const filters = style({
    alignItems: 'flex-end',
    display: 'flex',
    flexWrap: 'wrap',
    gap: '3',
  })

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

  export const label = style({
    alignItems: 'center',
    color: 'gray.900',
    columnGap: '1',
    display: 'flex',
    marginBlockEnd: '2',
    typography: 'label.13',
    // A 24px info button keeps the label line at its text height.
    selectors: { '& button': { height: '6', marginBlock: '-4px !custom' } },
  })

  export const legend = style({ color: 'foreground', typography: 'label.12.strong' })

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

  export const mono = style({ typography: 'copy.13.mono' })

  export const page = style({
    backgroundColor: 'background.primary',
    padding: '4',
    '@media sm': { padding: '8' },
  })

  export const plot = style({
    alignItems: 'flex-end',
    display: 'flex',
    gap: '1',
    height: '40',
    marginBlockEnd: '3',
  })

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

  export const tiles = style({
    display: 'grid',
    gap: '3',
    gridTemplateColumns: 'repeat(2, minmax(0, 1fr)) !custom',
    margin: '0',
    '@media md': { gridTemplateColumns: 'repeat(3, minmax(0, 1fr)) !custom' },
    '@media xl': { gridTemplateColumns: 'repeat(6, minmax(0, 1fr)) !custom' },
  })

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

  export const unit = style({ color: 'gray.900', typography: 'label.14' })

  export const value = style({
    fontVariantNumeric: 'tabular-nums !custom',
    margin: '0',
    typography: 'heading.24',
  })
}
```

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

## Metric Tiles

Each tile holds a label, a value, a unit, and an optional info button.

* **Label.** A short noun phrase in sentence case, such as Settled TPS or Block time.
* **Value.** A number in `fontVariantNumeric: 'tabular-nums'`, rounded to the precision readers act on, such as 1.38 rather than 1.3812.
* **Unit.** A separate, smaller `gray.900` span after the value, such as ms, TPS, or Ggas.
* **Info.** An icon `Button` with `InfoCircle`, named About Settled TPS, that opens a `Popover` with the metric's definition.

Mark up the tiles as a `dl`, with each label in a `dt` and each value in a `dd`, so each value stays tied to its label.

**Do:** Label every tile and set the unit beside the value.

**Don't:** Show a bare number without its label or unit.

### Put Definitions in a Popover

A `Tooltip` stays closed on touch input and isn't announced by screen readers, so a definition placed there is lost for many readers. Use a `Popover` instead, and set `openOnHover` on `Popover.Trigger` so it also opens on hover.

Write the definition in one or two sentences, such as Median settled throughput across 142 measured blocks.

### Show Change With Text

When a tile compares against a baseline, state the direction and the baseline in text, such as +4.2% vs previous run. A green or red color can reinforce the change but never carries it alone.

## Filters

| Control       | Use for                                                                 |
| ------------- | ----------------------------------------------------------------------- |
| `Select`      | One choice from a short or medium list, such as the scenario.           |
| `Combobox`    | One choice from a long list you search, such as 60 run IDs.             |
| `ChoiceGroup` | Two to four short options you compare often, such as p50, p95, and p99. |

* Give every filter a visible label through `Field.Label`, or `aria-labelledby` for a `ChoiceGroup`.
* Apply a filter as soon as it changes. Don't add an Apply button for single-choice controls.
* Keep filter state in the URL, so a shared link opens the same view.
* Name the active scope in chart titles, such as Block validation (p95).
* Use `size="small"` controls in dense dashboards, and the same size for every control in the row.

**Do:** Use a `ChoiceGroup` when a few short options fit on screen.

**Don't:** Hide three short options behind a `Select`.

## Values and Identifiers

* Set metric values in tabular figures, so digits keep their width as values update. `Amount` and `Table.Cell` with `numeric` already do this.
* Set identifiers in monospace with the `copy.13.mono` typography token, such as commit `296be2a` or run `main-2026-10-19-296be2ab`. `Hex` and `Table.Cell` with `mono` use it already.
* Keep one precision per metric across tiles, charts, and runs, so values compare directly.
* Group thousands with the locale separator, such as 14,960. Switch to a larger unit before values grow past four digits, such as 1.38 Ggas instead of 1,380 Mgas.

## Chart Containers

Frame every chart the same way, whatever library draws it.

* Render a `Surface` with `size="medium"` as a `section`, labelled by its `Surface.Title`.
* Keep the plot area at one fixed height, such as 12rem, in every state.
* Put the units and scope in a caption below the plot, such as Milliseconds per block, p50.
* Label series with text in a legend. Draw series in step 700 of the color ramps, such as `blue.700` and `purple.700`, and check each reaches 3:1 contrast against the surface. `teal.700` falls short on a light surface.
* Give the plot `role="img"` and an `aria-label` that summarizes it, such as Settled TPS per block, between 14,200 and 15,400. Offer the values in a `Table` when readers need them.

Switch between the states of one chart below.

```tsx
import { Callout, ChoiceGroup, Empty, Skeleton, Stack, style, Surface } from '@tempoxyz/ds'
import { ChartBar } from '@tempoxyz/ds/icons'
import { useState } from 'react'

export const fill = true
const latency = [42, 38, 45, 51, 40, 37, 44, 48, 39, 41, 46, 43, 38, 40]

type State = 'empty' | 'error' | 'loaded' | 'loading'

export function Demo() {
  const [state, setState] = useState<State>('loaded')

  return (
    <Stack gap="4">
      <ChoiceGroup.Root
        aria-label="Chart state"
        onValueChange={(value) => setState(value as State)}
        size="small"
        value={state}
      >
        <ChoiceGroup.Item value="loaded">Loaded</ChoiceGroup.Item>
        <ChoiceGroup.Item value="loading">Loading</ChoiceGroup.Item>
        <ChoiceGroup.Item value="empty">Empty</ChoiceGroup.Item>
        <ChoiceGroup.Item value="error">Error</ChoiceGroup.Item>
      </ChoiceGroup.Root>
      <Surface render={<section aria-labelledby="latency-title" />} size="medium">
        <Surface.Header>
          <Surface.Title id="latency-title">New payload validation latency</Surface.Title>
        </Surface.Header>
        {/* The plot area keeps one height in every state, so the grid around it never shifts. */}
        <div {...styles.area()}>
          {state === 'loaded' && (
            <div
              aria-label="Validation latency per block, p50, between 37 and 51 ms"
              role="img"
              {...styles.plot()}
            >
              {latency.map((value, index) => (
                <span key={index} {...styles.bar({ style: { height: `${value * 1.6}%` } })} />
              ))}
            </div>
          )}
          {state === 'loading' && <Skeleton {...styles.fill()} />}
          {state === 'empty' && (
            <Empty.Root>
              <Empty.Icon>
                <ChartBar />
              </Empty.Icon>
              <Empty.Title>No blocks in this range</Empty.Title>
              <Empty.Description>Pick a longer range or another run.</Empty.Description>
            </Empty.Root>
          )}
          {state === 'error' && (
            <Callout intent="negative">
              <Callout.Icon />
              <Callout.Content>Validator telemetry didn't load.</Callout.Content>
              <Callout.Action>Retry</Callout.Action>
            </Callout>
          )}
        </div>
        <p role="status" {...styles.caption()}>
          {state === 'loading' ? 'Loading chart data' : 'Milliseconds per block, p50'}
        </p>
      </Surface>
    </Stack>
  )
}

namespace styles {
  export const area = style({
    alignItems: 'center',
    display: 'flex',
    height: '48',
    justifyContent: 'center',
    marginBlockEnd: '3',
  })

  export const bar = style({
    backgroundColor: 'blue.700',
    borderTopLeftRadius: 'sm',
    borderTopRightRadius: 'sm',
    flex: '1 !custom',
  })

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

  export const fill = style({ alignSelf: 'stretch', flex: '1 !custom' })

  export const plot = style({
    alignItems: 'flex-end',
    alignSelf: 'stretch',
    display: 'flex',
    flex: '1 !custom',
    gap: '1',
  })
}
```

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

## Grid Reflow

* Lay tiles out in two columns below 768px, then add columns at `md` and `xl`. `Grid` with `fit="small"` gives a similar result without media queries.
* Lay charts out with `Grid` and `fit="large"`, which keeps each chart at least 20rem wide. Charts stack in one column on phones and sit two across on wider screens.
* Keep chart heights fixed on narrow screens. Stack charts rather than shrinking them.
* Inside `AppShell`, set `align="start"` on `AppShell.Root` so the dashboard can use the full width beside the sidebar.

## States

* **Loading.** Show labels and titles at once. Fill each plot area with a `Skeleton` of the same height, and wrap pending tile values in `Skeleton`. Say Loading chart data in a `role="status"` caption.
* **Empty.** Place an `Empty` inside the plot area that explains why, such as No blocks in this range, and what to try next.
* **Error.** Place a `Callout` with `intent="negative"` and a Retry `Callout.Action` inside the failed chart. Other charts keep working.
* **Partial data.** Name what is missing, such as Loading validator telemetry, rather than hiding the section.
* **Updating.** Show when data last changed, such as Updated 30 s ago. Update values in place without moving the layout.

**Do:** Hold the plot's height with a `Skeleton` while data loads.

**Don't:** Collapse the chart to a spinner, which shifts every section below it.

## Accessibility

* Tiles use `dl`, `dt`, and `dd`, so screen readers pair each value with its label. Keep the unit in the same `dd` as the value.
* Name each info button by its metric, such as About Block time. `Popover` moves focus into the popup when opened by press, and Escape returns focus to the button.
* Give every chart a text alternative, either a summary in `aria-label` on a `role="img"` plot or a `Table` of the values.
* Announce loading and errors once through `role="status"` or the `Callout` alert role. Don't announce every live update.
* Don't encode a series or a change by color alone. Pair color with text labels.
* Label every filter. `ChoiceGroup` supports arrow keys, and `Select` supports typeahead.
* The 24px info button meets the WCAG 2.2 minimum target size. Give it more room on touch screens.

## Related

* [Surface](/docs/components/surface) for tiles and chart containers
* [Grid](/docs/components/grid) for reflowing tiles and charts
* [Select](/docs/components/select), [Combobox](/docs/components/combobox), and [ChoiceGroup](/docs/components/choice-group) for filters
* [Popover](/docs/components/popover) for metric definitions
* [Skeleton](/docs/components/skeleton) for loading plots
* [Responsive Layout](/docs/patterns/responsive-layout) for the shared breakpoints
* [Tables and Lists](/docs/patterns/tables-and-lists) for exact values
* [Tempo Performance](/docs/products/tempo-performance) for the benchmark dashboard
