A security-posture score gauge with a computed 270-degree arc, an animated score, and a weighted category breakdown with mini bars and tooltips.
npx shadcn@latest add @paragon/posture-scoreAlso installs: number-ticker, tooltip
"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";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface PostureCategory {
label: string;
/** Category score 0–100. */
score: number;
/** Weight toward the composite (relative). Defaults to 1. */
weight?: number;
/** Optional detail shown in the tooltip. */
detail?: string;
}
export interface PostureScoreProps extends React.ComponentProps<"div"> {
/** Overall score 0–100. Derived from categories when omitted. */
score?: number;
/** Grade label; derived if omitted. */
grade?: string;
categories?: PostureCategory[];
/** Target threshold marker on the gauge, 0–100. */
target?: number;
size?: number;
/** Accent used for the score number/label; band still colors the arc. */
accent?: string;
/** Disables the dial sweep and ticker. */
static?: boolean;
}
const DEFAULT_CATEGORIES: PostureCategory[] = [
{ label: "Identity & access", score: 92, weight: 1.4, detail: "MFA enforced org-wide" },
{ label: "Endpoint", score: 74, weight: 1.2, detail: "18 of 24 hosts encrypted" },
{ label: "Network", score: 81, weight: 1, detail: "Egress filtering active" },
{ label: "Data protection", score: 63, weight: 1.2, detail: "2 buckets public-readable" },
{ label: "Vulnerability mgmt", score: 58, weight: 1.1, detail: "Unpatched CVE on 2 hosts" },
];
function bandColor(score: number) {
if (score >= 80) return "var(--color-success)";
if (score >= 60) return "var(--color-warning)";
return "var(--color-destructive)";
}
function bandBar(score: number) {
if (score >= 80) return "bg-success";
if (score >= 60) return "bg-warning";
return "bg-destructive";
}
function bandText(score: number) {
if (score >= 80) return "text-success";
if (score >= 60) return "text-warning";
return "text-destructive";
}
function gradeOf(score: number) {
if (score >= 90) return "Excellent";
if (score >= 80) return "Strong";
if (score >= 60) return "Fair";
return "At risk";
}
/** Polar→cartesian on the gauge circle (0° at 12 o'clock, clockwise). */
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = ((deg - 90) * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
/** SVG arc path between two sweep angles (degrees), clockwise. */
function describeArc(
cx: number,
cy: number,
r: number,
startDeg: number,
endDeg: number,
) {
const start = polar(cx, cy, r, endDeg);
const end = polar(cx, cy, r, startDeg);
const largeArc = endDeg - startDeg <= 180 ? 0 : 1;
return `M ${start.x.toFixed(2)} ${start.y.toFixed(2)} A ${r} ${r} 0 ${largeArc} 0 ${end.x.toFixed(2)} ${end.y.toFixed(2)}`;
}
/**
* A security-posture gauge with a weighted category breakdown. The 270° arc is
* built from an SVG arc path (describeArc via cos/sin) and swept in with a
* computed stroke-dashoffset: circumference = 2πr, offset = C·(1 − score/100).
* The score number counts up on a NumberTicker; each breakdown row has a mini
* bar colored by threshold (success / warning / destructive) and a tooltip.
* Reduced motion renders the final arc immediately.
*/
export function PostureScore({
score,
grade,
categories = DEFAULT_CATEGORIES,
target,
size = 176,
accent,
static: isStatic = false,
className,
...props
}: PostureScoreProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reduced;
const armed = !animate || inView;
// Weighted composite when score isn't supplied.
const computed = React.useMemo(() => {
if (typeof score === "number") return score;
const totalW = categories.reduce((s, c) => s + (c.weight ?? 1), 0) || 1;
return Math.round(
categories.reduce((s, c) => s + c.score * (c.weight ?? 1), 0) / totalW,
);
}, [score, categories]);
const clamped = Math.min(Math.max(computed, 0), 100);
const fraction = clamped / 100;
const color = bandColor(clamped);
const thickness = 12;
const cx = size / 2;
const cy = size / 2;
const r = (size - thickness) / 2 - 2;
// 270° gauge: gap centered at the bottom. Sweep 135° → 405°.
const START = 135;
const SWEEP = 270;
const circumference = 2 * Math.PI * r;
const arcLen = circumference * (SWEEP / 360);
const trackPath = describeArc(cx, cy, r, START, START + SWEEP);
// dashoffset drives the fill: full offset = empty.
const shownOffset = arcLen * (1 - (armed ? fraction : 0));
const accentColor = accent ?? color;
// Target tick position on the arc.
const targetAngle =
typeof target === "number"
? START + (Math.min(Math.max(target, 0), 100) / 100) * SWEEP
: null;
const targetInner = targetAngle != null ? polar(cx, cy, r - thickness / 2 - 3, targetAngle) : null;
const targetOuter = targetAngle != null ? polar(cx, cy, r + thickness / 2 + 3, targetAngle) : null;
return (
<TooltipProvider>
<div
ref={ref}
data-slot="posture-score"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-5">
<div className="relative shrink-0" style={{ width: size, height: size }}>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="block"
role="img"
aria-label={`Security posture score ${clamped} of 100, ${grade ?? gradeOf(clamped)}`}
>
{/* Track */}
<path
d={trackPath}
fill="none"
stroke="currentColor"
strokeOpacity={0.08}
strokeWidth={thickness}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{/* Value arc */}
<path
d={trackPath}
fill="none"
stroke={color}
strokeWidth={thickness}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
strokeDasharray={arcLen}
strokeDashoffset={shownOffset}
style={{
transition: animate
? "stroke-dashoffset 800ms var(--ease-out), stroke 300ms var(--ease-out)"
: "stroke 300ms var(--ease-out)",
}}
/>
{/* Target threshold tick */}
{targetInner && targetOuter && (
<line
x1={targetInner.x}
y1={targetInner.y}
x2={targetOuter.x}
y2={targetOuter.y}
stroke="currentColor"
strokeOpacity={0.5}
strokeWidth={2}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span
className="text-4xl font-semibold tabular-nums"
style={{ color: accentColor }}
>
<NumberTicker value={clamped} static={isStatic} duration={0.8} />
</span>
<span className="text-xs font-medium" style={{ color }}>
{grade ?? gradeOf(clamped)}
</span>
{typeof target === "number" && (
<span className="mt-0.5 text-[10px] text-muted-foreground tabular-nums">
target {target}
</span>
)}
</div>
</div>
<ul className="min-w-0 flex-1 space-y-2.5">
{categories.map((c, i) => {
const s = Math.min(Math.max(c.score, 0), 100);
return (
<li key={i}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-2.5 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span className="min-w-0 flex-1 truncate text-[13px] text-muted-foreground transition-[color] duration-(--duration-fast) group-hover:text-foreground">
{c.label}
</span>
<span className="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-secondary">
<span
className={cn(
"block h-full origin-left rounded-full",
bandBar(s),
)}
style={{
transform: `scaleX(${armed ? s / 100 : 0})`,
transition: animate
? `transform 700ms var(--ease-out) ${80 + i * 60}ms`
: undefined,
}}
/>
</span>
<span
className={cn(
"w-7 shrink-0 text-right text-[13px] font-medium tabular-nums",
bandText(s),
)}
>
{s}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium">{c.label}</div>
<div className="text-primary-foreground/70">
{c.detail ?? `Score ${s} / 100`}
{c.weight ? ` · weight ×${c.weight}` : ""}
</div>
</TooltipContent>
</Tooltip>
</li>
);
})}
</ul>
</div>
</div>
</TooltipProvider>
);
}