RoadieRoadie
  • Home
  • Foundations
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeFoundationsComponents
Appearance

Charts

  • Guidelines

    • Data visualisation
    • Dashboard design
  • Layout

    • Dashboard
    • DataCard
  • Data pieces

    • StatTile
    • Meter
    • Sparkline
    • DataTable
  • Chart types

    • LineChart
    • BarChart
    • RankedBars
    • StackedBars
    • Histogram
    • Funnel
    • Heatmap
    • Scatter
    • SmallMultiples
  • Chart parts

    • Chart
    • ChartLegend
    • ChartPatterns
    • ChartTooltip
  • Examples

    • Show dashboard
    • Portfolio dashboard
    • Audience dashboard

Dashboard design

A dashboard is a set of cards on one grid. These rules decide which cards to use, how big each one is and what goes in it, so every Oztix dashboard reads the same way.

Start with the question

A dashboard answers a few questions for one person. A promoter asks whether a show will sell out. A venue asks which nights need a push. Write those questions down first, then give each one a card. If a card answers no one’s question, cut it.

The grid

Dashboard sizes itself from its own width, not the window. It has 12 tracks from 960px, 6 from 600px and 2 below that, so it works in a side pane as well as on a phone. Cards flow in source order and the grid never moves them, so reading order always matches what people see. Cards in a row stretch to the same height. Dashboard.Section groups cards under an h2, and sections sit 32px apart.

SizeDesktop (of 12)Tablet (of 6)Phone (of 2)Use
stat331Stat tiles
sm432Ranked list, small chart
md632Chart, table
lg862Main chart
full1262Heatmap, wide table

There are no custom spans, breakpoint overrides or dense packing. If a layout needs a size that isn’t here, it gets a new size in Roadie.

At a glance

Sat 14 Nov, 30 days to go

What to do next

GA is carrying the show and VIP is 45 points short of target. Push VIP in the final month.

Tickets sold

1,464216up 216, better

This week, of 2,400

Sell-through

61%9 ptsup 9 points, better

Target 85%

Pace index

11212%up 12%, better

Similar shows = 100

Gross revenue

$118.4k9%up 9%, better

On last week

Sales

Sales pace

61%9 ptsup 9 points, better

Ahead of similar shows. Forecast 96%

Today 61%Line-up dropForecast 96%Similar shows 86%Target 85%16 Aug15 Sept15 Oct14 Nov0%50%100%

Sales pace
DaySoldForecastSimilar showsSimilar shows range
Sun 16 Aug14%14%7% to 22%
Tue 18 Aug18%16%9.3% to 24%
Thu 20 Aug21%19%12% to 27%
Sat 22 Aug24%21%14% to 29%
Mon 24 Aug27%22%15% to 30%
Wed 26 Aug30%24%17% to 32%
Fri 28 Aug32%26%19% to 34%
Sun 30 Aug33%27%20% to 35%
Tue 1 Sept35%29%22% to 37%
Thu 3 Sept35%30%23% to 38%
Sat 5 Sept36%32%25% to 40%
Mon 7 Sept36%33%26% to 41%
Wed 9 Sept36%34%27% to 42%
Fri 11 Sept36%35%28% to 43%
Sun 13 Sept36%36%29% to 44%
Tue 15 Sept36%37%30% to 45%
Thu 17 Sept37%38%31% to 46%
Sat 19 Sept37%39%32% to 47%
Mon 21 Sept38%40%33% to 48%
Wed 23 Sept39%41%34% to 49%
Fri 25 Sept40%42%35% to 50%
Sun 27 Sept42%43%36% to 51%
Tue 29 Sept43%44%37% to 52%
Thu 1 Oct46%45%38% to 53%
Sat 3 Oct48%46%39% to 54%
Mon 5 Oct50%47%40% to 55%
Wed 7 Oct53%48%41% to 56%
Fri 9 Oct55%49%42% to 57%
Sun 11 Oct57%50%43% to 58%
Tue 13 Oct59%51%44% to 59%
Thu 15 Oct61%53%46% to 61%
Sat 17 Oct63%54%47% to 62%
Mon 19 Oct64%56%49% to 64%
Wed 21 Oct66%57%50% to 65%
Fri 23 Oct68%59%52% to 67%
Sun 25 Oct69%60%53% to 68%
Tue 27 Oct71%62%55% to 70%
Thu 29 Oct73%64%57% to 72%
Sat 31 Oct76%67%60% to 75%
Mon 2 Nov78%69%62% to 77%
Wed 4 Nov80%71%64% to 79%
Fri 6 Nov83%74%67% to 82%
Sun 8 Nov86%77%70% to 85%
Tue 10 Nov89%80%73% to 88%
Thu 12 Nov92%83%76% to 91%
Sat 14 Nov96%86%79% to 94%
Oztix sales. 38 similar shows, last 3 years.

Ticket types

VIP is the one to push this month

Ticket types
Ticket typeSell-through
GA early bird300 of 300
100%
GA1,004 of 1,700
59%
VIP160 of 400
40%
Oztix sales.

Where buyers are from

Most buyers are within 20km

Where buyers are from
SuburbShare
Fortitude Valley14%
West End11%
Paddington9%
Newstead7%
Oztix sales, billing postcodes.

Buyers

Daily orders

Orders peak on Fridays and grow each week

25 Sept29 Sept3 Oct7 Oct11 Oct15 Oct01530

Daily orders
DayOrders
Fri 25 Sept15
Sat 26 Sept12
Sun 27 Sept9
Mon 28 Sept7
Tue 29 Sept7
Wed 30 Sept8
Thu 1 Oct10
Fri 2 Oct19
Sat 3 Oct16
Sun 4 Oct11
Mon 5 Oct9
Tue 6 Oct10
Wed 7 Oct10
Thu 8 Oct13
Fri 9 Oct21
Sat 10 Oct18
Sun 11 Oct12
Mon 12 Oct9
Tue 13 Oct10
Wed 14 Oct11
Thu 15 Oct15
Oztix sales.

Ticket type mix

GA is carrying the show

  • GA
  • VIP
  • Early bird
0400800AugSepOct

Ticket type mix
MonthGAVIPEarly birdTotal
Aug41460300774
Sep213400253
Oct377600437
Oztix sales.

When fans buy

Fans buy most on Friday evenings

  • Fewer
  • More
9am12pm3pm6pm9pmMonTueWedThuFriSatSun

When fans buy
Weekday9am12pm3pm6pm9pm
Mon48101812
Tue59112013
Wed59122214
Thu610143022
Fri812184236
Sat1422202418
Sun121816148
Oztix sales, venue time.

This is the show dashboard. It opens with a full note that says what to do, then four stat tiles, a full chart and two md tables side by side. Two md charts and a full heatmap close it. This column is narrower than 960px, so it shows 6 tracks or fewer. Here is the same layout at each width, and the JSX behind it.

Desktop

full
stat
stat
stat
stat
full
md
md
md
md
full

Tablet

full
stat
stat
stat
stat
full
md
md
md
md
full

Phone

full
stat
stat
stat
stat
full
md
md
md
md
full
<Dashboard>
<Dashboard.Section title='At a glance'>
<DataCard size='full' label='What to do next'>
<p>GA is carrying the show and VIP is 45 points short of target. Push VIP in the final month.</p>
</DataCard>
<StatTile label='Tickets sold' value={1464} delta={{ value: 216 }} />
<StatTile label='Sell-through' value={0.61} format='percent' />
<StatTile label='Pace index' value={112} format='index' />
<StatTile label='Gross revenue' value={118400} format='compactCurrency' />
</Dashboard.Section>
<Dashboard.Section title='Sales'>
<Chart size='full' label='Sales pace' source='Oztix sales.'>
<LineChart data={pace} x='day' y='sold' format='percent' />
</Chart>
<DataCard size='md' label='Ticket types'>
<DataTable columns={typeColumns} rows={types} />
</DataCard>
<DataCard size='md' label='Where buyers are from'>
<DataTable columns={suburbColumns} rows={suburbs} />
</DataCard>
</Dashboard.Section>
<Dashboard.Section title='Buyers'>
<Chart size='md' label='Daily orders' takeaway='Orders peak on Fridays and grow each week'>
<BarChart data={dailyOrders} x='day' y='orders' />
</Chart>
<Chart size='md' label='Ticket type mix' takeaway='GA is carrying the show'>
<StackedBars data={salesByMonth} x='month' y='sold' series='type' />
</Chart>
<Chart size='full' label='When fans buy' takeaway='Fans buy most on Friday evenings'>
<Heatmap data={ordersByHour} rows='weekday' columns='hour' value='orders' />
</Chart>
</Dashboard.Section>
</Dashboard>

Rows that fill

Every row must fill at desktop, tablet and phone widths. These combinations do.

RowDesktopTabletPhone
Four stat
Two md
One full
Six sm
Mirrored lg + sm

On tablet, lg takes a full row and sm takes half. So lg then sm leaves half a row empty. The mirrored sm fills it.

Mirror a large card

Desktop

lg
sm
sm
lg

Tablet

lg
sm
sm
lg

Phone

lg
sm
sm
lg

Do

Follow lg and sm with sm and lg. Both rows fill at every width.

Desktop

lg
Gap
md
Gap

Tablet

lg
md
Gap

Phone

lg
md

Don’t

Don’t follow lg with md. It leaves gaps on desktop and tablet.

In development, Dashboard.Section logs a warning for any row that leaves a gap. It uses the same check as validateDashboard. It only sees the section’s direct children. If you wrap a card, pass size to the wrapper so the check still sees it.

[Roadie Dashboard] "Pace" has rows that don't fill: desktop row 1 leaves 4 empty; desktop row 2 leaves 6 empty; tablet row 2 leaves 3 empty. See /charts/dashboards.

Card anatomy

DataCard is the shared card. StatTile, table cards and Chart are built on it. Its props match the JSON description field for field, so a card in JSX and a card in JSON come out the same.

Pace to date

61%16 ptsup 16 points, better

Forecast 96% by show day

  • This show
  • Forecast
  • Similar shows
Today 61%Forecast 96%Target 85%Similar shows 85%1 Sept26 Sept21 Oct0%50%100%

Oztix sales. 38 similar shows.
  1. Label. Required. Fixed and short.In the example, “Pace to date”
  2. Headline. A value with a short delta, or a takeaway sentence. Never both.In the example, “61% ↑ 16 pts”
  3. Context line. Optional. What the delta is compared with.In the example, “Forecast 96% by show day”
  4. Actions. Top right. The Chart and Table switch, and an optional menu.In the example, “Chart, Table”
  5. Body. A sparkline, plot, table, note text or nothing.In the example, “A LineChart”
  6. Footer. The source, under a hairline. Required on chart and table cards.In the example, “Oztix sales. 38 similar shows.”

The label, headline and context line never wrap. Text that doesn’t fit ends in an ellipsis. The full text goes in the tooltip and the accessible name. The legend is the only chart text that wraps.

A number or a sentence

The headline is a value with a delta, or a takeaway sentence. Never both. Use a value when one number answers the question. Use a takeaway when there’s no single number, such as a channel mix or a table.

One headline per card

Gross revenue

$118.4k4%down 4%, worse

Last week, before fees

Do

Let the value and delta carry the story.

Gross revenue

$118.4k

Revenue fell 4% on last week

Don’t

Don’t add a takeaway that repeats the value.

Which card answers which question

QuestionCardExample
How much, right nowStat tileTickets sold
Is it on trackStat tile with a reference lineSell-through against 85%
Which ones need attentionData table with sparklinesUpcoming shows
What should I doNote cardWhat to do next
Is the show selling on paceChart card showing LineChart with band, median, forecast, target and todayPace index
How many have sold, and whenChart card showing LineChart (cumulative), BarChart by daySales to date, daily orders
What moved salesAnnotations on a time chartAnnouncements, line-up drops, email sends, ad bursts, price releases
How did the on-sale goChart card showing BarChart by hour, annotatedOn-sale spike
Which ticket types are sellingChart card showing StackedBars using the pair or trio setsTicket type mix
How much sold before general saleChart card showing StackedBarsPresale and general
Where did buyers come fromChart card showing RankedBarsChannel, referrer, campaign
Where do buyers liveChart card showing RankedBars with sharePostcode, region, travel distance
Who are the buyersChart card showing RankedBars or StackedBarsAge and gender brackets, new and returning
How far ahead do fans buyChart card showing HistogramBooking lead time
How many tickets per orderChart card showing HistogramOrder size
Where do buyers drop outChart card showing FunnelCheckout conversion, waitlist to purchase
When do fans buyChart card showing HeatmapOrders by hour and weekday, venue timezone
Which sections are fillingChart card showing Heatmap, or a table of metersSell-through by section or zone
Is there unmet demandChart card showing BarChart with annotations, FunnelSell-out time by release, waitlist sign-ups, abandoned checkouts
How do buyers respond to priceChart card showing RankedBars, annotated BarChartSales by price tier, jumps at each price release
What else do our buyers go toChart card showing RankedBarsAlso bought
How are all my shows trackingChart card showing Scatter with quadrantsPortfolio pace against sell-through
How did entry goChart card showing BarChart with line, SmallMultiples per gateScan rate per 15 minutes, share of fans inside
Did people turn upChart card showing RankedBars with reference (similar shows)Attendance against sold, no-show rate
How good was the forecastChart card showing LineChartForecast against actual
How much was resoldChart card showing StackedBarsResale share

Copy that fits

Copy is sized for the card’s narrowest width. Past these limits it truncates.

SizeLabelContext
stat19 characters20 characters
md32 characters34 characters

Chart cards carry the Chart/Table switch in the label’s row, so their label budget is tighter than COPY_LIMITS.

SizeLabel
sm21 characters
md21 characters
lg27 characters
full27 characters
  • Keep the delta short. It sits next to the value.
  • Say what the delta is compared with on the context line.
  • No dashes as punctuation. Use “to” for ranges.
  • Sentence case for labels, context and takeaways.

Short label, short delta

Tickets sold

1,842214up 214, better

This week, of 2,400

Do

Put the comparison on the context line.

Total tickets sold this week

1,842 ↑ 214 this week vs last week

Don’t

Don’t pack the comparison into the label or delta.

States

Every card takes a state. The app sets it. A hidden sparkline is not a state.

ready

The data arrived. This is the default.

Tickets sold

1,842214up 214, better

This week, of 2,400

Oztix sales.

loading

The app is fetching. The label shows straight away and skeletons hold the shape, so nothing jumps when the data lands.

Tickets sold

Oztix sales.

empty

There is nothing to count yet. Say so in one line. Never draw a chart full of zeros.

Tickets sold

No sales yet. Tickets go on sale Fri 27 Nov.

error

The request failed. The card says so in plain words and the app passes a Retry action.

Tickets sold

We couldn't load tickets sold. Try again in a minute.

stale

The app decides when data is stale and passes the time to show in the footer.

Tickets sold

1,842214up 214, better

This week, of 2,400

Oztix sales.As of 10:42am

Card actions

Actions sit at the top right of a card, in the label’s row. They carry handlers, so the app adds them and the JSON never does. Pass actions to DataCard, StatTile or Chart. For cards described as data, pass cardActions to DashboardView. It gets each card’s spec and returns its actions, or nothing. When DashboardView renders on the server, return a client component that owns the handlers, not inline handlers.

// CardMenu.tsx
'use client'
import {
ArrowRightIcon,
CopyIcon,
DownloadIcon,
EyeSlashIcon,
ImageIcon,
PencilSimpleIcon,
TrashIcon
} from '@phosphor-icons/react'
import type { ChartTable } from '@oztix/roadie-charts/tables'
import { DataCard } from '@oztix/roadie-components/data-card'
import { Menu } from '@oztix/roadie-components/menu'
function csvField(value: unknown) {
const text = Array.isArray(value) ? value.join(' ') : String(value ?? '')
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text
}
function tableCsv({ columns, rows }: ChartTable) {
const lines = [
columns.map((column) => csvField(column.header)),
...rows.map((row) => columns.map((column) => csvField(row[column.key])))
]
return lines.map((line) => line.join(',')).join('\n')
}
function downloadCsv(label: string, table: ChartTable) {
const url = URL.createObjectURL(
new Blob([tableCsv(table)], { type: 'text/csv' })
)
const link = document.createElement('a')
link.href = url
link.download = `${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.csv`
link.click()
// Revoking in the same task can cancel the download in some browsers.
setTimeout(() => URL.revokeObjectURL(url))
}
export function CardMenu({ label, table }: { label: string; table?: ChartTable }) {
return (
<Menu>
<Menu.Trigger render={<DataCard.MoreButton label={label} />} />
<Menu.Content align='end'>
<Menu.Group>
<Menu.GroupLabel>Chart</Menu.GroupLabel>
<Menu.Item icon={<PencilSimpleIcon weight='bold' />}>Edit</Menu.Item>
<Menu.Item icon={<CopyIcon weight='bold' />}>Duplicate</Menu.Item>
</Menu.Group>
<Menu.Separator />
<Menu.Group>
<Menu.GroupLabel>Display</Menu.GroupLabel>
<Menu.Item icon={<EyeSlashIcon weight='bold' />}>Hide description</Menu.Item>
</Menu.Group>
<Menu.Separator />
<Menu.Group>
<Menu.GroupLabel>Export</Menu.GroupLabel>
{table && (
<Menu.Item
icon={<DownloadIcon weight='bold' />}
onClick={() => downloadCsv(label, table)}
>
Download CSV
</Menu.Item>
)}
<Menu.Item icon={<ImageIcon weight='bold' />}>Copy as image</Menu.Item>
</Menu.Group>
<Menu.Separator />
<Menu.Group>
<Menu.GroupLabel>Dashboard</Menu.GroupLabel>
<Menu.Item icon={<ArrowRightIcon weight='bold' />}>Move to</Menu.Item>
<Menu.Item intent='danger' icon={<TrashIcon weight='bold' />}>
Remove from dashboard
</Menu.Item>
</Menu.Group>
</Menu.Content>
</Menu>
)
}
// page.tsx
import { cardTable } from '@oztix/roadie-charts/tables'
<DashboardView
spec={spec}
cardActions={(card) => (
<CardMenu label={card.label} table={cardTable(card)} />
)}
/>

Slot order

  • The Chart and Table switch comes first, on chart cards.
  • Then at most one visible action, for the thing people do most on that card.
  • More comes last, always. Every other action goes in its menu.

One action, then More

Sales pace

61%

Today 61%Forecast 96%Target 85%Similar shows 85%1 Sept26 Sept21 Oct0%50%100%

Oztix sales.

Do

Put the view switch first and More last.

Ticket types

VIP is the one to push

Don’t

Don’t line up icon buttons. They crowd out the label.

Always visible

  • Actions show all the time, not only on hover, so they work on touch and by keyboard.
  • Tab order follows reading order: the view switch, then the action, then More.
  • Actions stay in every state, so Refresh data and Remove from dashboard still work on a card that failed to load. The view switch shows only when there’s data.

Keep actions in view

Ticket types

VIP is the one to push

Do

Show More at rest, in every state.

<DataCard
label='Ticket types'
actions={
<DataCard.MoreButton
label='Ticket types'
className='opacity-0 group-hover/card:opacity-100'
/>
}
/>

Don’t

Don’t hide actions until hover. Touch has no hover, and a hidden button still takes focus.

The More menu

Group the menu in this order and leave out what a card can’t do. Build it with Menu: pass DataCard.MoreButton to Menu.Trigger as its render. The button keeps its name, “More actions for” the card’s label, and its ref. Name each group with Menu.GroupLabel, split groups with Menu.Separator, and open the menu with align='end' so it opens back over the card.

GroupItems
ChartView, Edit, Rename, Duplicate, Alerts
DisplayHide description, and the view options
ExportDownload CSV, Copy as image, and Refresh data with “Last updated 4 hours ago” as helper text
DashboardMove to, Copy to, then Remove from dashboard last, in the danger intent
  • Download CSV from the card’s table. cardTable(card) from @oztix/roadie-charts/tables builds it on the server, for a chart card or a table card.
  • Copy as image with renderChartSvg from @oztix/roadie-charts/static.
  • Refresh data shows when the numbers were last updated, as helper text under the item.

Copy

  • Sentence case, verb first: “Download CSV”, not “CSV download”.
  • No dashes as punctuation.
  • Destructive actions confirm first. Remove from dashboard asks before it removes.

Label room

More takes room from the label, which truncates past it. Beside More, keep chart labels to 18 characters at sm and md and 23 at lg and full, and stat labels to 14. ACTIONS_LABEL_LIMITS holds them. Table and note labels keep their COPY_LIMITS. validateDashboard can’t check this, because actions aren’t part of the JSON.

Tables on dashboards

  • Use at most two visual columns, sparkline or meter, per table.
  • Give each column a priority. As the card narrows, higher numbers hide first. Columns without one always show.
  • The “Show all columns” button switches to horizontal scrolling and holds the pin column in place.
  • A sparkline cell needs 5 points. With fewer, it shows “Not enough history”.

Upcoming shows

One show is behind similar shows

Upcoming shows
ShowDaily sales, 30 daysSell-throughPace indexGross
Ball Park MusicThe Lantern Room, Fortitude Valley
61%
112112, up 12, better$118.4k
Ocean AlleyParkside Amphitheatre, Geelong
51%
101101, up 1, better$183k
Julia JacklinThe Gasworks Room, Hobart
40%
7878, down 22, worse$22.9k
Oztix sales. Pace against 38 similar shows.

At this width the sparkline column hides first. Show all columns brings it back.

Describe it as data

Any dashboard can be written as a JSON description with its numbers inline. Card fields match the component props, and DashboardView from @oztix/roadie-charts renders it. Agents can build and check a dashboard this way before anyone sees it.

{
"id": "sold", "kind": "stat", "size": "stat",
"label": "Tickets sold", "value": 1464,
"delta": { "value": 216 }, "context": "This week, of 2,400"
}

validateDashboard from @oztix/roadie-core/dashboard returns every problem with its path.

import { validateDashboard } from '@oztix/roadie-core/dashboard'
const { problems } = validateDashboard(spec)

Here the second section puts a pace card at lg before a channels card at md. It returns these problems.

PathMessageSeverity
sections[1]Row 1 leaves 4 empty columns on desktop after pacewarning
sections[1]Row 2 leaves 6 empty columns on desktop after channelswarning
sections[1]Row 2 leaves 3 empty columns on tablet after channelswarning

It also checks sizes per card kind, unique ids, visual columns, a source on every chart and table card, copy lengths, dashes and sentence case.

CARD_SPANS and COPY_LIMITS hold the sizes and limits above. Import them from @oztix/roadie-core/dashboard-layout when you only need the layout rules, because that entry doesn’t load Zod.

Accessibility

  • Each card is a section named by its label. Dashboard sections use h2 headings.
  • Values and deltas read as sentences, such as “Sell-through, 61%, up 9 points, better. This week, target 85%.”
  • A plot is an image with a one-line summary from its headline or takeaway. The Table tab is the full alternative, reachable by keyboard.
  • Colour is never the only signal. Deltas have arrows and words, table status has text, and textures turn on under forced colours.
  • Nothing animates.
Previous page← Data visualisationNext pageDashboard →