A small SVG radar chart of skill mastery whose value polygon draws in once when it scrolls into view.
npx shadcn@latest add @paragon/mastery-radar"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface MasteryAxis {
label: string;
/** Mastery 0–1. */
value: number;
}
export interface MasteryRadarProps extends React.ComponentProps<"div"> {
axes: MasteryAxis[];
size?: number;
/** Number of concentric grid rings. */
rings?: number;
/** Fill opacity of the value polygon, 0–1. */
fillOpacity?: number;
/** Polygon + accent color. Defaults to the primary token. */
color?: string;
/** Accessible description. */
label?: string;
/** Renders the polygon at full scale immediately, no draw. */
static?: boolean;
}
/**
* A small SVG radar (spider) chart of skill mastery. Every vertex is computed
* in polar coordinates around N evenly spaced spokes (angle = i/N·2π − π/2), so
* the concentric grid polygons and the data polygon share exact vertices. The
* value polygon scales up from the center once when the chart enters view.
*
* Interactive: hover or keyboard-focus a spoke to highlight its vertex and
* surface a value tooltip. Reduced motion renders the final polygon with no
* draw. All geometry is deterministic — no randomness.
*/
export function MasteryRadar({
axes,
size = 240,
rings = 4,
fillOpacity = 0.18,
color = "var(--color-primary)",
label,
static: isStatic = false,
className,
...props
}: MasteryRadarProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const [active, setActive] = React.useState<number | null>(null);
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 34;
const count = axes.length;
const percentFormat = React.useMemo(
() =>
new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 0,
}),
[],
);
// Vertex for axis i at fraction t (0–1) of the radius.
const vertex = (i: number, t: number) => {
const angle = -Math.PI / 2 + (i / count) * Math.PI * 2;
return [
cx + radius * t * Math.cos(angle),
cy + radius * t * Math.sin(angle),
] as const;
};
const ringPolygon = (t: number) =>
axes
.map((_, i) => {
const [x, y] = vertex(i, t);
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
const valuePolygon = axes
.map((axis, i) => {
const [x, y] = vertex(i, Math.min(Math.max(axis.value, 0), 1));
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
if (count === 0) {
return (
<div
ref={ref}
data-slot="mastery-radar"
className={cn(
"inline-flex items-center justify-center text-sm text-muted-foreground",
className,
)}
style={{ width: size, height: size }}
{...props}
>
No data
</div>
);
}
return (
<div
ref={ref}
data-slot="mastery-radar"
className={cn("inline-flex", className)}
{...props}
>
<svg
role="img"
aria-label={
label ??
`Mastery radar: ${axes
.map((a) => `${a.label} ${Math.round(a.value * 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}
vectorEffect="non-scaling-stroke"
/>
);
})}
{/* Axes spokes */}
{axes.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={active === i ? 1.5 : 1}
vectorEffect="non-scaling-stroke"
style={{
stroke:
active === i ? color : "var(--color-border)",
transition: "stroke 150ms var(--ease-out)",
}}
/>
);
})}
{/* Value polygon — scales up from center on view */}
<polygon
points={valuePolygon}
fill={color}
fillOpacity={fillOpacity}
stroke={color}
strokeWidth={2}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
style={{
transformBox: "fill-box",
transformOrigin: "center",
transform: `scale(${drawn ? 1 : 0})`,
opacity: drawn ? 1 : 0,
transition: animate
? "transform 600ms var(--ease-out), opacity 300ms var(--ease-out)"
: undefined,
}}
/>
{/* Vertex dots + focusable hit targets */}
{axes.map((axis, i) => {
const v = Math.min(Math.max(axis.value, 0), 1);
const [x, y] = vertex(i, v);
const isActive = active === i;
return (
<g key={i}>
<circle
cx={x}
cy={y}
r={isActive ? 4.5 : 3}
fill={color}
stroke="var(--color-background)"
strokeWidth={1.5}
style={{
opacity: drawn ? 1 : 0,
transition: animate
? "opacity 300ms var(--ease-out) 300ms, r 150ms var(--ease-out)"
: "r 150ms var(--ease-out)",
}}
/>
{/* Invisible larger hit/focus target on the spoke tip */}
<circle
cx={x}
cy={y}
r={11}
fill="transparent"
tabIndex={0}
role="button"
aria-label={`${axis.label}: ${percentFormat.format(v)}`}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
className="cursor-pointer outline-none focus-visible:[outline:2px_solid_var(--color-ring)] focus-visible:[outline-offset:1px]"
style={{ borderRadius: "9999px" }}
/>
</g>
);
})}
{/* Axis labels */}
{axes.map((axis, i) => {
const [lx, ly] = vertex(i, 1.18);
const anchor =
Math.abs(lx - cx) < 6 ? "middle" : lx > cx ? "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-colors",
isActive ? "fill-foreground" : "fill-muted-foreground",
)}
>
{axis.label}
</text>
);
})}
{/* Active-vertex value badge, anchored just outside the tip */}
{active !== null &&
!reducedMotion &&
(() => {
const v = Math.min(Math.max(axes[active].value, 0), 1);
const [bx, by] = vertex(active, 1.02);
const anchor =
Math.abs(bx - cx) < 6 ? "middle" : bx > cx ? "start" : "end";
return (
<text
x={bx}
y={by - 10}
textAnchor={anchor}
dominantBaseline="middle"
className="fill-foreground text-[11px] font-semibold tabular-nums"
>
{percentFormat.format(v)}
</text>
);
})()}
</svg>
</div>
);
}