Climate

Emissions vs Target

A hand-built SVG column chart against a target reference line; bars rise and the target draws in on first view, breaches tint red.

Install

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

emissions-vs-target.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface EmissionsVsTargetProps extends React.ComponentProps<"div"> {
  /** One value per period. */
  data: number[];
  /** X labels, one per data point. */
  labels: string[];
  /** Target ceiling drawn as a horizontal reference line. */
  target: number;
  height?: number;
  /** Unit suffix for the target readout. */
  unit?: string;
  formatValue?: (value: number) => string;
  /** Accessible description. */
  label?: string;
  /** Renders the final state immediately, no draw-in. */
  static?: boolean;
}

/**
 * A hand-built SVG column chart against a target reference line. Columns rise
 * from the baseline on first view via a grouped scaleY transform (non-scaling
 * stroke unaffected), and a bar tints destructive when it breaches the target.
 * The target line and its dashed stroke draw once. Responsive via
 * ResizeObserver; reduced motion (or `static`) renders the final chart
 * immediately.
 */
export function EmissionsVsTarget({
  data,
  labels,
  target,
  height = 200,
  unit = "t",
  formatValue = (v) => v.toLocaleString("en-US"),
  label,
  static: isStatic = false,
  className,
  ...props
}: EmissionsVsTargetProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;

  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) =>
      setWidth(entry.contentRect.width),
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const pad = { top: 12, right: 12, bottom: 22, left: 40 };
  const innerW = Math.max(width - pad.left - pad.right, 0);
  const plotBottom = height - pad.bottom;
  const innerH = plotBottom - pad.top;

  const dataMax = Math.max(target, ...data);
  const yMax = Math.ceil((dataMax * 1.15) / 10) * 10 || 1;
  const n = data.length;
  const slot = n > 0 ? innerW / n : 0;
  const barW = slot * 0.56;

  const yFor = (v: number) => plotBottom - (v / yMax) * innerH;
  const targetY = yFor(target);

  return (
    <div
      ref={ref}
      data-slot="emissions-vs-target"
      className={cn("w-full max-w-md", className)}
      {...props}
    >
      {width > 0 && (
        <svg
          role="img"
          aria-label={
            label ??
            `Emissions by period against a target of ${formatValue(target)} ${unit}`
          }
          width={width}
          height={height}
          viewBox={`0 0 ${width} ${height}`}
          className="block"
        >
          {[0, 0.5, 1].map((f) => {
            const v = yMax * f;
            return (
              <g key={f}>
                <line
                  x1={pad.left}
                  x2={width - pad.right}
                  y1={yFor(v)}
                  y2={yFor(v)}
                  stroke="currentColor"
                  strokeOpacity={f === 0 ? 0.15 : 0.07}
                />
                <text
                  x={pad.left - 8}
                  y={yFor(v)}
                  textAnchor="end"
                  dominantBaseline="central"
                  fontSize={10}
                  className="fill-muted-foreground tabular-nums"
                >
                  {formatValue(v)}
                </text>
              </g>
            );
          })}

          {/* Bars rise together */}
          <g
            style={{
              transform: drawn ? "scaleY(1)" : "scaleY(0)",
              transformOrigin: `0px ${plotBottom}px`,
              transition: animate
                ? "transform 650ms var(--ease-out)"
                : undefined,
            }}
          >
            {data.map((v, i) => {
              const x = pad.left + i * slot + (slot - barW) / 2;
              const over = v > target;
              return (
                <rect
                  key={i}
                  x={x}
                  y={yFor(v)}
                  width={barW}
                  height={Math.max(0, plotBottom - yFor(v))}
                  rx={2}
                  fill={over ? "var(--destructive)" : "var(--primary)"}
                  fillOpacity={over ? 0.9 : 0.85}
                />
              );
            })}
          </g>

          {/* Target reference line — draws in */}
          <TargetLine
            x1={pad.left}
            x2={width - pad.right}
            y={targetY}
            animate={animate}
            drawn={drawn}
          />
          <text
            x={width - pad.right}
            y={targetY - 5}
            textAnchor="end"
            fontSize={10}
            className="fill-warning tabular-nums"
            style={{
              opacity: drawn ? 1 : 0,
              transition: animate
                ? "opacity 250ms var(--ease-out) 400ms"
                : undefined,
            }}
          >
            Target {formatValue(target)} {unit}
          </text>

          {labels.map((text, i) => (
            <text
              key={i}
              x={pad.left + i * slot + slot / 2}
              y={height - 6}
              textAnchor="middle"
              fontSize={10}
              className="fill-muted-foreground"
            >
              {text}
            </text>
          ))}
        </svg>
      )}
    </div>
  );
}

function TargetLine({
  x1,
  x2,
  y,
  animate,
  drawn,
}: {
  x1: number;
  x2: number;
  y: number;
  animate: boolean;
  drawn: boolean;
}) {
  const length = Math.abs(x2 - x1);
  return (
    <line
      x1={x1}
      x2={x2}
      y1={y}
      y2={y}
      stroke="var(--warning)"
      strokeWidth={1.5}
      strokeDasharray={animate ? `${length}` : "5 4"}
      strokeDashoffset={animate ? (drawn ? 0 : length) : 0}
      style={
        animate
          ? { transition: "stroke-dashoffset 500ms var(--ease-out) 250ms" }
          : undefined
      }
    />
  );
}