A security risk radar chart plotting risk across dimensions like identity, endpoint, and cloud, with a composite score and an on-view draw-in.
npx shadcn@latest add @paragon/risk-radar"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface RiskDimension {
label: string;
/** Primary series score, 0–1 (0 = healthy, 1 = severe). */
value: number;
/** Optional second series (e.g. target / last quarter), 0–1. */
target?: number;
}
export interface RiskRadarProps extends React.ComponentProps<"div"> {
dimensions?: RiskDimension[];
size?: number;
/** Number of concentric grid rings. */
rings?: number;
/** Accent hue for the primary risk fill. */
color?: string;
/** Hue for the comparison series (target). */
compareColor?: string;
/** Label for the primary series (legend + tooltip). */
seriesLabel?: string;
/** Label for the comparison series. */
compareLabel?: string;
/** Show the comparison polygon when `target` is present on dimensions. */
showCompare?: boolean;
/** Upper bound of the scale (values are divided by this). */
maxScale?: number;
/** Accessible description override. */
label?: string;
/** Renders the polygon at full scale immediately, no draw. */
static?: boolean;
}
const DEFAULT_DIMENSIONS: RiskDimension[] = [
{ label: "Identity", value: 0.35, target: 0.2 },
{ label: "Endpoint", value: 0.62, target: 0.4 },
{ label: "Network", value: 0.28, target: 0.25 },
{ label: "Data", value: 0.5, target: 0.3 },
{ label: "App Sec", value: 0.72, target: 0.45 },
{ label: "Cloud", value: 0.44, target: 0.35 },
];
const clamp01 = (n: number) => Math.min(Math.max(n, 0), 1);
/**
* A security risk radar across dimensions with an optional comparison series
* (e.g. current vs. target). Every vertex is computed via polar→cartesian trig;
* grid rings, spokes, and both polygons share one coordinate space. Hover a
* vertex for a category tooltip; hover a legend item to emphasize its series.
* The polygons scale up from center once on enter. Reduced motion renders the
* final geometry with no draw.
*/
export function RiskRadar({
dimensions = DEFAULT_DIMENSIONS,
size = 300,
rings = 4,
color = "#f43f5e",
compareColor = "#3b82f6",
seriesLabel = "Current",
compareLabel = "Target",
showCompare = true,
maxScale = 1,
label,
static: isStatic = false,
className,
...props
}: RiskRadarProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const animate = !isStatic && !reduced;
const drawn = !animate || inView;
const [active, setActive] = React.useState<number | null>(null);
const [hoverSeries, setHoverSeries] = React.useState<
"current" | "target" | null
>(null);
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 46;
const count = dimensions.length;
const hasCompare =
showCompare && dimensions.some((d) => typeof d.target === "number");
const angleOf = (i: number) => -Math.PI / 2 + (i / count) * Math.PI * 2;
const vertex = React.useCallback(
(i: number, t: number) => {
const angle = angleOf(i);
return [
cx + radius * t * Math.cos(angle),
cy + radius * t * Math.sin(angle),
] as const;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[cx, cy, radius, count],
);
const ringPolygon = (t: number) =>
dimensions
.map((_, i) => {
const [x, y] = vertex(i, t);
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
const seriesPolygon = (key: "value" | "target") =>
dimensions
.map((d, i) => {
const raw = key === "value" ? d.value : (d.target ?? 0);
const [x, y] = vertex(i, clamp01(raw / maxScale));
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
const avg = Math.round(
(dimensions.reduce((s, d) => s + clamp01(d.value / maxScale), 0) /
(count || 1)) *
100,
);
// Tooltip position tracks the active vertex.
const tip =
active != null
? (() => {
const d = dimensions[active];
const [x, y] = vertex(active, clamp01(d.value / maxScale));
return { d, x, y };
})()
: null;
const dimCurrent = hoverSeries === "target";
const dimTarget = hoverSeries === "current";
return (
<div
ref={ref}
data-slot="risk-radar"
className={cn("inline-flex flex-col items-center", className)}
{...props}
>
<div className="relative" style={{ width: size, height: size }}>
<svg
role="img"
aria-label={
label ??
`Risk radar, ${avg}% average risk: ${dimensions
.map((d) => `${d.label} ${Math.round(clamp01(d.value / maxScale) * 100)}%`)
.join(", ")}`
}
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="block overflow-visible"
onPointerLeave={() => setActive(null)}
>
{/* Grid rings */}
{Array.from({ length: rings }, (_, r) => {
const t = (r + 1) / rings;
return (
<polygon
key={r}
points={ringPolygon(t)}
fill="none"
stroke="var(--color-border)"
strokeWidth={1}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
);
})}
{/* Spokes */}
{dimensions.map((_, i) => {
const [x, y] = vertex(i, 1);
return (
<line
key={i}
x1={cx}
y1={cy}
x2={x}
y2={y}
stroke="var(--color-border)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
);
})}
{/* Comparison (target) polygon — drawn under current */}
{hasCompare && (
<polygon
points={seriesPolygon("target")}
fill={compareColor}
fillOpacity={dimTarget ? 0.06 : 0.14}
stroke={compareColor}
strokeWidth={1.5}
strokeDasharray="4 3"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
style={{
transformBox: "fill-box",
transformOrigin: "center",
transform: `scale(${drawn ? 1 : 0.4})`,
opacity: drawn ? (dimTarget ? 0.5 : 1) : 0,
transition: animate
? "transform 600ms var(--ease-out) 80ms, opacity 300ms var(--ease-out) 80ms, fill-opacity 150ms var(--ease-out)"
: "opacity 150ms var(--ease-out), fill-opacity 150ms var(--ease-out)",
}}
/>
)}
{/* Primary (current) polygon */}
<polygon
points={seriesPolygon("value")}
fill={color}
fillOpacity={dimCurrent ? 0.08 : 0.2}
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
style={{
transformBox: "fill-box",
transformOrigin: "center",
transform: `scale(${drawn ? 1 : 0.4})`,
opacity: drawn ? (dimCurrent ? 0.5 : 1) : 0,
transition: animate
? "transform 600ms var(--ease-out), opacity 300ms var(--ease-out), fill-opacity 150ms var(--ease-out)"
: "opacity 150ms var(--ease-out), fill-opacity 150ms var(--ease-out)",
}}
/>
{/* Interactive vertices (current series) */}
{dimensions.map((d, i) => {
const [x, y] = vertex(i, clamp01(d.value / maxScale));
const isActive = active === i;
return (
<g key={i}>
{/* Enlarged invisible hit target */}
<circle
cx={x}
cy={y}
r={12}
fill="transparent"
className="cursor-pointer outline-none"
tabIndex={0}
role="button"
aria-label={`${d.label}: ${seriesLabel} ${Math.round(clamp01(d.value / maxScale) * 100)}%${
typeof d.target === "number"
? `, ${compareLabel} ${Math.round(clamp01(d.target / maxScale) * 100)}%`
: ""
}`}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
/>
<circle
cx={x}
cy={y}
r={isActive ? 5 : 3.2}
fill={color}
stroke="var(--color-background)"
strokeWidth={1.5}
className="pointer-events-none transition-[r] duration-150 ease-out"
style={{
opacity: drawn ? 1 : 0,
transition: animate
? "opacity 300ms var(--ease-out) 300ms, r 150ms var(--ease-out)"
: "r 150ms var(--ease-out)",
}}
/>
</g>
);
})}
{/* Axis labels at ring edge, anchored by quadrant */}
{dimensions.map((d, i) => {
const [lx, ly] = vertex(i, 1.18);
const dx = lx - cx;
const anchor =
Math.abs(dx) < 6 ? "middle" : dx > 0 ? "start" : "end";
const isActive = active === i;
return (
<text
key={i}
x={lx}
y={ly}
textAnchor={anchor}
dominantBaseline="middle"
className={cn(
"text-[10px] font-medium transition-[fill] duration-150 ease-out",
isActive ? "fill-foreground" : "fill-muted-foreground",
)}
>
{d.label}
</text>
);
})}
</svg>
{/* Hover tooltip */}
{tip && (
<div
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-md bg-popover px-2.5 py-1.5 text-xs whitespace-nowrap text-popover-foreground shadow-overlay"
style={{ left: tip.x, top: tip.y - 10 }}
>
<div className="font-medium">{tip.d.label}</div>
<div className="mt-0.5 flex items-center gap-1.5">
<span
className="size-1.5 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-muted-foreground">{seriesLabel}</span>
<span className="ml-auto pl-2 font-medium tabular-nums">
{Math.round(clamp01(tip.d.value / maxScale) * 100)}%
</span>
</div>
{typeof tip.d.target === "number" && (
<div className="mt-0.5 flex items-center gap-1.5">
<span
className="size-1.5 rounded-full"
style={{ backgroundColor: compareColor }}
/>
<span className="text-muted-foreground">{compareLabel}</span>
<span className="ml-auto pl-2 font-medium tabular-nums">
{Math.round(clamp01(tip.d.target / maxScale) * 100)}%
</span>
</div>
)}
</div>
)}
</div>
{/* Legend + composite */}
<div className="mt-2 flex items-center gap-4">
<LegendItem
color={color}
label={seriesLabel}
onEnter={() => setHoverSeries("current")}
onLeave={() => setHoverSeries(null)}
/>
{hasCompare && (
<LegendItem
color={compareColor}
label={compareLabel}
dashed
onEnter={() => setHoverSeries("target")}
onLeave={() => setHoverSeries(null)}
/>
)}
<span className="text-xs text-muted-foreground">
Composite{" "}
<span className="font-semibold text-foreground tabular-nums">
{avg}%
</span>
</span>
</div>
</div>
);
}
function LegendItem({
color,
label,
dashed,
onEnter,
onLeave,
}: {
color: string;
label: string;
dashed?: boolean;
onEnter: () => void;
onLeave: () => void;
}) {
return (
<button
type="button"
onPointerEnter={onEnter}
onPointerLeave={onLeave}
onFocus={onEnter}
onBlur={onLeave}
className="group flex items-center gap-1.5 rounded-md text-xs text-muted-foreground outline-none transition-[color] duration-(--duration-fast) hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span
aria-hidden
className={cn(
"h-0 w-3.5 shrink-0 rounded-full border-t-2",
dashed && "border-dashed",
)}
style={{ borderColor: color }}
/>
{label}
</button>
);
}