An SLO error-budget burndown chart with an ideal-burn reference line and a budget-remaining gauge.
npx shadcn@latest add @paragon/slo-burndownAlso installs: number-ticker
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
export interface SloPoint {
/** Short date label, e.g. "Jun 3". */
label: string;
/** Fraction of the error budget remaining, 0–1. */
remaining: number;
}
export interface SloBurndownProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Target availability, e.g. 99.9. */
objective?: number;
/** Remaining-budget series, oldest → newest. */
series?: SloPoint[];
/** Alert threshold as a fraction (a horizontal budget line). */
threshold?: number;
/** Window label. */
window?: string;
/** Accent color for the series + gauge. */
accent?: string;
/** Opt out of the enter reveal. */
static?: boolean;
}
const DEFAULT_SERIES: SloPoint[] = [
{ label: "Jun 1", remaining: 1.0 },
{ label: "Jun 4", remaining: 0.97 },
{ label: "Jun 7", remaining: 0.94 },
{ label: "Jun 10", remaining: 0.9 },
{ label: "Jun 13", remaining: 0.85 },
{ label: "Jun 16", remaining: 0.79 },
{ label: "Jun 19", remaining: 0.72 },
{ label: "Jun 22", remaining: 0.63 },
{ label: "Jun 25", remaining: 0.55 },
{ label: "Jun 28", remaining: 0.47 },
{ label: "Jul 1", remaining: 0.4 },
{ label: "Jul 4", remaining: 0.34 },
];
const PAD = { t: 8, r: 8, b: 8, l: 8 };
const VB_W = 320;
const VB_H = 120;
const PLOT_W = VB_W - PAD.l - PAD.r;
const PLOT_H = VB_H - PAD.t - PAD.b;
const Y_TICKS = [0, 0.25, 0.5, 0.75, 1];
export function SloBurndown({
objective = 99.9,
series = DEFAULT_SERIES,
threshold = 0.2,
window = "30-day",
accent,
static: isStatic = false,
className,
style,
...props
}: SloBurndownProps) {
const ref = React.useRef<HTMLDivElement>(null);
const svgRef = React.useRef<SVGSVGElement>(null);
const reduced = useReducedMotion() ?? false;
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const reveal = isStatic || reduced || inView;
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const [hover, setHover] = React.useState<number | null>(null);
const [legendHover, setLegendHover] = React.useState<string | null>(null);
const n = series.length;
const current = n > 0 ? series[n - 1].remaining : 0;
// domain → range scales. x: index 0..n-1, y: remaining 0..1.
const xFor = React.useCallback(
(i: number) => PAD.l + (n > 1 ? (i / (n - 1)) * PLOT_W : PLOT_W / 2),
[n],
);
const yFor = React.useCallback(
(v: number) => PAD.t + (1 - Math.max(0, Math.min(1, v))) * PLOT_H,
[],
);
const linePath = React.useMemo(
() =>
n === 0
? ""
: series
.map(
(p, i) =>
`${i === 0 ? "M" : "L"} ${xFor(i).toFixed(2)} ${yFor(p.remaining).toFixed(2)}`,
)
.join(" "),
[series, n, xFor, yFor],
);
const areaPath = React.useMemo(
() =>
n === 0
? ""
: `${linePath} L ${xFor(n - 1).toFixed(2)} ${(PAD.t + PLOT_H).toFixed(2)} L ${xFor(0).toFixed(2)} ${(PAD.t + PLOT_H).toFixed(2)} Z`,
[linePath, n, xFor],
);
const accentColor = accent ?? "var(--color-primary)";
const belowThreshold = current <= threshold;
const seriesColor = belowThreshold ? "var(--color-destructive)" : accentColor;
const gaugePct = Math.round(current * 100);
const R = 30;
const CIRC = 2 * Math.PI * R;
const dash = CIRC * Math.max(0, Math.min(1, current));
// x-axis labels: show first, middle, last to avoid overlap.
const labelIdx = n > 2 ? [0, Math.floor((n - 1) / 2), n - 1] : series.map((_, i) => i);
function onMove(e: React.PointerEvent<SVGSVGElement>) {
if (n === 0 || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const px = ((e.clientX - rect.left) / rect.width) * VB_W;
// nearest index in the shared x-scale
let best = 0;
let bestD = Infinity;
for (let i = 0; i < n; i++) {
const d = Math.abs(xFor(i) - px);
if (d < bestD) {
bestD = d;
best = i;
}
}
setHover(best);
}
const legendDim = (key: string) => legendHover != null && legendHover !== key;
if (n === 0) {
return (
<div
ref={ref}
className={cn(
"flex w-full max-w-md flex-col items-center justify-center gap-1 rounded-xl bg-card p-8 text-center text-card-foreground shadow-border",
className,
)}
style={style}
{...props}
>
<p className="text-sm font-medium">No burndown data</p>
<p className="text-xs text-muted-foreground">
Error-budget consumption will appear once SLIs report.
</p>
</div>
);
}
const hp = hover != null ? series[hover] : null;
return (
<div
ref={ref}
className={cn(
"w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
style={
{
"--pg-slo-accent": accentColor,
...style,
} as React.CSSProperties
}
{...props}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-medium">Error budget</h3>
<p className="truncate text-xs text-muted-foreground">
{objective}% availability · {window} window
</p>
</div>
{/* remaining-budget gauge */}
<div className="relative size-[76px] shrink-0">
<svg viewBox="0 0 76 76" className="size-full -rotate-90" aria-hidden>
<circle cx="38" cy="38" r={R} fill="none" stroke="var(--color-secondary)" strokeWidth="7" />
<circle
cx="38"
cy="38"
r={R}
fill="none"
stroke={seriesColor}
strokeWidth="7"
strokeLinecap="round"
strokeDasharray={`${(reveal ? dash : 0).toFixed(2)} ${CIRC.toFixed(2)}`}
className="transition-[stroke-dasharray,stroke] duration-[600ms] ease-out"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-base font-semibold leading-none">
<NumberTicker
value={gaugePct}
static={isStatic}
formatOptions={{ maximumFractionDigits: 0 }}
/>
%
</span>
<span className="text-[9px] text-muted-foreground">left</span>
</div>
</div>
</div>
<div className="relative mt-3 rounded-lg border border-border bg-background p-2">
<svg
ref={svgRef}
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="w-full touch-none"
role="img"
aria-label="Error-budget burndown over time"
onPointerMove={onMove}
onPointerLeave={() => setHover(null)}
>
<defs>
<linearGradient id={`slo-fill-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={seriesColor} stopOpacity="0.22" />
<stop offset="100%" stopColor={seriesColor} stopOpacity="0" />
</linearGradient>
<clipPath id={`slo-clip-${uid}`}>
<rect
x={PAD.l}
y="0"
width={reveal ? PLOT_W : 0}
height={VB_H}
className="transition-[width] duration-[600ms] ease-out"
/>
</clipPath>
</defs>
{/* y gridlines aligned exactly to the y-scale ticks */}
{Y_TICKS.map((t) => (
<g key={t}>
<line
x1={PAD.l}
x2={PAD.l + PLOT_W}
y1={yFor(t)}
y2={yFor(t)}
stroke="var(--color-border)"
strokeWidth={0.5}
strokeOpacity={t === 0 ? 1 : 0.5}
vectorEffect="non-scaling-stroke"
/>
</g>
))}
{/* budget threshold line */}
<line
x1={PAD.l}
x2={PAD.l + PLOT_W}
y1={yFor(threshold)}
y2={yFor(threshold)}
stroke="var(--color-destructive)"
strokeWidth={1}
strokeDasharray="3 3"
strokeOpacity={legendDim("threshold") ? 0.25 : 0.85}
className="transition-[stroke-opacity] duration-150 ease-out"
vectorEffect="non-scaling-stroke"
/>
<g clipPath={`url(#slo-clip-${uid})`}>
<path
d={areaPath}
fill={`url(#slo-fill-${uid})`}
opacity={legendDim("budget") ? 0.3 : 1}
className="transition-[opacity] duration-150 ease-out"
/>
<path
d={linePath}
fill="none"
stroke={seriesColor}
strokeWidth={1.75}
strokeLinejoin="round"
strokeLinecap="round"
opacity={legendDim("budget") ? 0.3 : 1}
className="transition-[opacity] duration-150 ease-out"
vectorEffect="non-scaling-stroke"
/>
</g>
{/* crosshair + focused point */}
{hp && hover != null && (
<g pointerEvents="none">
<line
x1={xFor(hover)}
x2={xFor(hover)}
y1={PAD.t}
y2={PAD.t + PLOT_H}
stroke="var(--color-foreground)"
strokeWidth={0.75}
strokeOpacity={0.35}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={xFor(hover)}
cy={yFor(hp.remaining)}
r={3}
fill="var(--color-background)"
stroke={seriesColor}
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
</g>
)}
</svg>
{/* HTML tooltip driven by hover state (SVG-safe) */}
{hp && hover != null && (
<div
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-md bg-popover px-2 py-1 text-[11px] whitespace-nowrap text-popover-foreground shadow-overlay"
style={{
left: `${(xFor(hover) / VB_W) * 100}%`,
top: `calc(${(yFor(hp.remaining) / VB_H) * 100}% - 6px)`,
}}
>
<span className="font-medium">{hp.label}</span>
<span className="ml-1.5 tabular-nums text-muted-foreground">
{Math.round(hp.remaining * 100)}% left
</span>
</div>
)}
{/* x-axis labels (first / mid / last, no overlap) */}
<div className="mt-1 flex justify-between px-0.5 text-[9px] tabular-nums text-muted-foreground">
{labelIdx.map((i) => (
<span key={i}>{series[i].label}</span>
))}
</div>
</div>
{/* hoverable legend */}
<div className="mt-2.5 flex items-center justify-between gap-3 text-[11px]">
<div className="flex items-center gap-3">
<button
type="button"
onMouseEnter={() => setLegendHover("budget")}
onMouseLeave={() => setLegendHover(null)}
onFocus={() => setLegendHover("budget")}
onBlur={() => setLegendHover(null)}
className="flex items-center gap-1.5 rounded outline-none transition-[opacity] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring"
style={{ opacity: legendDim("budget") ? 0.45 : 1 }}
>
<span
className="h-1.5 w-3 rounded-full"
style={{ background: seriesColor }}
/>
budget remaining
</button>
<button
type="button"
onMouseEnter={() => setLegendHover("threshold")}
onMouseLeave={() => setLegendHover(null)}
onFocus={() => setLegendHover("threshold")}
onBlur={() => setLegendHover(null)}
className="flex items-center gap-1.5 rounded text-muted-foreground outline-none transition-[opacity] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring"
style={{ opacity: legendDim("threshold") ? 0.45 : 1 }}
>
<span className="h-px w-3.5 border-t border-dashed border-destructive" />
alert at {Math.round(threshold * 100)}%
</button>
</div>
<span className="shrink-0 tabular-nums text-muted-foreground">
<span className="font-medium" style={{ color: seriesColor }}>
{belowThreshold ? "breached" : `${gaugePct}%`}
</span>
</span>
</div>
</div>
);
}