Sparkline Metric Cards
KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.
Where a total went, drawn as one conic-gradient ring with the total in the hole and every number repeated in the legend.
components/donut-breakdown.tsxnpx hoverlab add donut-breakdown
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Billing period to date, across every project in the workspace.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <DonutBreakdown> — where a total went, as one ring and a legend.
*
* The ring is a single `conic-gradient` with hard colour stops, not arcs
* and not a library. One element, no SVG path arithmetic, no dependency —
* and because the stops are percentages of a turn, adding a slice is adding
* a row to an array rather than recomputing four `A` commands.
*
* WHY A DONUT AND NOT A PIE
*
* The hole is doing work. A pie forces the reader to compare wedge areas,
* which people are measurably bad at; a donut turns the same data into arc
* lengths, which they are better at, and buys the one place the total
* belongs — the middle. A breakdown without its total is a set of
* percentages of something unstated.
*
* WHY SLICES ARE CAPPED AND THE REST IS "OTHER"
*
* Past about six, slices become slivers with labels that cannot be placed,
* and the chart stops being readable exactly as it starts looking
* impressive. Everything below the cut is summed into one honest "Other"
* row rather than being drawn and left unlabelled.
*
* NEVER COLOUR ALONE
*
* Every legend row carries its own percentage and value as text. Colour is
* how you find a row in the ring, never how you read it — around 4% of the
* people looking at this cannot tell two of the default hues apart, and a
* legend that only maps swatch to name makes them guess.
*
* Accessibility: the ring is `aria-hidden`, and the legend is a real
* definition list carrying every number, so the graphic is decoration over
* text rather than a picture of data that only exists in pixels.
*/
import type * as React from 'react'
export interface BreakdownSlice {
label: string
value: number
/** Any CSS colour. Falls back to a ramp mixed off the primary token. */
color?: string
}
export interface DonutBreakdownProps {
heading?: string
description?: string
slices?: BreakdownSlice[]
/** Slices drawn individually before the remainder becomes "Other". */
maxSlices?: number
totalLabel?: string
format?: (value: number) => string
className?: string
}
const DEFAULT_SLICES: BreakdownSlice[] = [
{ label: 'Compute', value: 4820 },
{ label: 'Object storage', value: 2140 },
{ label: 'Egress', value: 1310 },
{ label: 'Managed Postgres', value: 890 },
{ label: 'Log retention', value: 420 },
{ label: 'Image builds', value: 260 },
{ label: 'Secrets manager', value: 95 },
{ label: 'Scheduled jobs', value: 60 },
]
/*
A ramp mixed off the project's own primary, rather than eight hard-coded
hues.
`color-mix` in oklab walks each step further toward the card colour, so
the ring inherits whatever palette it lands in instead of importing a
brand of its own — and because the steps differ in lightness rather than
only in hue, adjacent slices stay distinguishable in greyscale and to a
red-green colour-blind reader.
Note this reads the token directly as `var(--primary)`. The tokens in
this project are complete `oklch()` colours, not the bare "H S% L%"
triplets the older shadcn convention used — wrapping one in `hsl()` was
the first version of this file and produced an invisible chart.
*/
function rampColor(index: number, count: number): string {
const step = count <= 1 ? 0 : index / (count - 1)
const toward = Math.round(step * 62)
return `color-mix(in oklab, var(--primary) ${100 - toward}%, var(--card))`
}
/*
Per-instance id, hashed from props that differ between instances.
A literal id is a latent duplicate the moment this block is rendered
twice on one document -- two pages on a catalog hub, or one page using
the section twice. `aria-labelledby` pointing at a duplicated id resolves
to whichever element comes first, so the second copy is announced with
the first copy's label. Server component, so no `useId`: hashing props
gives each instance its own target and stays stable across server and
client renders in a way a counter would not.
*/
function instanceId(...parts: (string | undefined)[]): string {
const text = parts.filter(Boolean).join('|')
let hash = 0
for (let i = 0; i < text.length; i++) hash = (Math.imul(hash, 31) + text.charCodeAt(i)) | 0
return (hash >>> 0).toString(36).slice(0, 6)
}
export function DonutBreakdown({
heading = 'Where the bill went',
description = 'Billing period to date, across every project in the workspace.',
slices = DEFAULT_SLICES,
maxSlices = 5,
totalLabel = 'Total spend',
format = (value) => `$${value.toLocaleString('en-US')}`,
className = '',
}: DonutBreakdownProps) {
const uid = instanceId(heading, totalLabel)
const sorted = [...slices].sort((a, b) => b.value - a.value)
const head = sorted.slice(0, maxSlices)
const tail = sorted.slice(maxSlices)
const shown: BreakdownSlice[] =
tail.length > 0
? [
...head,
{
label: `Other (${tail.length} line${tail.length === 1 ? '' : 's'})`,
value: tail.reduce((sum, s) => sum + s.value, 0),
},
]
: head
const total = shown.reduce((sum, s) => sum + s.value, 0) || 1
/*
Stops are cumulative, so each slice ends where the next begins. Using
the same percentage for the previous stop's end and this one's start is
what produces hard edges rather than a blur between colours.
*/
let cursor = 0
const stops = shown.map((slice, i) => {
const start = cursor
const end = start + (slice.value / total) * 100
cursor = end
const color = slice.color ?? rampColor(i, shown.length)
return { ...slice, color, start, end, share: (slice.value / total) * 100 }
})
const gradient = stops
.map((s) => `${s.color} ${s.start.toFixed(2)}% ${s.end.toFixed(2)}%`)
.join(', ')
return (
<section
aria-labelledby={`donut-breakdown-heading-${uid}`}
className={`mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}
>
<div className="rounded-2xl border border-border bg-card p-6 sm:p-8">
<h2 id={`donut-breakdown-heading-${uid}`} className="text-lg font-semibold text-foreground">
{heading}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<div className="mt-8 flex flex-col items-center gap-8 md:flex-row md:items-center md:gap-10">
<div aria-hidden className="relative shrink-0">
<div
className="h-44 w-44 rounded-full"
style={{ background: `conic-gradient(${gradient})` }}
/>
{/* The hole, and the only place the total makes sense. */}
<div className="absolute inset-[22%] flex flex-col items-center justify-center rounded-full bg-card text-center">
<span className="text-xs text-muted-foreground">{totalLabel}</span>
<span className="text-xl font-bold tracking-tight text-foreground">
{format(total)}
</span>
</div>
</div>
<dl className="w-full min-w-0 flex-1 divide-y divide-border/60">
{stops.map((slice) => (
<div key={slice.label} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-transparent"
style={{ background: slice.color }}
/>
<dt className="min-w-0 flex-1 truncate text-sm text-foreground">
{slice.label}
</dt>
<dd className="ms-auto text-end">
<span className="font-mono text-sm text-foreground">
{format(slice.value)}
</span>
<span className="ms-2 font-mono text-xs text-muted-foreground">
{slice.share.toFixed(1)}%
</span>
</dd>
</div>
))}
</dl>
</div>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/donut-breakdown.tsx and import it where you need the section:
import { DonutBreakdown } from '@/components/donut-breakdown'4 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
heading | string | 'Where the bill went' |
description | string | 'Billing period to date, across every project… |
slices | BreakdownSlice[] | DEFAULT_SLICES |
maxSlicesSlices drawn individually before the remainder becomes "Other". | number | 5 |
totalLabel | string | 'Total spend' |
format | (value: number) => string | (value) => `$${value.toLocaleString('en-US')}` |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
<!--
Donut Breakdown — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block has no interactive behaviour, so nothing is missing.
-->
<section aria-labelledby="donut-breakdown-heading-1idk25" class="mx-auto w-full max-w-3xl px-4 py-16 sm:px-6 lg:px-8 ">
<div class="rounded-2xl border border-border bg-card p-6 sm:p-8">
<h2 id="donut-breakdown-heading-1idk25" class="text-lg font-semibold text-foreground">Where the bill went</h2>
<p class="mt-1 text-sm text-muted-foreground">Billing period to date, across every project in the workspace.</p>
<div class="mt-8 flex flex-col items-center gap-8 md:flex-row md:items-center md:gap-10">
<div aria-hidden="true" class="relative shrink-0">
<div class="h-44 w-44 rounded-full" style="background:conic-gradient(color-mix(in oklab, var(--primary) 100%, var(--card)) 0.00% 48.22%, color-mix(in oklab, var(--primary) 88%, var(--card)) 48.22% 69.63%, color-mix(in oklab, var(--primary) 75%, var(--card)) 69.63% 82.74%, color-mix(in oklab, var(--primary) 63%, var(--card)) 82.74% 91.65%, color-mix(in oklab, var(--primary) 50%, var(--card)) 91.65% 95.85%, color-mix(in oklab, var(--primary) 38%, var(--card)) 95.85% 100.00%)"></div>
<div class="absolute inset-[22%] flex flex-col items-center justify-center rounded-full bg-card text-center">
<span class="text-xs text-muted-foreground">Total spend</span>
<span class="text-xl font-bold tracking-tight text-foreground">$9,995</span>
</div>
</div>
<dl class="w-full min-w-0 flex-1 divide-y divide-border/60">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<span aria-hidden="true" class="h-2.5 w-2.5 shrink-0 rounded-sm border border-transparent" style="background:color-mix(in oklab, var(--primary) 100%, var(--card))"></span>
<dt class="min-w-0 flex-1 truncate text-sm text-foreground">Compute</dt>
<dd class="ms-auto text-end">
<span class="font-mono text-sm text-foreground">$4,820</span>
<span class="ms-2 font-mono text-xs text-muted-foreground">48.2%</span>
</dd>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<span aria-hidden="true" class="h-2.5 w-2.5 shrink-0 rounded-sm border border-transparent" style="background:color-mix(in oklab, var(--primary) 88%, var(--card))"></span>
<dt class="min-w-0 flex-1 truncate text-sm text-foreground">Object storage</dt>
<dd class="ms-auto text-end">
<span class="font-mono text-sm text-foreground">$2,140</span>
<span class="ms-2 font-mono text-xs text-muted-foreground">21.4%</span>
</dd>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<span aria-hidden="true" class="h-2.5 w-2.5 shrink-0 rounded-sm border border-transparent" style="background:color-mix(in oklab, var(--primary) 75%, var(--card))"></span>
<dt class="min-w-0 flex-1 truncate text-sm text-foreground">Egress</dt>
<dd class="ms-auto text-end">
<span class="font-mono text-sm text-foreground">$1,310</span>
<span class="ms-2 font-mono text-xs text-muted-foreground">13.1%</span>
</dd>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<span aria-hidden="true" class="h-2.5 w-2.5 shrink-0 rounded-sm border border-transparent" style="background:color-mix(in oklab, var(--primary) 63%, var(--card))"></span>
<dt class="min-w-0 flex-1 truncate text-sm text-foreground">Managed Postgres</dt>
<dd class="ms-auto text-end">
<span class="font-mono text-sm text-foreground">$890</span>
<span class="ms-2 font-mono text-xs text-muted-foreground">8.9%</span>
</dd>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<span aria-hidden="true" class="h-2.5 w-2.5 shrink-0 rounded-sm border border-transparent" style="background:color-mix(in oklab, var(--primary) 50%, var(--card))"></span>
<dt class="min-w-0 flex-1 truncate text-sm text-foreground">Log retention</dt>
<dd class="ms-auto text-end">
<span class="font-mono text-sm text-foreground">$420</span>
<span class="ms-2 font-mono text-xs text-muted-foreground">4.2%</span>
</dd>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<span aria-hidden="true" class="h-2.5 w-2.5 shrink-0 rounded-sm border border-transparent" style="background:color-mix(in oklab, var(--primary) 38%, var(--card))"></span>
<dt class="min-w-0 flex-1 truncate text-sm text-foreground">Other (3 lines)</dt>
<dd class="ms-auto text-end">
<span class="font-mono text-sm text-foreground">$415</span>
<span class="ms-2 font-mono text-xs text-muted-foreground">4.2%</span>
</dd>
</div>
</dl>
</div>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Charts & MetricsCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.
Categorical bars in plain CSS with gridlines, hover values and a screen-reader table carrying the real numbers.
Consumption against plan limits, with warning thresholds, honest overage display and unmetered quotas that do not fake a bar.
A year-over-year trend in inline SVG with a printed axis, no charting dependency and the same data repeated as a screen-reader table.
A year of daily activity as one square per day, bucketed against a quantile so a single outlier cannot flatten the scale.
A funnel that leads with the step-to-step rate and the people who fell out of it, not the tidy share-of-top percentages nobody can act on. Biggest drop named in words, and a footnote saying which definition of funnel this is.
Alert thresholds with their current distance from firing, so a quiet alert is visibly armed rather than possibly broken.
The top-of-dashboard figures, each carrying the comparison period that makes a delta mean anything.
The moving price rail that sits under an exchange’s navbar — direction carried by an arrow and a sign as well as by colour, and the duplicate half dropped rather than read twice.