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.
npx shadcn@latest add @paragon/carbon-breakdown"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>
);
}