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.jsonnpx shadcn@latest add https://boardcn.dev/r/radar-chart-card.jsonnpm 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.
"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.
| Prop | Type | Default | Description |
|---|---|---|---|
| datarequired | RadarChartDatum[] | — | — |
| seriesrequired | RadarChartSeries[] | — | — |
| alertBelow | number | — | In score mode, values below this threshold use the rose status color. |
| defaultRange | string | — | — |
| delta | number | — | Decimal change, e.g. `0.052` renders as `+5.2%`. |
| format | (value: number) => string | (value: number) => value.toLocaleString("en-US") | — |
| headline | number | — | — |
| legend | "top" | "bottom" | "overlay" | bottom | — |
| max | number | — | — |
| onRangeChange | (id: string) => void | — | — |
| plotOffsetY | number | 0 | — |
| radiusScale | number | 1 | — |
| range | string | — | Static period pill. |
| ranges | RadarChartRange[] | — | — |
| scoreCaption | string | ((average: number) => string) | — | — |
| tiles | boolean | false | — |
| title | string | — | — |
| variant | "filled" | "dots" | "lines" | "score" | filled | — |