Climate

Emissions Target Tracker

A net-zero pathway chart with a dashed target line and filled envelope, a self-drawing actuals line with a leading dot, and reduction-vs-target progress.

Install

npx shadcn@latest add @paragon/emissions-target-tracker

emissions-target-tracker.tsx

"use client";

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

export interface EmissionsTargetTrackerProps extends React.ComponentProps<"div"> {
  /** Actual emissions per period so far (nulls allowed for future gaps). */
  actual?: (number | null)[];
  /** The planned net-zero pathway across all periods. */
  pathway?: (number | null)[];
  /** Period labels (e.g. years). */
  labels?: string[];
  /** Baseline year value used to compute % reduction. */
  baseline?: number;
  unit?: string;
}

const DEFAULT_PATHWAY = [100, 88, 76, 63, 50, 38, 25, 12, 0];
const DEFAULT_ACTUAL = [100, 91, 79, 66, 54, null, null, null, null];
const DEFAULT_LABELS = ["'20", "'22", "'24", "'26", "'28", "'30", "'32", "'34", "'40"];

const W = 320;
const H = 150;
const PAD_L = 4;
const PAD_R = 4;
const PAD_T = 10;
const PAD_B = 18;

function scaleX(i: number, n: number) {
  return PAD_L + (i / (n - 1)) * (W - PAD_L - PAD_R);
}
function scaleY(v: number, max: number) {
  return PAD_T + (H - PAD_T - PAD_B) * (1 - v / max);
}

function linePath(values: (number | null)[], max: number, n: number) {
  let d = "";
  let started = false;
  values.forEach((v, i) => {
    if (v == null) return;
    const cmd = started ? "L" : "M";
    d += `${cmd} ${scaleX(i, n).toFixed(2)} ${scaleY(v, max).toFixed(2)} `;
    started = true;
  });
  return d.trim();
}

/**
 * A net-zero pathway chart: the planned reduction path drawn as a dashed target
 * line with a filled envelope, and actuals-to-date drawn on top with a leading
 * dot. The actual stroke draws itself in on mount (one-shot dash reveal, reduced
 * motion safe). A progress readout shows reduction achieved vs. the target.
 */
export function EmissionsTargetTracker({
  actual = DEFAULT_ACTUAL,
  pathway = DEFAULT_PATHWAY,
  labels = DEFAULT_LABELS,
  baseline = 100,
  unit = "%",
  className,
  ...props
}: EmissionsTargetTrackerProps) {
  const n = Math.max(pathway.length, actual.length, labels.length);
  const present = [...pathway, ...actual].filter((v): v is number => v != null);
  const max = Math.max(baseline, ...present);

  const pathwayLine = linePath(pathway, max, n);
  const actualLine = linePath(actual, max, n);

  // Envelope under the pathway.
  const area =
    pathwayLine +
    ` L ${scaleX(n - 1, n).toFixed(2)} ${(H - PAD_B).toFixed(2)}` +
    ` L ${scaleX(0, n).toFixed(2)} ${(H - PAD_B).toFixed(2)} Z`;

  const lastActualIdx = actual.reduce<number>(
    (acc, v, i) => (v != null ? i : acc),
    -1,
  );
  const lastActual =
    lastActualIdx >= 0 ? (actual[lastActualIdx] as number) : baseline;
  const targetAtNow = pathway[lastActualIdx] ?? baseline;
  const reduction = Math.round(baseline - lastActual);
  const onTrack = lastActual <= targetAtNow + 2;

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

      <div className="flex items-start justify-between">
        <div>
          <h3 className="text-sm font-medium">Net-zero pathway</h3>
          <p className="text-xs text-muted-foreground">
            Scope 1 & 2 vs. 2020 baseline
          </p>
        </div>
        <span
          className={cn(
            "rounded-full px-2.5 py-0.5 text-xs font-medium",
            onTrack ? "bg-success/10 text-success" : "bg-warning/10 text-warning",
          )}
        >
          {onTrack ? "On track" : "Behind"}
        </span>
      </div>

      <div className="mt-3 flex items-baseline gap-2">
        <span className="text-2xl font-semibold tabular-nums">
{reduction}
          {unit}
        </span>
        <span className="text-xs text-muted-foreground tabular-nums">
          target −{Math.round(baseline - targetAtNow)}
          {unit} by now
        </span>
      </div>

      <svg
        role="img"
        aria-label={`Emissions reduced ${reduction} percent against a net-zero pathway`}
        viewBox={`0 0 ${W} ${H}`}
        className="mt-3 w-full"
      >
        <defs>
          <linearGradient id="etarget-fill" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0%" stopColor="var(--color-success)" stopOpacity="0.16" />
            <stop offset="100%" stopColor="var(--color-success)" stopOpacity="0.01" />
          </linearGradient>
        </defs>

        {[0, 0.5, 1].map((f) => (
          <line
            key={f}
            x1={PAD_L}
            x2={W - PAD_R}
            y1={PAD_T + (H - PAD_T - PAD_B) * f}
            y2={PAD_T + (H - PAD_T - PAD_B) * f}
            className="stroke-border"
            strokeWidth={1}
            strokeDasharray="2 3"
          />
        ))}

        <path d={area} fill="url(#etarget-fill)" />

        <path
          d={pathwayLine}
          fill="none"
          className="stroke-success/50"
          strokeWidth={1.5}
          strokeDasharray="4 3"
          strokeLinecap="round"
        />

        <path
          data-etarget-draw
          d={actualLine}
          fill="none"
          className="stroke-foreground"
          strokeWidth={2}
          strokeLinecap="round"
          strokeLinejoin="round"
          pathLength={1}
          strokeDasharray={1}
          strokeDashoffset={1}
          style={{ animation: "paragon-etarget-draw 1s var(--ease-out) forwards" }}
        />

        {lastActualIdx >= 0 && (
          <circle
            cx={scaleX(lastActualIdx, n)}
            cy={scaleY(lastActual, max)}
            r={3}
            className="fill-foreground"
          />
        )}

        {labels.map((lbl, i) => (
          <text
            key={lbl + i}
            x={scaleX(i, n)}
            y={H - 5}
            textAnchor={i === 0 ? "start" : i === n - 1 ? "end" : "middle"}
            className="fill-muted-foreground"
            style={{ fontSize: 9 }}
          >
            {lbl}
          </text>
        ))}
      </svg>

      <div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
        <span className="flex items-center gap-1.5">
          <span className="h-0.5 w-3 rounded-full bg-foreground" /> Actual
        </span>
        <span className="flex items-center gap-1.5">
          <span className="h-0.5 w-3 rounded-full bg-success/50" /> Target path
        </span>
      </div>
    </div>
  );
}