Climate

ESG Scorecard

An ESG rating card with hand-built SVG dials for the E, S, and G pillars that fill on mount, per-pillar point deltas, and an overall band.

Install

npx shadcn@latest add @paragon/esg-scorecard

esg-scorecard.tsx

"use client";

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

export interface EsgPillar {
  key: string;
  label: string;
  /** Score 0–100. */
  score: number;
  /** Change vs. prior period, in points. */
  delta?: number;
  color: string;
}

export interface EsgScorecardProps extends React.ComponentProps<"div"> {
  pillars?: EsgPillar[];
  /** Overall rating band, e.g. "AA". */
  rating?: string;
  company?: string;
}

const DEFAULT_PILLARS: EsgPillar[] = [
  { key: "E", label: "Environmental", score: 78, delta: 4, color: "var(--color-success)" },
  { key: "S", label: "Social", score: 71, delta: 2, color: "#3b82f6" },
  { key: "G", label: "Governance", score: 84, delta: -1, color: "#a855f7" },
];

const R = 30;
const C = 2 * Math.PI * R;

function Dial({ pillar }: { pillar: EsgPillar }) {
  const pct = Math.min(Math.max(pillar.score, 0), 100) / 100;
  const dash = pct * C;
  return (
    <div className="flex flex-col items-center gap-2">
      <div className="relative">
        <svg width={80} height={80} viewBox="0 0 80 80">
          <circle
            cx={40}
            cy={40}
            r={R}
            fill="none"
            className="stroke-secondary"
            strokeWidth={7}
          />
          <circle
            data-esg-dial
            cx={40}
            cy={40}
            r={R}
            fill="none"
            stroke={pillar.color}
            strokeWidth={7}
            strokeLinecap="round"
            strokeDasharray={`${dash} ${C}`}
            transform="rotate(-90 40 40)"
            style={{
              animation: "paragon-esg-fill 0.8s var(--ease-out) both",
            }}
          />
        </svg>
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <span className="text-lg font-semibold tabular-nums">
            {pillar.score}
          </span>
        </div>
      </div>
      <div className="text-center">
        <div className="text-xs font-medium">{pillar.label}</div>
        {pillar.delta != null && (
          <div
            className={cn(
              "text-[11px] tabular-nums",
              pillar.delta >= 0 ? "text-success" : "text-destructive",
            )}
          >
            {pillar.delta >= 0 ? "+" : ""}
            {pillar.delta} pts
          </div>
        )}
      </div>
    </div>
  );
}

/**
 * An ESG scorecard: three hand-built SVG dials for the Environmental, Social,
 * and Governance pillars, each filling on mount via a shared dashoffset keyframe
 * (reduced-motion collapses it), with per-pillar point deltas and an overall
 * rating band. Deterministic and tabular.
 */
export function EsgScorecard({
  pillars = DEFAULT_PILLARS,
  rating = "AA",
  company = "Northwind Materials",
  className,
  ...props
}: EsgScorecardProps) {
  const overall = Math.round(
    pillars.reduce((a, p) => a + p.score, 0) / Math.max(pillars.length, 1),
  );

  return (
    <div
      data-slot="esg-scorecard"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-esg-scorecard" precedence="paragon">{`
        @keyframes paragon-esg-fill { from { stroke-dasharray: 0 ${C}; } }
        @media (prefers-reduced-motion: reduce) {
          [data-esg-dial] { animation: none !important; }
        }
      `}</style>

      <div className="flex items-start justify-between">
        <div>
          <h3 className="text-sm font-medium">ESG rating</h3>
          <p className="text-xs text-muted-foreground">{company}</p>
        </div>
        <div className="flex items-center gap-2 rounded-lg bg-secondary px-3 py-1.5">
          <span className="text-xl font-semibold leading-none">{rating}</span>
          <span className="text-xs text-muted-foreground tabular-nums">
            {overall}/100
          </span>
        </div>
      </div>

      <div className="mt-5 grid grid-cols-3 gap-2">
        {pillars.map((p) => (
          <Dial key={p.key} pillar={p} />
        ))}
      </div>

      <p className="mt-5 border-t border-border pt-3 text-xs text-muted-foreground">
        Weighted across 24 disclosure metrics · updated Jun 2026
      </p>
    </div>
  );
}