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.
npx shadcn@latest add @paragon/esg-scorecard"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>
);
}