Radar Chart

Interactive radar chart card.

Radar

A multi-axis radar chart.

Visitors

1,424

+5.2%
function RadarChartDemo() {
  return <RadarChartCard data={RADAR_DATA} series={RADAR_SERIES} ranges={RADAR_RANGES} />;
}
function RadarChartDemo() {
  return <RadarChartCard data={RADAR_DATA} series={RADAR_SERIES} ranges={RADAR_RANGES} />;
}

Installation

npx shadcn@latest add https://boardcn.dev/r/radar-chart-card.json
npx shadcn@latest add https://boardcn.dev/r/radar-chart-card.json

npm packages

  • recharts

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/charts/radar-chart-card.tsx
"use client";

import { useState, type HTMLAttributes, type ReactNode } from "react";
import {
  PolarAngleAxis,
  PolarGrid,
  PolarRadiusAxis,
  Radar,
  RadarChart,
  ResponsiveContainer,
  Tooltip,
  type ActiveDotProps,
} from "recharts";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export type RadarChartVariant = "filled" | "dots" | "lines" | "score";
export type RadarChartLegendPosition = "top" | "bottom" | "overlay";

export interface RadarChartDatum {
  label: string;
  [seriesKey: string]: string | number;
}

export interface RadarChartSeries {
  key: string;
  label: string;
  /** Any CSS color. Defaults follow BoardCN's chart tone cycle. */
  color?: string;
  /** Hover/active color. A custom color is mixed with black when omitted. */
  activeColor?: string;
}

export interface RadarChartRange {
  id: string;
  label: string;
  data: RadarChartDatum[];
  series?: RadarChartSeries[];
  max?: number;
  headline?: number;
  /** Decimal change, e.g. `0.052` renders as `+5.2%`. */
  delta?: number;
}

export interface RadarChartCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
  variant?: RadarChartVariant;
  title?: string;
  data: RadarChartDatum[];
  series: RadarChartSeries[];
  max?: number;
  headline?: number;
  /** Decimal change, e.g. `0.052` renders as `+5.2%`. */
  delta?: number;
  /** Static period pill. */
  range?: string;
  ranges?: RadarChartRange[];
  defaultRange?: string;
  onRangeChange?: (id: string) => void;
  format?: (value: number) => string;
  /** In score mode, values below this threshold use the rose status color. */
  alertBelow?: number;
  scoreCaption?: string | ((average: number) => string);
  tiles?: boolean;
  legend?: RadarChartLegendPosition;
  radiusScale?: number;
  plotOffsetY?: number;
}

const TONES = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const formatNumber = (value: number) => value.toLocaleString("en-US");

function resolveTone(index: number, color?: string, activeColor?: string) {
  const tone = TONES[index % TONES.length];
  return color
    ? {
        color,
        activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
      }
    : {
        color: `var(--color-chart-${tone})`,
        activeColor: `var(--color-chart-${tone}-active)`,
      };
}

function describeDelta(delta: number) {
  const percentage = Math.round(Math.abs(delta) * 1000) / 10;
  if (percentage === 0) return { label: "0.0%", color: "neutral" as const };
  return {
    label: `${delta > 0 ? "+" : "-"}${percentage}%`,
    color: delta > 0 ? ("lime" as const) : ("rose" as const),
  };
}

function useAnimatedNumber(value: number) {
  const precision = Number.isInteger(value)
    ? 0
    : Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
      ? 1
      : 2;
  const factor = 10 ** precision;
  return useCountUp(Math.round(value * factor), 320) / factor;
}

function ChartHeader({
  label,
  value,
  format,
  delta,
  fadeKey,
  range,
  ranges,
  rangeId,
  onRangeChange,
  trailing,
}: {
  label: string;
  value?: number;
  format: (value: number) => string;
  delta?: number;
  fadeKey: string;
  range?: string;
  ranges?: RadarChartRange[];
  rangeId?: string;
  onRangeChange: (id: string) => void;
  trailing?: ReactNode;
}) {
  const animatedValue = useAnimatedNumber(value ?? 0);
  const deltaDescription = delta === undefined ? undefined : describeDelta(delta);

  return (
    <header className="flex w-full items-start justify-between gap-3">
      <div className="flex min-w-0 flex-1 flex-col gap-0.5">
        <p className="w-full truncate text-body-medium text-text-secondary">{label}</p>
        {value !== undefined && (
          <div className="flex w-full items-center gap-2">
            <p
              key={fadeKey}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
              aria-live="polite"
            >
              {format(animatedValue)}
            </p>
            {deltaDescription && (
              <Chip variant="bold" color={deltaDescription.color}>
                {deltaDescription.label}
              </Chip>
            )}
          </div>
        )}
      </div>
      {trailing}
      {ranges && ranges.length > 0 ? (
        <ChartRangeControl ranges={ranges} value={rangeId} onValueChange={onRangeChange} />
      ) : (
        range && <ChartRangeControl label={range} />
      )}
    </header>
  );
}

interface LegendItem {
  label: string;
  color: string;
  value?: string;
}

function ChartLegend({ items, className }: { items: LegendItem[]; className?: string }) {
  return (
    <div
      className={cx(
        "flex w-full flex-wrap items-center justify-center gap-x-4 gap-y-1",
        className,
      )}
    >
      {items.map((item) => (
        <div key={item.label} className="flex items-center gap-1.5">
          <span
            className="size-3 shrink-0 rounded-[4px]"
            style={{ backgroundColor: item.color }}
            aria-hidden
          />
          <span className="text-body-regular whitespace-nowrap text-text-secondary">{item.label}</span>
          {item.value !== undefined && (
            <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
              {item.value}
            </span>
          )}
        </div>
      ))}
    </div>
  );
}

function StatTiles({
  items,
  activeIndex,
  onActiveChange,
}: {
  items: { label: string; value: string }[];
  activeIndex: number | null;
  onActiveChange: (index: number | null) => void;
}) {
  const remainderAtThreeColumns = items.length % 3;
  const hasOddCount = items.length % 2 === 1;

  return (
    <div className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
      {items.map((item, index) => {
        const isActive = activeIndex === index;
        const isInFinalDesktopRow =
          remainderAtThreeColumns > 0 && index >= items.length - remainderAtThreeColumns;
        const desktopSpan = isInFinalDesktopRow ? 6 / remainderAtThreeColumns : 2;
        return (
          <div
            key={`${item.label}-${index}`}
            className={cx(
              "flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 transition-opacity duration-200 ease-out",
              hasOddCount && index === items.length - 1 ? "col-span-2" : "col-span-1",
              desktopSpan === 6
                ? "sm:col-span-6"
                : desktopSpan === 3
                  ? "sm:col-span-3"
                  : "sm:col-span-2",
            )}
            style={{ opacity: activeIndex !== null && !isActive ? 0.4 : 1 }}
            onMouseEnter={() => onActiveChange(index)}
            onMouseLeave={() => onActiveChange(null)}
          >
            <span className="max-w-full truncate text-body-regular text-text-secondary">
              {item.label}
            </span>
            <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
              {item.value}
            </span>
          </div>
        );
      })}
    </div>
  );
}

function PulsingActiveDot({ cx: dotX, cy: dotY, color }: ActiveDotProps & { color: string }) {
  if (dotX == null || dotY == null) return null;
  return (
    <g>
      <circle cx={dotX} cy={dotY} r={5} fill={color} opacity={0.3}>
        <animate attributeName="r" values="5;13" dur="1.4s" repeatCount="indefinite" />
        <animate attributeName="opacity" values="0.35;0" dur="1.4s" repeatCount="indefinite" />
      </circle>
      <circle
        cx={dotX}
        cy={dotY}
        r={5}
        fill={color}
        stroke="var(--color-background-secondary-default)"
        strokeWidth={3}
      />
    </g>
  );
}

/**
 * BoardCN-compatible polygon radar card. Axis hover drives the headline,
 * series values and pulsing vertex; the chart intentionally has no floating
 * tooltip. `variant="score"` swaps the headline for a raised centre score.
 */
export function RadarChartCard({
  variant = "filled",
  title,
  data,
  series,
  max,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatNumber,
  alertBelow,
  scoreCaption,
  tiles = false,
  legend = "bottom",
  radiusScale = 1,
  plotOffsetY = 0,
  className,
  ...props
}: RadarChartCardProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const isScore = variant === "score";
  const availableRanges = ranges;
  const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
  const selectedRange =
    availableRanges?.find((item) => item.id === selectedRangeId) ?? availableRanges?.[0];
  const selectedId = selectedRange?.id;
  const chartData = selectedRange?.data ?? data;
  const chartSeries = selectedRange?.series ?? series;
  const chartMax = selectedRange?.max ?? max;
  const restingHeadline = selectedRange?.headline ?? headline;
  const shownDelta = selectedRange?.delta ?? delta;
  const resolvedSeries = chartSeries.map((item, index) => ({
    ...item,
    ...resolveTone(index, item.color, item.activeColor),
  }));
  const numberAt = (key: string, index: number) => Number(chartData[index]?.[key] ?? 0);
  const totalFor = (key: string) =>
    chartData.reduce((total, datum) => total + Number(datum[key] ?? 0), 0);
  const isHovering = activeIndex !== null && activeIndex < chartData.length;
  const primarySeries = resolvedSeries[0];
  const domainMaximum =
    chartMax ??
    (isScore
      ? 100
      : Math.max(
          1,
          ...resolvedSeries.flatMap((item) =>
            chartData.map((datum) => Number(datum[item.key] ?? 0)),
          ),
        ));
  const shownValue = isHovering
    ? numberAt(primarySeries.key, activeIndex)
    : (restingHeadline ?? totalFor(primarySeries.key));
  const shownLabel = isHovering
    ? String(chartData[activeIndex].label)
    : (title ?? (isScore ? "Weekly score" : "Visitors"));
  const averageScore = Math.round(totalFor(primarySeries.key) / Math.max(1, chartData.length));
  const scoreDescription =
    typeof scoreCaption === "function"
      ? scoreCaption(averageScore)
      : (scoreCaption ??
        (averageScore >= 90
          ? "Excellent"
          : averageScore >= 75
            ? "Strong"
            : averageScore >= 50
              ? "Fair"
              : "Needs work"));
  const legendItems =
    resolvedSeries.length > 1
      ? resolvedSeries.map((item) => ({
          label: item.label,
          color: item.color,
          value: format(isHovering ? numberAt(item.key, activeIndex) : totalFor(item.key)),
        }))
      : null;

  const changeRange = (id: string) => {
    setActiveIndex(null);
    setSelectedRangeId(id);
    onRangeChange?.(id);
  };

  return (
    <section
      className={cx(
        "flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        tiles && "h-auto",
        className,
      )}
      {...props}
    >
      <ChartHeader
        label={shownLabel}
        value={isScore ? undefined : shownValue}
        format={format}
        delta={shownDelta}
        fadeKey={`${selectedId ?? ""}:${activeIndex}`}
        range={range}
        ranges={availableRanges}
        rangeId={selectedId}
        onRangeChange={changeRange}
        trailing={
          legendItems && legend === "top" ? (
            <ChartLegend
              items={legendItems}
              className="h-8 w-auto shrink-0 justify-end gap-x-3"
            />
          ) : undefined
        }
      />

      <div
        className={cx("relative min-h-0 w-full flex-1", tiles && "h-[231px] flex-none")}
        role="img"
        aria-label={`${shownLabel} radar chart${isScore ? `, average score ${format(averageScore)}` : `, ${format(shownValue)}`}`}
      >
        <div
          className="absolute inset-0"
          style={plotOffsetY ? { transform: `translateY(${plotOffsetY}px)` } : undefined}
        >
          <ResponsiveContainer width="100%" height="100%">
            <RadarChart
              data={chartData}
              cy={legend === "overlay" && legendItems ? "44%" : "50%"}
              outerRadius={`${Math.round((isScore ? 62 : legend === "overlay" && legendItems ? 66 : 74) * radiusScale)}%`}
              margin={{ top: 4, right: 4, bottom: 4, left: 4 }}
              className="[&_svg]:overflow-visible"
              onMouseMove={(state) => {
                const index = Number(state?.activeTooltipIndex);
                setActiveIndex(
                  state?.isTooltipActive && Number.isInteger(index) ? index : null,
                );
              }}
              onMouseLeave={() => setActiveIndex(null)}
            >
              <PolarGrid stroke="var(--color-chart-cursor)" strokeWidth={1} />
              <PolarAngleAxis
                dataKey="label"
                tick={(tickProps) => {
                  const tickX = Number(tickProps.x);
                  const tickY = Number(tickProps.y);
                  const index = tickProps.payload?.index ?? tickProps.index ?? 0;
                  const label = String(tickProps.payload?.value ?? "");
                  const isActive = index === activeIndex;
                  const textAnchor = tickProps.textAnchor ?? "middle";

                  if (!isScore) {
                    return (
                      <text
                        x={tickX}
                        y={tickY}
                        dy={4}
                        textAnchor={textAnchor}
                        fontSize={12}
                        fontWeight={500}
                        fill={
                          isActive
                            ? "var(--color-text-primary)"
                            : "var(--color-text-tertiary)"
                        }
                        className="transition-[fill] duration-150 ease-out"
                      >
                        {label}
                      </text>
                    );
                  }

                  const sine = Math.sin(
                    (Number(tickProps.payload?.coordinate ?? 90) * Math.PI) / 180,
                  );
                  const offset = sine > 0.3 ? -22 : sine < -0.3 ? 12 : -6;
                  const value = numberAt(primarySeries.key, index);
                  const isAlert = alertBelow !== undefined && value < alertBelow;
                  return (
                    <g>
                      <text
                        x={tickX}
                        y={tickY}
                        dy={offset}
                        textAnchor={textAnchor}
                        fontSize={11}
                        fontWeight={500}
                        letterSpacing="0.02em"
                        fill={
                          isActive
                            ? "var(--color-text-primary)"
                            : "var(--color-text-tertiary)"
                        }
                        className="transition-[fill] duration-150 ease-out"
                      >
                        {label}
                      </text>
                      <text
                        x={tickX}
                        y={tickY}
                        dy={offset + 17}
                        textAnchor={textAnchor}
                        fontSize={16}
                        fontWeight={500}
                        fill={
                          isAlert
                            ? "var(--color-status-rose-text)"
                            : "var(--color-text-primary)"
                        }
                        className="tabular-nums"
                      >
                        {format(value)}
                      </text>
                    </g>
                  );
                }}
                tickLine={false}
              />
              <PolarRadiusAxis domain={[0, domainMaximum]} tick={false} axisLine={false} />
              <Tooltip content={() => null} cursor={false} isAnimationActive={false} />
              {resolvedSeries.map((item) => {
                const isFilled = variant !== "lines";
                const showDots = variant === "dots" || isScore;
                return (
                  <Radar
                    key={item.key}
                    name={item.label}
                    dataKey={item.key}
                    stroke={item.activeColor}
                    strokeWidth={2}
                    strokeLinejoin="round"
                    fill={isFilled ? item.color : "none"}
                    fillOpacity={isFilled ? 0.28 : 0}
                    dot={
                      showDots
                        ? {
                            r: 3.5,
                            fill: item.activeColor,
                            fillOpacity: 1,
                            stroke: "var(--color-background-secondary-default)",
                            strokeWidth: 2,
                          }
                        : false
                    }
                    activeDot={(activeDotProps: ActiveDotProps) => (
                      <PulsingActiveDot {...activeDotProps} color={item.activeColor} />
                    )}
                    isAnimationActive
                    animationDuration={450}
                  />
                );
              })}
            </RadarChart>
          </ResponsiveContainer>

          {isScore && (
            <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
              <div className="flex size-[84px] flex-col items-center justify-center rounded-full bg-background-inner-default shadow-card backdrop-blur-[2px]">
                <span className="text-title-1-medium leading-none text-text-primary tabular-nums">
                  {format(averageScore)}
                </span>
                <span className="mt-1 max-w-[72px] truncate text-caption-1-medium text-text-tertiary">
                  {scoreDescription}
                </span>
              </div>
            </div>
          )}
        </div>

        {legendItems && legend === "overlay" && (
          <ChartLegend items={legendItems} className="absolute inset-x-0 bottom-0" />
        )}
      </div>

      {legendItems && legend === "bottom" && (
        <ChartLegend items={legendItems} className={tiles ? undefined : "pb-1"} />
      )}

      {tiles && (
        <StatTiles
          items={chartData.map((datum, index) => ({
            label: String(datum.label),
            value: format(numberAt(primarySeries.key, index)),
          }))}
          activeIndex={isHovering ? activeIndex : null}
          onActiveChange={setActiveIndex}
        />
      )}
    </section>
  );
}

export default RadarChartCard;
"use client";

import { useState, type HTMLAttributes, type ReactNode } from "react";
import {
  PolarAngleAxis,
  PolarGrid,
  PolarRadiusAxis,
  Radar,
  RadarChart,
  ResponsiveContainer,
  Tooltip,
  type ActiveDotProps,
} from "recharts";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export type RadarChartVariant = "filled" | "dots" | "lines" | "score";
export type RadarChartLegendPosition = "top" | "bottom" | "overlay";

export interface RadarChartDatum {
  label: string;
  [seriesKey: string]: string | number;
}

export interface RadarChartSeries {
  key: string;
  label: string;
  /** Any CSS color. Defaults follow BoardCN's chart tone cycle. */
  color?: string;
  /** Hover/active color. A custom color is mixed with black when omitted. */
  activeColor?: string;
}

export interface RadarChartRange {
  id: string;
  label: string;
  data: RadarChartDatum[];
  series?: RadarChartSeries[];
  max?: number;
  headline?: number;
  /** Decimal change, e.g. `0.052` renders as `+5.2%`. */
  delta?: number;
}

export interface RadarChartCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
  variant?: RadarChartVariant;
  title?: string;
  data: RadarChartDatum[];
  series: RadarChartSeries[];
  max?: number;
  headline?: number;
  /** Decimal change, e.g. `0.052` renders as `+5.2%`. */
  delta?: number;
  /** Static period pill. */
  range?: string;
  ranges?: RadarChartRange[];
  defaultRange?: string;
  onRangeChange?: (id: string) => void;
  format?: (value: number) => string;
  /** In score mode, values below this threshold use the rose status color. */
  alertBelow?: number;
  scoreCaption?: string | ((average: number) => string);
  tiles?: boolean;
  legend?: RadarChartLegendPosition;
  radiusScale?: number;
  plotOffsetY?: number;
}

const TONES = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const formatNumber = (value: number) => value.toLocaleString("en-US");

function resolveTone(index: number, color?: string, activeColor?: string) {
  const tone = TONES[index % TONES.length];
  return color
    ? {
        color,
        activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
      }
    : {
        color: `var(--color-chart-${tone})`,
        activeColor: `var(--color-chart-${tone}-active)`,
      };
}

function describeDelta(delta: number) {
  const percentage = Math.round(Math.abs(delta) * 1000) / 10;
  if (percentage === 0) return { label: "0.0%", color: "neutral" as const };
  return {
    label: `${delta > 0 ? "+" : "-"}${percentage}%`,
    color: delta > 0 ? ("lime" as const) : ("rose" as const),
  };
}

function useAnimatedNumber(value: number) {
  const precision = Number.isInteger(value)
    ? 0
    : Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
      ? 1
      : 2;
  const factor = 10 ** precision;
  return useCountUp(Math.round(value * factor), 320) / factor;
}

function ChartHeader({
  label,
  value,
  format,
  delta,
  fadeKey,
  range,
  ranges,
  rangeId,
  onRangeChange,
  trailing,
}: {
  label: string;
  value?: number;
  format: (value: number) => string;
  delta?: number;
  fadeKey: string;
  range?: string;
  ranges?: RadarChartRange[];
  rangeId?: string;
  onRangeChange: (id: string) => void;
  trailing?: ReactNode;
}) {
  const animatedValue = useAnimatedNumber(value ?? 0);
  const deltaDescription = delta === undefined ? undefined : describeDelta(delta);

  return (
    <header className="flex w-full items-start justify-between gap-3">
      <div className="flex min-w-0 flex-1 flex-col gap-0.5">
        <p className="w-full truncate text-body-medium text-text-secondary">{label}</p>
        {value !== undefined && (
          <div className="flex w-full items-center gap-2">
            <p
              key={fadeKey}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
              aria-live="polite"
            >
              {format(animatedValue)}
            </p>
            {deltaDescription && (
              <Chip variant="bold" color={deltaDescription.color}>
                {deltaDescription.label}
              </Chip>
            )}
          </div>
        )}
      </div>
      {trailing}
      {ranges && ranges.length > 0 ? (
        <ChartRangeControl ranges={ranges} value={rangeId} onValueChange={onRangeChange} />
      ) : (
        range && <ChartRangeControl label={range} />
      )}
    </header>
  );
}

interface LegendItem {
  label: string;
  color: string;
  value?: string;
}

function ChartLegend({ items, className }: { items: LegendItem[]; className?: string }) {
  return (
    <div
      className={cx(
        "flex w-full flex-wrap items-center justify-center gap-x-4 gap-y-1",
        className,
      )}
    >
      {items.map((item) => (
        <div key={item.label} className="flex items-center gap-1.5">
          <span
            className="size-3 shrink-0 rounded-[4px]"
            style={{ backgroundColor: item.color }}
            aria-hidden
          />
          <span className="text-body-regular whitespace-nowrap text-text-secondary">{item.label}</span>
          {item.value !== undefined && (
            <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
              {item.value}
            </span>
          )}
        </div>
      ))}
    </div>
  );
}

function StatTiles({
  items,
  activeIndex,
  onActiveChange,
}: {
  items: { label: string; value: string }[];
  activeIndex: number | null;
  onActiveChange: (index: number | null) => void;
}) {
  const remainderAtThreeColumns = items.length % 3;
  const hasOddCount = items.length % 2 === 1;

  return (
    <div className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
      {items.map((item, index) => {
        const isActive = activeIndex === index;
        const isInFinalDesktopRow =
          remainderAtThreeColumns > 0 && index >= items.length - remainderAtThreeColumns;
        const desktopSpan = isInFinalDesktopRow ? 6 / remainderAtThreeColumns : 2;
        return (
          <div
            key={`${item.label}-${index}`}
            className={cx(
              "flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 transition-opacity duration-200 ease-out",
              hasOddCount && index === items.length - 1 ? "col-span-2" : "col-span-1",
              desktopSpan === 6
                ? "sm:col-span-6"
                : desktopSpan === 3
                  ? "sm:col-span-3"
                  : "sm:col-span-2",
            )}
            style={{ opacity: activeIndex !== null && !isActive ? 0.4 : 1 }}
            onMouseEnter={() => onActiveChange(index)}
            onMouseLeave={() => onActiveChange(null)}
          >
            <span className="max-w-full truncate text-body-regular text-text-secondary">
              {item.label}
            </span>
            <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
              {item.value}
            </span>
          </div>
        );
      })}
    </div>
  );
}

function PulsingActiveDot({ cx: dotX, cy: dotY, color }: ActiveDotProps & { color: string }) {
  if (dotX == null || dotY == null) return null;
  return (
    <g>
      <circle cx={dotX} cy={dotY} r={5} fill={color} opacity={0.3}>
        <animate attributeName="r" values="5;13" dur="1.4s" repeatCount="indefinite" />
        <animate attributeName="opacity" values="0.35;0" dur="1.4s" repeatCount="indefinite" />
      </circle>
      <circle
        cx={dotX}
        cy={dotY}
        r={5}
        fill={color}
        stroke="var(--color-background-secondary-default)"
        strokeWidth={3}
      />
    </g>
  );
}

/**
 * BoardCN-compatible polygon radar card. Axis hover drives the headline,
 * series values and pulsing vertex; the chart intentionally has no floating
 * tooltip. `variant="score"` swaps the headline for a raised centre score.
 */
export function RadarChartCard({
  variant = "filled",
  title,
  data,
  series,
  max,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatNumber,
  alertBelow,
  scoreCaption,
  tiles = false,
  legend = "bottom",
  radiusScale = 1,
  plotOffsetY = 0,
  className,
  ...props
}: RadarChartCardProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const isScore = variant === "score";
  const availableRanges = ranges;
  const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
  const selectedRange =
    availableRanges?.find((item) => item.id === selectedRangeId) ?? availableRanges?.[0];
  const selectedId = selectedRange?.id;
  const chartData = selectedRange?.data ?? data;
  const chartSeries = selectedRange?.series ?? series;
  const chartMax = selectedRange?.max ?? max;
  const restingHeadline = selectedRange?.headline ?? headline;
  const shownDelta = selectedRange?.delta ?? delta;
  const resolvedSeries = chartSeries.map((item, index) => ({
    ...item,
    ...resolveTone(index, item.color, item.activeColor),
  }));
  const numberAt = (key: string, index: number) => Number(chartData[index]?.[key] ?? 0);
  const totalFor = (key: string) =>
    chartData.reduce((total, datum) => total + Number(datum[key] ?? 0), 0);
  const isHovering = activeIndex !== null && activeIndex < chartData.length;
  const primarySeries = resolvedSeries[0];
  const domainMaximum =
    chartMax ??
    (isScore
      ? 100
      : Math.max(
          1,
          ...resolvedSeries.flatMap((item) =>
            chartData.map((datum) => Number(datum[item.key] ?? 0)),
          ),
        ));
  const shownValue = isHovering
    ? numberAt(primarySeries.key, activeIndex)
    : (restingHeadline ?? totalFor(primarySeries.key));
  const shownLabel = isHovering
    ? String(chartData[activeIndex].label)
    : (title ?? (isScore ? "Weekly score" : "Visitors"));
  const averageScore = Math.round(totalFor(primarySeries.key) / Math.max(1, chartData.length));
  const scoreDescription =
    typeof scoreCaption === "function"
      ? scoreCaption(averageScore)
      : (scoreCaption ??
        (averageScore >= 90
          ? "Excellent"
          : averageScore >= 75
            ? "Strong"
            : averageScore >= 50
              ? "Fair"
              : "Needs work"));
  const legendItems =
    resolvedSeries.length > 1
      ? resolvedSeries.map((item) => ({
          label: item.label,
          color: item.color,
          value: format(isHovering ? numberAt(item.key, activeIndex) : totalFor(item.key)),
        }))
      : null;

  const changeRange = (id: string) => {
    setActiveIndex(null);
    setSelectedRangeId(id);
    onRangeChange?.(id);
  };

  return (
    <section
      className={cx(
        "flex h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        tiles && "h-auto",
        className,
      )}
      {...props}
    >
      <ChartHeader
        label={shownLabel}
        value={isScore ? undefined : shownValue}
        format={format}
        delta={shownDelta}
        fadeKey={`${selectedId ?? ""}:${activeIndex}`}
        range={range}
        ranges={availableRanges}
        rangeId={selectedId}
        onRangeChange={changeRange}
        trailing={
          legendItems && legend === "top" ? (
            <ChartLegend
              items={legendItems}
              className="h-8 w-auto shrink-0 justify-end gap-x-3"
            />
          ) : undefined
        }
      />

      <div
        className={cx("relative min-h-0 w-full flex-1", tiles && "h-[231px] flex-none")}
        role="img"
        aria-label={`${shownLabel} radar chart${isScore ? `, average score ${format(averageScore)}` : `, ${format(shownValue)}`}`}
      >
        <div
          className="absolute inset-0"
          style={plotOffsetY ? { transform: `translateY(${plotOffsetY}px)` } : undefined}
        >
          <ResponsiveContainer width="100%" height="100%">
            <RadarChart
              data={chartData}
              cy={legend === "overlay" && legendItems ? "44%" : "50%"}
              outerRadius={`${Math.round((isScore ? 62 : legend === "overlay" && legendItems ? 66 : 74) * radiusScale)}%`}
              margin={{ top: 4, right: 4, bottom: 4, left: 4 }}
              className="[&_svg]:overflow-visible"
              onMouseMove={(state) => {
                const index = Number(state?.activeTooltipIndex);
                setActiveIndex(
                  state?.isTooltipActive && Number.isInteger(index) ? index : null,
                );
              }}
              onMouseLeave={() => setActiveIndex(null)}
            >
              <PolarGrid stroke="var(--color-chart-cursor)" strokeWidth={1} />
              <PolarAngleAxis
                dataKey="label"
                tick={(tickProps) => {
                  const tickX = Number(tickProps.x);
                  const tickY = Number(tickProps.y);
                  const index = tickProps.payload?.index ?? tickProps.index ?? 0;
                  const label = String(tickProps.payload?.value ?? "");
                  const isActive = index === activeIndex;
                  const textAnchor = tickProps.textAnchor ?? "middle";

                  if (!isScore) {
                    return (
                      <text
                        x={tickX}
                        y={tickY}
                        dy={4}
                        textAnchor={textAnchor}
                        fontSize={12}
                        fontWeight={500}
                        fill={
                          isActive
                            ? "var(--color-text-primary)"
                            : "var(--color-text-tertiary)"
                        }
                        className="transition-[fill] duration-150 ease-out"
                      >
                        {label}
                      </text>
                    );
                  }

                  const sine = Math.sin(
                    (Number(tickProps.payload?.coordinate ?? 90) * Math.PI) / 180,
                  );
                  const offset = sine > 0.3 ? -22 : sine < -0.3 ? 12 : -6;
                  const value = numberAt(primarySeries.key, index);
                  const isAlert = alertBelow !== undefined && value < alertBelow;
                  return (
                    <g>
                      <text
                        x={tickX}
                        y={tickY}
                        dy={offset}
                        textAnchor={textAnchor}
                        fontSize={11}
                        fontWeight={500}
                        letterSpacing="0.02em"
                        fill={
                          isActive
                            ? "var(--color-text-primary)"
                            : "var(--color-text-tertiary)"
                        }
                        className="transition-[fill] duration-150 ease-out"
                      >
                        {label}
                      </text>
                      <text
                        x={tickX}
                        y={tickY}
                        dy={offset + 17}
                        textAnchor={textAnchor}
                        fontSize={16}
                        fontWeight={500}
                        fill={
                          isAlert
                            ? "var(--color-status-rose-text)"
                            : "var(--color-text-primary)"
                        }
                        className="tabular-nums"
                      >
                        {format(value)}
                      </text>
                    </g>
                  );
                }}
                tickLine={false}
              />
              <PolarRadiusAxis domain={[0, domainMaximum]} tick={false} axisLine={false} />
              <Tooltip content={() => null} cursor={false} isAnimationActive={false} />
              {resolvedSeries.map((item) => {
                const isFilled = variant !== "lines";
                const showDots = variant === "dots" || isScore;
                return (
                  <Radar
                    key={item.key}
                    name={item.label}
                    dataKey={item.key}
                    stroke={item.activeColor}
                    strokeWidth={2}
                    strokeLinejoin="round"
                    fill={isFilled ? item.color : "none"}
                    fillOpacity={isFilled ? 0.28 : 0}
                    dot={
                      showDots
                        ? {
                            r: 3.5,
                            fill: item.activeColor,
                            fillOpacity: 1,
                            stroke: "var(--color-background-secondary-default)",
                            strokeWidth: 2,
                          }
                        : false
                    }
                    activeDot={(activeDotProps: ActiveDotProps) => (
                      <PulsingActiveDot {...activeDotProps} color={item.activeColor} />
                    )}
                    isAnimationActive
                    animationDuration={450}
                  />
                );
              })}
            </RadarChart>
          </ResponsiveContainer>

          {isScore && (
            <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
              <div className="flex size-[84px] flex-col items-center justify-center rounded-full bg-background-inner-default shadow-card backdrop-blur-[2px]">
                <span className="text-title-1-medium leading-none text-text-primary tabular-nums">
                  {format(averageScore)}
                </span>
                <span className="mt-1 max-w-[72px] truncate text-caption-1-medium text-text-tertiary">
                  {scoreDescription}
                </span>
              </div>
            </div>
          )}
        </div>

        {legendItems && legend === "overlay" && (
          <ChartLegend items={legendItems} className="absolute inset-x-0 bottom-0" />
        )}
      </div>

      {legendItems && legend === "bottom" && (
        <ChartLegend items={legendItems} className={tiles ? undefined : "pb-1"} />
      )}

      {tiles && (
        <StatTiles
          items={chartData.map((datum, index) => ({
            label: String(datum.label),
            value: format(numberAt(primarySeries.key, index)),
          }))}
          activeIndex={isHovering ? activeIndex : null}
          onActiveChange={setActiveIndex}
        />
      )}
    </section>
  );
}

export default RadarChartCard;

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

RadarChartCard

BoardCN-compatible polygon radar card. Axis hover drives the headline, series values and pulsing vertex; the chart intentionally has no floating tooltip. `variant="score"` swaps the headline for a raised centre score.

PropTypeDefaultDescription
datarequiredRadarChartDatum[]
seriesrequiredRadarChartSeries[]
alertBelownumberIn score mode, values below this threshold use the rose status color.
defaultRangestring
deltanumberDecimal change, e.g. `0.052` renders as `+5.2%`.
format(value: number) => string(value: number) => value.toLocaleString("en-US")
headlinenumber
legend"top" | "bottom" | "overlay"bottom
maxnumber
onRangeChange(id: string) => void
plotOffsetYnumber0
radiusScalenumber1
rangestringStatic period pill.
rangesRadarChartRange[]
scoreCaptionstring | ((average: number) => string)
tilesbooleanfalse
titlestring
variant"filled" | "dots" | "lines" | "score"filled