Security

Posture Score

A security-posture score gauge with a computed 270-degree arc, an animated score, and a weighted category breakdown with mini bars and tooltips.

Install

npx shadcn@latest add @paragon/posture-score

Also installs: number-ticker, tooltip

posture-score.tsx

"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>
  );
}