Charts

Mastery Radar

A small SVG radar chart of skill mastery whose value polygon draws in once when it scrolls into view.

Install

npx shadcn@latest add @paragon/mastery-radar

mastery-radar.tsx

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