Skip to content
  1. Metrics
  2. Stat band

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

PropDefaultDescription
StatBandrenders a <dl> element
columns4Tiles 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.
colorbg-zinc-300 dark:bg-zinc-600Background color classes of the bars.
trend(current, previous, options)
current-The figure of this period.
previous-The same figure one period earlier.
upIsBadfalseFlip 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.
PropDefaultDescription
StatBandrenders a <dl> element
columns4Tiles 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.
colorbg-zinc-300 dark:bg-zinc-600Background color classes of the bars.
trend(current, previous, options)
current-The figure of this period.
previous-The same figure one period earlier.
upIsBadfalseFlip 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" />