Infrastructure & DevOps

SLO Burndown

An SLO error-budget burndown chart with an ideal-burn reference line and a budget-remaining gauge.

Install

npx shadcn@latest add @paragon/slo-burndown

Also installs: number-ticker

slo-burndown.tsx

"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>
  );
}