---
title: Writing dashboards
description: Build a dashboard from page.md, typed inputs, SQL data sources, charts, tables, maps, and custom components.
species: guide
---
# Writing dashboards

A dashboard is a `dashboards/<slug>/page.md` document plus optional sibling `.sql` and `.tsx` files. Markdown supplies the narrative. Components turn named query results into charts, tables, numbers, pivots, and maps.

## Create the page

```text
dashboards/revenue/
  page.md
  revenue-daily.sql
```

Put the page title, refresh interval, result cap, and optional theme in frontmatter:

```text
---
title: Revenue
refresh: 4h
max_rows: 2000
theme: supernova
---
```

## Add a query

`revenue-daily.sql` is addressed as `revenue-daily` from the page:

```sql
select
  cast(created_at as date) as day,
  sum(amount) / 100.0 as revenue
from titan.stripe.charges
where status = 'succeeded' and not _deleted
group by day
order by day
```

An inline fence named after its language is also a data source: ```` ```sql revenue-daily ````. Sibling files keep longer queries easier to review.

## Build on another query

A query can read another query of the same dashboard as `queries.<name>`:

```sql
select sum(revenue) as revenue
from queries."revenue-daily"
where day >= $period.start
```

The referenced query is inlined where you name it, so it runs inside the same job and always reads current data — nothing is stored between the two. Inputs like `$period.start` work in either query. A reference to a query that does not exist, or a loop between two queries, is reported above the page.

## Render the result

```text
<Chart data={revenue-daily} type="line" x="day" y="revenue"
       title="Revenue by day" format={{"type":"money","currency":"USD"}} />
```

Built-in data components are `Chart`, `Table`, `BigNumber`, `Pivot`, and `Map`. Chart types are `line`, `bar`, `area`, `donut`, `pie`, and `scatter`. Put related components inside `<Row>` to share a row.

## Mark a target

```text
<Chart data={revenue-daily} type="bar" x="day" y="revenue"
       goal={{"y":120000,"label":"Target"}} />
```

`goal` draws a reference line on the value axis and stretches the scale so the line always lands inside the plot. Pass an array for up to four of them — a floor, a plan, a stretch. On horizontal bars the line runs vertically, because it follows the value, not the screen. Donut and pie charts have no value axis, so they reject it.

## Point at one series

```text
<Chart data={revenue-by-plan} type="line" x="day" y="revenue" series="plan" emphasis="enterprise" />
```

`emphasis` keeps the named series in its color and drops every other one to muted ink — lines, areas, bars, dots, legend and tooltip swatches together. Matching ignores case. A name that no series in the result carries changes nothing, so a chart never breaks because a category disappeared from the data.

## Name a moment

```text
<Chart data={revenue-daily} type="line" x="day" y="revenue"
       annotations={[{"x":"2026-03-01","label":"Price change"}]} />
```

Each annotation draws a vertical hairline at that x with a small label at the top of the plot, up to eight of them. The `x` has to be a value the query actually returned — filters move the window, so one that falls outside it is skipped rather than snapped to the edge. Labels are truncated and stagger down a row when they would collide; past four rows the hairline still draws and the label is dropped, because a fifth row of text is the plot.

## Reuse one query for numbers and charts

```text
<BigNumber data={revenue-daily} value="revenue" label="Revenue" agg="sum" trend />
```

A big number reads the first row by default. `agg` folds the whole column instead — `sum`, `avg`, `last`, or `first` — so a daily query can feed a chart and an honest total from the same rows. `trend` adds a sparkline of that column in row order beside the value.

A column of whole numbers sums exactly, past the point a floating-point total starts drifting, so counts and ids stay right. The default `compact` format still rounds what it prints — that is what compact is for; ask for `number` or `integer` when every digit matters.

## Compare against the previous period

```text
<Chart data={revenue-daily} type="line" x="day" y="revenue" compare="prev-period" />
<BigNumber data={revenue-total} value="revenue" label="Revenue" compare="prev-year" />
```

`compare` runs the same query a second time with its date range shifted back — `prev-period` by the length of the current window, `prev-year` by one calendar year. A chart draws the earlier period as a ghost under the current one and, unless you wrote a subtitle, adds the change as a dek. A big number turns it into the change beside the value.

The query has to read a date range for there to be anything to shift, so filter it with `$period.start` and `$period.end`. A chart compares one measure: no `y` list, no `series`, no donut. On a big number, `compare` replaces `delta`.

The two periods are lined up **by value, not by row order**. On a date axis each earlier row moves forward by exactly the shift the query used, so a period missing a day leaves a gap instead of sliding everything left. On a category axis — a top-N chart — the earlier period's rows match by name: a category that was in the top N last period but not this one has nowhere to sit and is not drawn, and one that is new this period has no ghost. The change in the dek is still measured over the whole earlier period, including the rows the chart had no room for, so the sentence stays true even when the picture is partial.

## Add typed inputs

```text
<DateRange name="period" default="-30d" />
<Select name="currency" options={["usd","eur","gbp"]} default="usd" />
```

Reference inputs in SQL as `$period.start`, `$period.end`, and `$currency`. Values are validated against the declaration and bound as prepared-statement parameters. URL values are never interpolated into SQL.

`TextInput`, `NumberInput`, `Select`, and `DateRange` are supported. A `Select` with `multiple` binds a bounded list. Relative date presets include `-30d`, `-2w`, `-6m`, `-1y`, `mtd`, `ytd`, `today`, and `all`.

## Extend a dashboard

Add `<Name>.tsx` beside `page.md` for a page-specific component, or place a shared component under `dashboards/components/`. Custom components receive bounded table data and the same theme utilities as built-ins.
