Climate

Carbon Breakdown

A carbon footprint by category as a hand-built SVG donut with gapped arcs that sweep in on mount, alongside a legend of per-category shares.

Install

npx shadcn@latest add @paragon/carbon-breakdown

carbon-breakdown.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface CarbonSegment {
  label: string;
  /** Emissions in the same unit as the others (e.g. tonnes CO₂e). */
  value: number;
  /** CSS color for the arc + row dot. */
  color: string;
}

export interface CarbonBreakdownProps extends React.ComponentProps<"div"> {
  segments?: CarbonSegment[];
  /** Unit label shown after the total. */
  unit?: string;
  /** Period label under the total. */
  period?: string;
}

const DEFAULT_SEGMENTS: CarbonSegment[] = [
  { label: "Purchased goods", value: 4.8, color: "var(--color-warning)" },
  { label: "Business travel", value: 3.1, color: "#3b82f6" },
  { label: "Electricity", value: 2.4, color: "var(--color-success)" },
  { label: "Commuting", value: 1.6, color: "#a855f7" },
  { label: "Waste", value: 0.7, color: "var(--color-muted-foreground)" },
];

const R = 52;
const STROKE = 16;
const C = 2 * Math.PI * R;

/**
 * A carbon footprint broken down by category: a hand-built SVG donut whose arcs
 * are placed with stroke-dasharray offsets (a tiny gap between them), and a
 * legend of rows with per-category share. Arcs sweep in on mount via a one-shot
 * dashoffset animation that reduced motion collapses to a static render.
 */
export function CarbonBreakdown({
  segments = DEFAULT_SEGMENTS,
  unit = "t CO₂e",
  period = "This quarter",
  className,
  ...props
}: CarbonBreakdownProps) {
  const total = segments.reduce((a, s) => a + s.value, 0);
  const gap = 2; // degrees between arcs, expressed as a fraction of C below
  const gapLen = (gap / 360) * C;

  let cursor = 0;
  const arcs = segments.map((s) => {
    const frac = total > 0 ? s.value / total : 0;
    const len = Math.max(frac * C - gapLen, 0);
    const offset = cursor;
    cursor += frac * C;
    return { ...s, len, offset, frac };
  });

  return (
    <div
      data-slot="carbon-breakdown"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-carbon-breakdown" precedence="paragon">{`
        @keyframes paragon-carbon-arc { from { stroke-dashoffset: var(--paragon-arc-from); } }
        @media (prefers-reduced-motion: reduce) {
          [data-carbon-arc] { animation: none !important; }
        }
      `}</style>

      <h3 className="text-sm font-medium">Carbon footprint</h3>

      <div className="mt-3 flex items-center gap-5">
        <div className="relative shrink-0">
          <svg
            width={140}
            height={140}
            viewBox="0 0 140 140"
            role="img"
            aria-label={`Carbon breakdown totaling ${total.toFixed(1)} ${unit}`}
          >
            <circle
              cx={70}
              cy={70}
              r={R}
              fill="none"
              className="stroke-secondary"
              strokeWidth={STROKE}
            />
            {arcs.map((a, i) => (
              <circle
                key={a.label}
                data-carbon-arc
                cx={70}
                cy={70}
                r={R}
                fill="none"
                stroke={a.color}
                strokeWidth={STROKE}
                strokeLinecap="butt"
                strokeDasharray={`${a.len} ${C - a.len}`}
                strokeDashoffset={-a.offset}
                transform="rotate(-90 70 70)"
                style={
                  {
                    "--paragon-arc-from": `${-a.offset + a.len}`,
                    animation: `paragon-carbon-arc 0.7s var(--ease-out) ${i * 0.08}s both`,
                  } as React.CSSProperties
                }
              />
            ))}
          </svg>
          <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
            <span className="text-xl font-semibold tabular-nums">
              {total.toFixed(1)}
            </span>
            <span className="text-[10px] text-muted-foreground">{unit}</span>
          </div>
        </div>

        <ul className="min-w-0 flex-1 space-y-2">
          {arcs.map((a) => (
            <li key={a.label} className="flex items-center gap-2 text-sm">
              <span
                aria-hidden
                className="size-2.5 shrink-0 rounded-full"
                style={{ background: a.color }}
              />
              <span className="min-w-0 flex-1 truncate text-muted-foreground">
                {a.label}
              </span>
              <span className="tabular-nums text-muted-foreground">
                {Math.round(a.frac * 100)}%
              </span>
              <span className="w-12 text-right font-medium tabular-nums">
                {a.value.toFixed(1)}
              </span>
            </li>
          ))}
        </ul>
      </div>

      <p className="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">
        {period} · down 12% vs. last quarter
      </p>
    </div>
  );
}