Stat band
A row of figures separated by rules, each with its bars per period and its change against the last one. No card and no dot: the verdict is in the color of the sentence.
- Screened
- 1,284
- 17% up on last month (good)
- Payments run through the rules
- Flagged
- 34
- 17% down on last month (good)
- Held for a second look
- Under review
- 12
- 33% up on last month (bad)
- Awaiting a decision
- Disputes
- 5
- Same as last month
- Opened by the cardholder's bank
import { StatBand, StatTile, trend } from '@/components/stat-band'
export default function Example() {
return (
<StatBand>
<StatTile
label="Screened"
value="1,284"
series={[38, 42, 35, 51, 47, 55, 44, 39, 58, 61, 49, 52, 66, 58]}
delta={trend(1284, 1102)}
context="Payments run through the rules"
/>
<StatTile
label="Flagged"
value="34"
series={[2, 1, 3, 2, 4, 1, 0, 2, 3, 5, 2, 1, 4, 4]}
color="bg-amber-400"
delta={trend(34, 41, { upIsBad: true })}
context="Held for a second look"
/>
<StatTile
label="Under review"
value="12"
series={[0, 1, 1, 0, 2, 1, 1, 0, 1, 2, 1, 0, 1, 1]}
delta={trend(12, 9, { upIsBad: true })}
context="Awaiting a decision"
/>
<StatTile
label="Disputes"
value="5"
series={[1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1]}
delta={trend(5, 5, { upIsBad: true })}
context="Opened by the cardholder's bank"
/>
</StatBand>
)
}<script setup lang="ts">
import StatBand from '@/components/stat-band.vue'
import StatTile, { trend } from '@/components/stat-tile.vue'
</script>
<template>
<StatBand>
<StatTile
label="Screened"
value="1,284"
:series="[38, 42, 35, 51, 47, 55, 44, 39, 58, 61, 49, 52, 66, 58]"
:delta="trend(1284, 1102)"
context="Payments run through the rules"
/>
<StatTile
label="Flagged"
value="34"
:series="[2, 1, 3, 2, 4, 1, 0, 2, 3, 5, 2, 1, 4, 4]"
color="bg-amber-400"
:delta="trend(34, 41, { upIsBad: true })"
context="Held for a second look"
/>
<StatTile
label="Under review"
value="12"
:series="[0, 1, 1, 0, 2, 1, 1, 0, 1, 2, 1, 0, 1, 1]"
:delta="trend(12, 9, { upIsBad: true })"
context="Awaiting a decision"
/>
<StatTile
label="Disputes"
value="5"
:series="[1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1]"
:delta="trend(5, 5, { upIsBad: true })"
context="Opened by the cardholder's bank"
/>
</StatBand>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import clsx from 'clsx'
import type React from 'react'
import { Sparkbars } from './sparkbars'
const columns = {
2: 'sm:grid-cols-2 sm:*:even:border-l sm:*:even:pl-6',
3: 'sm:grid-cols-2 sm:max-lg:*:even:border-l sm:max-lg:*:even:pl-6 lg:grid-cols-3 lg:*:not-first:border-l lg:*:not-first:pl-6',
4: 'sm:grid-cols-2 sm:max-lg:*:even:border-l sm:max-lg:*:even:pl-6 lg:grid-cols-4 lg:*:not-first:border-l lg:*:not-first:pl-6',
}
const tones = {
neutral: 'text-zinc-500 dark:text-zinc-400',
good: 'text-emerald-600 dark:text-emerald-400',
bad: 'text-red-600 dark:text-red-400',
}
export type Trend = { text: string; tone: keyof typeof tones }
export function trend(
current: number,
previous: number,
{ upIsBad = false, period = 'last month' }: { upIsBad?: boolean; period?: string } = {}
): Trend {
let change = current - previous
if (change === 0) return { text: `Same as ${period}`, tone: 'neutral' }
let tone: Trend['tone'] = change < 0 === upIsBad ? 'good' : 'bad'
if (previous === 0) return { text: `${change > 0 ? 'Up' : 'Down'} from zero on ${period}`, tone }
let share = Math.round(Math.abs(change / previous) * 100)
return { text: `${share}% ${change > 0 ? 'up' : 'down'} on ${period}`, tone }
}
export function StatBand({
columns: count = 4,
className,
...props
}: { columns?: keyof typeof columns } & React.ComponentPropsWithoutRef<'dl'>) {
return <dl {...props} className={clsx(className, 'grid grid-cols-1 gap-x-6 gap-y-8', columns[count])} />
}
export function StatTile({
label,
value,
series,
color,
delta,
context,
className,
children,
...props
}: {
label: React.ReactNode
value: React.ReactNode
series?: number[]
color?: string
delta?: Trend
context?: React.ReactNode
} & React.ComponentPropsWithoutRef<'div'>) {
return (
<div {...props} className={clsx(className, 'min-w-0 border-zinc-950/10 dark:border-white/10')}>
<dt className="truncate text-base/6 sm:text-sm/6 text-zinc-500 dark:text-zinc-400">{label}</dt>
<dd className="mt-1 text-3xl/9 font-semibold text-zinc-950 tabular-nums sm:text-2xl/8 dark:text-white">{value}</dd>
{series && (
<dd>
<Sparkbars series={series} color={color} className="mt-3" />
</dd>
)}
{delta && (
<dd className={clsx('mt-2 text-base/6 font-medium sm:text-sm/6', tones[delta.tone])}>
{delta.text}
{delta.tone !== 'neutral' && <span className="sr-only">{delta.tone === 'good' ? ' (good)' : ' (bad)'}</span>}
</dd>
)}
{context && (
<dd className="mt-0.5 text-base/6 text-pretty text-zinc-500 sm:text-sm/6 dark:text-zinc-400">{context}</dd>
)}
{children}
</div>
)
}<script setup lang="ts">
const columns = {
2: 'sm:grid-cols-2 sm:*:even:border-l sm:*:even:pl-6',
3: 'sm:grid-cols-2 sm:max-lg:*:even:border-l sm:max-lg:*:even:pl-6 lg:grid-cols-3 lg:*:not-first:border-l lg:*:not-first:pl-6',
4: 'sm:grid-cols-2 sm:max-lg:*:even:border-l sm:max-lg:*:even:pl-6 lg:grid-cols-4 lg:*:not-first:border-l lg:*:not-first:pl-6',
}
withDefaults(defineProps<{ columns?: keyof typeof columns }>(), { columns: 4 })
</script>
<template>
<dl class="grid grid-cols-1 gap-x-6 gap-y-8" :class="columns[$props.columns]">
<slot />
</dl>
</template>Component API
| Prop | Default | Description |
|---|---|---|
StatBandrenders a <dl> element | ||
columns | 4 | Tiles per row from `lg`. Two per row from `sm`, one below. Meant for a single row. |
StatTilerenders a <div> element | ||
label | - | What the figure counts. |
value | - | The figure, already formatted. |
series | - | One point per period, oldest first. Drawn as bars under the figure. |
color | - | Bar color classes, passed to `Sparkbars`. |
delta | - | The change, as `{ text, tone }`. Build it with `trend`. A `good` or `bad` tone is also spelled out to screen readers, not only shown in color. |
context | - | One line under the change: what to make of the figure. |
children | - | Anything else under the tile: a meter, a link. |
Sparkbarsrenders a <div> element | ||
series | - | Values, oldest first. Each bar is scaled to the highest one; negative values count as zero. |
label | - | Describes the bars. Without it they are hidden from assistive tech. |
color | bg-zinc-300 dark:bg-zinc-600 | Background color classes of the bars. |
| trend(current, previous, options) | ||
current | - | The figure of this period. |
previous | - | The same figure one period earlier. |
upIsBad | false | Flip the verdict for counts that should fall. |
period | 'last month' | Named in the sentence: `12% up on last month`, or `Up from zero on last month` when the previous figure is zero. |
| Prop | Default | Description |
|---|---|---|
StatBandrenders a <dl> element | ||
columns | 4 | Tiles per row from `lg`. Two per row from `sm`, one below. Meant for a single row. |
StatTilerenders a <div> element | ||
label | - | What the figure counts. |
value | - | The figure, already formatted. |
series | - | One point per period, oldest first. Drawn as bars under the figure. |
color | - | Bar color classes, passed to `Sparkbars`. |
delta | - | The change, as `{ text, tone }`. Build it with `trend`. |
context | - | One line under the change: what to make of the figure. |
#default | - | Anything else under the tile: a meter, a link. |
Sparkbarsrenders a <div> element | ||
series | - | Values, oldest first. Each bar is scaled to the highest one. |
label | - | Describes the bars. Without it they are hidden from assistive tech. |
color | bg-zinc-300 dark:bg-zinc-600 | Background color classes of the bars. |
| trend(current, previous, options) | ||
current | - | The figure of this period. |
previous | - | The same figure one period earlier. |
upIsBad | false | Flip the verdict for counts that should fall. |
period | 'last month' | Named in the sentence: `12% up on last month`. |
Examples
Figures only
A tile needs nothing but a label and a value. columns sets how many sit on the row from lg; from sm they go two by two whatever the count:
- Volume
- €28,400
- Payments
- 412
- Customers
- 96
<StatBand columns={3}>
<StatTile label="Volume" value="€28,400" />
<StatTile label="Payments" value="412" />
<StatTile label="Customers" value="96" />
</StatBand><StatBand :columns="3">
<StatTile label="Volume" value="€28,400" />
<StatTile label="Payments" value="412" />
<StatTile label="Customers" value="96" />
</StatBand>Qualified change
trend words the change and decides whether it is good news. Pass upIsBad for a count that should fall, and the color flips with it. No change reads neutral:
- Screened
- 1,284
- 17% up on last month (good)
- Refunds
- 7
- 42% down on the previous week (good)
- Disputes
- 5
- Same as last month
const delta = trend(1284, 1102)
const refunds = trend(7, 12, { upIsBad: true, period: 'the previous week' })
const same = trend(5, 5)Bars on their own
Sparkbars is the row of bars alone. Scale is the highest point, and an empty period keeps a floor so the row has no holes. Give it a label when it stands without a figure:
<Sparkbars series={series} label="Payments per day, last two weeks" />
<Sparkbars series={series} color="bg-amber-400" />
<Sparkbars series={series} color="bg-blue-500/70" /><Sparkbars :series="series" label="Payments per day, last two weeks" />
<Sparkbars :series="series" color="bg-amber-400" />
<Sparkbars :series="series" color="bg-blue-500/70" />