Skip to content
Product block

Donut Breakdown

Where a total went, drawn as one conic-gradient ring with the total in the hole and every number repeated in the legend.

205 linesNo dependenciesAdded 25 Aug 2026Updated 2 Sept 2026
  • donut
  • pie chart
  • breakdown
  • conic gradient
  • spend

What's included

  • components/donut-breakdown.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add donut-breakdown

Or over MCP, from your editor's agent — no account needed.

License

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.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Where the bill went

Billing period to date, across every project in the workspace.

Compute
$4,82048.2%
Object storage
$2,14021.4%
Egress
$1,31013.1%
Managed Postgres
$8908.9%
Log retention
$4204.2%
Other (3 lines)
$4154.2%

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/donut-breakdown.tsx
/**
 * <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>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Nothing to install. No component library, no icon package.
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/donut-breakdown.tsx and import it where you need the section:

import { DonutBreakdown } from '@/components/donut-breakdown'

Customize

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.

Props

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.

PropTypeDefault
headingstring'Where the bill went'
descriptionstring'Billing period to date, across every project…
slicesBreakdownSlice[]DEFAULT_SLICES
maxSlicesSlices drawn individually before the remainder becomes "Other".number5
totalLabelstring'Total spend'
format(value: number) => string(value) => `$${value.toLocaleString('en-US')}`
classNamestring''

Not using React?

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.html
<!--
  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>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

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.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

9 more blocks in Charts & Metrics

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 & Metrics

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Charts & Metrics blocks

View category
Open the full page for this block

Sparkline Metric Cards

KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.

Charts & Metrics142 linesNo deps
Open the full page for this block

CSS Bar Chart Panel

Categorical bars in plain CSS with gridlines, hover values and a screen-reader table carrying the real numbers.

Charts & Metrics139 linesNo deps
Open the full page for this block

Quota & Usage Meters

Consumption against plan limits, with warning thresholds, honest overage display and unmetered quotas that do not fake a bar.

Charts & Metrics172 lines1 dep
Open the full page for this block

Dual-Series Line Chart

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.

Charts & Metrics277 linesNo deps
Open the full page for this block

Daily Activity Heatmap

A year of daily activity as one square per day, bucketed against a quantile so a single outlier cannot flatten the scale.

Charts & Metrics231 linesNo deps
Open the full page for this block

Conversion Funnel

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.

Charts & Metrics195 lines1 dep
Open the full page for this block

Metric Alert List

Alert thresholds with their current distance from firing, so a quiet alert is visibly armed rather than possibly broken.

Charts & Metrics141 linesNo deps
Open the full page for this block

KPI Summary Band

The top-of-dashboard figures, each carrying the comparison period that makes a delta mean anything.

Charts & Metrics118 linesNo deps
Open the full page for this block

Market Price Ticker

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.

Charts & Metrics180 lines1 dep