Radial Chart
Interactive radial chart card.
Radial
Radial bars and a donut.
Visitors
925
+5.2%function RadialChartDemo() {
return <RadialChartCard data={RADIAL_RING_DATA} ranges={RADIAL_RING_RANGES} />;
}function RadialChartDemo() {
return <RadialChartCard data={RADIAL_RING_DATA} ranges={RADIAL_RING_RANGES} />;
}Installation
npx shadcn@latest add https://boardcn.dev/r/radial-chart-card.jsonnpx shadcn@latest add https://boardcn.dev/r/radial-chart-card.jsonnpm packages
- motion
- 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 {
useCallback,
useMemo,
useState,
type FocusEvent,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useReducedMotion } from "motion/react";
import {
Cell,
LabelList,
Pie,
PieChart,
PolarAngleAxis,
PolarGrid,
RadialBar,
RadialBarChart,
ResponsiveContainer,
} 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 RadialChartVariant =
| "rings"
| "labels"
| "grid"
| "gauge"
| "solid"
| "stacked";
export interface RadialChartDatum {
label: string;
value: number;
/** Any CSS color. Defaults follow BoardCN's chart tone cycle. */
color?: string;
/** Hover/active color. A custom color is darkened when this is omitted. */
activeColor?: string;
}
export interface RadialChartRange {
id: string;
label: string;
data: RadialChartDatum[];
max?: number;
headline?: number;
/** Decimal change, for example 0.052 renders as +5.2%. */
delta?: number;
}
export interface RadialChartCardProps
extends Omit<HTMLAttributes<HTMLElement>, "title" | "children"> {
variant?: RadialChartVariant;
title?: string;
data: RadialChartDatum[];
/** Full-circle value. Ring variants default to 110% of the largest item. */
max?: number;
/** Resting header value. Defaults to the sum of the active data. */
headline?: number;
/** Decimal change, for example 0.052 renders as +5.2%. */
delta?: number;
/** Static period label. */
range?: string;
ranges?: RadialChartRange[];
defaultRange?: string;
onRangeChange?: (id: string) => void;
format?: (value: number) => string;
centerCaption?: string;
/** Adds linked stat tiles below the plot and lets the card grow to fit. */
tiles?: boolean;
}
interface ResolvedTone {
color: string;
activeColor: string;
}
const TONE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const formatNumber = (value: number) => value.toLocaleString("en-US");
const formatPercent = (value: number) => `${value}%`;
function resolveTone(index: number, color?: string, activeColor?: string): ResolvedTone {
if (color) {
return {
color,
activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
};
}
const tone = TONE_ORDER[index % TONE_ORDER.length];
return {
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 scale = 10 ** (Number.isInteger(value) ? 0 : Math.abs(value * 10 - Math.round(value * 10)) < 0.000001 ? 1 : 2);
return useCountUp(Math.round(value * scale)) / scale;
}
function useChartRange(
ranges: RadialChartRange[] | undefined,
defaultRange: string | undefined,
onRangeChange: ((id: string) => void) | undefined,
) {
const [selectedId, setSelectedId] = useState(defaultRange);
const selected = ranges?.find((item) => item.id === selectedId) ?? ranges?.[0];
return {
selected,
selectedId: selected?.id,
select(id: string) {
setSelectedId(id);
onRangeChange?.(id);
},
};
}
function ChartHeader({
label,
value,
format,
delta,
hovering,
fadeKey,
range,
ranges,
rangeId,
onRangeChange,
}: {
label: string;
value: number;
format: (value: number) => string;
delta?: ReturnType<typeof describeDelta>;
hovering?: boolean;
fadeKey: string;
range?: string;
ranges?: RadialChartRange[];
rangeId?: string;
onRangeChange: (id: string) => void;
}) {
const animatedValue = useAnimatedNumber(value);
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>
<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>
{delta && (
<Chip variant="bold" color={delta.color} className={hovering ? "invisible" : undefined}>
{delta.label}
</Chip>
)}
</div>
</div>
{ranges?.length ? (
<ChartRangeControl ranges={ranges} value={rangeId} onValueChange={onRangeChange} />
) : (
range && <ChartRangeControl label={range} />
)}
</header>
);
}
function CenterReadout({
value,
caption,
fadeKey,
className,
}: {
value: number;
caption?: string;
fadeKey: string;
className?: string;
}) {
const animatedValue = useAnimatedNumber(value);
return (
<div
className={cx(
"pointer-events-none absolute inset-0 flex flex-col items-center justify-center",
className,
)}
>
<span key={fadeKey} className="animate-number-fade text-display-4-medium text-text-primary tabular-nums">
{formatPercent(animatedValue)}
</span>
{caption && (
<span
key={`caption:${fadeKey}`}
className="animate-number-fade -mt-1 max-w-[120px] truncate text-caption-1-medium text-text-tertiary"
>
{caption}
</span>
)}
</div>
);
}
function InteractiveItem({
active,
dimmed,
onActivate,
onDeactivate,
children,
className,
}: {
active: boolean;
dimmed: boolean;
onActivate: () => void;
onDeactivate: () => void;
children: ReactNode;
className?: string;
}) {
return (
<button
type="button"
data-active={active || undefined}
onMouseEnter={onActivate}
onMouseLeave={onDeactivate}
onFocus={onActivate}
onBlur={onDeactivate}
className={cx(
"text-left outline-none transition-opacity duration-200 ease-out focus-visible:ring-2 focus-visible:ring-border-focus-ring",
dimmed && "opacity-50",
className,
)}
>
{children}
</button>
);
}
function ChartLegend({
items,
activeIndex,
onActiveChange,
}: {
items: Array<{ label: string; value: string; color: string }>;
activeIndex: number | null;
onActiveChange: (index: number | null) => void;
}) {
return (
<div className="flex w-full flex-wrap items-center justify-center gap-x-4 gap-y-1 pb-1">
{items.map((item, index) => (
<InteractiveItem
key={item.label}
active={activeIndex === index}
dimmed={activeIndex !== null && activeIndex !== index}
onActivate={() => onActiveChange(index)}
onDeactivate={() => onActiveChange(null)}
className="flex items-center gap-1.5 rounded-sm"
>
<span className="size-3 shrink-0 rounded-[4px]" style={{ backgroundColor: item.color }} />
<span className="text-body-regular whitespace-nowrap text-text-secondary">{item.label}</span>
<span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">{item.value}</span>
</InteractiveItem>
))}
</div>
);
}
const LAST_ROW_SPAN: Record<number, string> = {
2: "sm:col-span-2",
3: "sm:col-span-3",
6: "sm:col-span-6",
};
function ChartStatTiles({
items,
activeIndex,
onActiveChange,
}: {
items: Array<{ label: string; value: string; color: string; activeColor: string }>;
activeIndex: number | null;
onActiveChange: (index: number | null) => void;
}) {
const remainderByThree = 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 active = activeIndex === index;
const isInLastRow = remainderByThree > 0 && index >= items.length - remainderByThree;
return (
<InteractiveItem
key={`${item.label}-${index}`}
active={active}
dimmed={false}
onActivate={() => onActiveChange(index)}
onDeactivate={() => onActiveChange(null)}
className={cx(
"flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2",
hasOddCount && index === items.length - 1 ? "col-span-2" : "col-span-1",
LAST_ROW_SPAN[isInLastRow ? 6 / remainderByThree : 2],
activeIndex !== null && !active && "opacity-40",
)}
>
<span className="flex min-w-0 max-w-full items-center gap-1.5">
<span
className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out"
style={{ backgroundColor: active ? item.activeColor : item.color }}
/>
<span className="truncate text-body-regular text-text-secondary">{item.label}</span>
</span>
<span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">{item.value}</span>
</InteractiveItem>
);
})}
</div>
);
}
/**
* BoardCN-compatible radial chart card: concentric rings, labelled/grid rings,
* two single-value gauges, and a stacked half gauge from one data contract.
*/
export function RadialChartCard({
variant = "rings",
title = "Visitors",
data,
max,
headline,
delta,
range,
ranges,
defaultRange,
onRangeChange,
format = formatNumber,
centerCaption,
tiles = false,
className,
...props
}: RadialChartCardProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const reduceMotion = useReducedMotion();
const isRingVariant = variant === "rings" || variant === "labels" || variant === "grid";
const isSingleGauge = variant === "gauge" || variant === "solid";
const availableRanges = ranges;
const { selected, selectedId, select } = useChartRange(availableRanges, defaultRange, onRangeChange);
const chartData = selected?.data ?? data;
const selectedHeadline = selected?.headline ?? headline;
const selectedDelta = selected?.delta ?? delta;
const tones = useMemo(
() => chartData.map((item, index) => resolveTone(index, item.color, item.activeColor)),
[chartData],
);
const values = chartData.map((item) => item.value);
const total = values.reduce((sum, value) => sum + value, 0);
const largest = Math.max(1, ...values);
const chartMax =
selected?.max ??
max ??
(isRingVariant
? Math.ceil(largest * 1.1)
: Math.max(1, isSingleGauge ? largest : total));
const hasActiveItem = activeIndex !== null && activeIndex < chartData.length;
const headerLabel = hasActiveItem ? chartData[activeIndex].label : title;
const headerValue = hasActiveItem ? chartData[activeIndex].value : (selectedHeadline ?? total);
const percentOfMax = (value: number) => Math.round((value / Math.max(1, chartMax)) * 100);
const plottedData = useMemo(
() =>
chartData.map((item, index) => ({
...item,
fill: tones[index].color,
index,
})),
[chartData, tones],
);
const [lastAnimatedData, setLastAnimatedData] = useState<RadialChartDatum[] | null>(null);
const shouldAnimate = !reduceMotion && lastAnimatedData !== chartData;
const rememberAnimatedData = useCallback(() => setLastAnimatedData(chartData), [chartData]);
const centerReadout = isSingleGauge
? { value: percentOfMax(chartData[0]?.value ?? 0), caption: centerCaption ?? "of goal" }
: variant === "stacked"
? {
value: percentOfMax(chartData[hasActiveItem ? activeIndex : 0]?.value ?? 0),
caption: centerCaption ?? chartData[hasActiveItem ? activeIndex : 0]?.label ?? "",
}
: null;
const stackedData = useMemo(() => {
const remainder = Math.max(0, chartMax - total);
return [
...plottedData.map((item) => ({ value: item.value, fill: item.fill })),
...(remainder > 0 ? [{ value: remainder, fill: "transparent" }] : []),
];
}, [chartMax, plottedData, total]);
function moveActiveIndex(event: KeyboardEvent<HTMLDivElement>) {
if (
chartData.length === 0 ||
(event.key !== "ArrowLeft" && event.key !== "ArrowRight")
) {
return;
}
event.preventDefault();
const direction = event.key === "ArrowRight" ? 1 : -1;
setActiveIndex((current) => {
const start = current ?? (direction > 0 ? -1 : 0);
return (start + direction + chartData.length) % chartData.length;
});
}
function clearFocus(event: FocusEvent<HTMLDivElement>) {
if (!event.currentTarget.contains(event.relatedTarget)) setActiveIndex(null);
}
const chart =
variant === "stacked" ? (
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={[{ value: 1 }]}
dataKey="value"
cx="50%"
cy="78%"
innerRadius={96}
outerRadius={128}
startAngle={180}
endAngle={0}
cornerRadius={99}
fill="var(--color-chart-track)"
stroke="none"
isAnimationActive={false}
/>
<Pie
data={stackedData}
dataKey="value"
cx="50%"
cy="78%"
innerRadius={96}
outerRadius={128}
startAngle={180}
endAngle={0}
cornerRadius={99}
paddingAngle={3}
stroke="none"
onMouseEnter={(_, index) => setActiveIndex(index < plottedData.length ? index : null)}
onMouseLeave={() => setActiveIndex(null)}
isAnimationActive={!reduceMotion}
animationDuration={450}
>
{stackedData.map((item, index) => {
const isRealSegment = index < plottedData.length;
const isActive = hasActiveItem && activeIndex === index;
return (
<Cell
key={`${index}-${item.value}`}
fill={isRealSegment && isActive ? tones[index].activeColor : item.fill}
opacity={isRealSegment && hasActiveItem && !isActive ? 0.3 : 1}
className="transition-opacity duration-200 ease-out"
/>
);
})}
</Pie>
</PieChart>
) : (
<RadialBarChart
data={plottedData}
innerRadius={variant === "labels" ? 26 : isSingleGauge ? (variant === "solid" ? 72 : 88) : 44}
outerRadius={isSingleGauge ? 104 : 106}
startAngle={90}
endAngle={-270}
barCategoryGap={variant === "labels" ? "14%" : "22%"}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<PolarAngleAxis type="number" domain={[0, chartMax]} tick={false} axisLine={false} />
{variant === "grid" && (
<PolarGrid
gridType="circle"
stroke="var(--color-chart-cursor)"
strokeWidth={1}
polarAngles={[0, 45, 90, 135, 180, 225, 270, 315]}
/>
)}
{variant === "solid" && (
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
fill="var(--color-background-inner-default)"
polarRadius={[64]}
/>
)}
<RadialBar
dataKey="value"
cornerRadius={99}
background={variant !== "grid" ? { fill: "var(--color-chart-track)" } : undefined}
onMouseEnter={(_, index) => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(null)}
isAnimationActive={shouldAnimate}
animationDuration={450}
onAnimationEnd={rememberAnimatedData}
>
{plottedData.map((item) => (
<Cell
key={item.label}
fill={activeIndex === item.index ? tones[item.index].activeColor : item.fill}
opacity={hasActiveItem && activeIndex !== item.index ? 0.25 : 1}
className="transition-[fill,opacity] duration-200 ease-out"
/>
))}
{variant === "labels" && (
<LabelList
dataKey="label"
position="insideStart"
fill="var(--color-background-secondary-default)"
fontSize={10}
fontWeight={500}
offset={10}
className="pointer-events-none"
/>
)}
</RadialBar>
</RadialBarChart>
);
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",
variant === "stacked" && !tiles && "max-sm:h-auto max-sm:min-h-[329px]",
className,
)}
{...props}
>
<ChartHeader
label={headerLabel}
value={headerValue}
format={format}
delta={selectedDelta === undefined ? undefined : describeDelta(selectedDelta)}
hovering={hasActiveItem}
fadeKey={`${selectedId ?? ""}:${activeIndex}`}
range={range}
ranges={availableRanges}
rangeId={selectedId}
onRangeChange={(id) => {
setActiveIndex(null);
select(id);
}}
/>
<div
className={cx(
"relative min-h-0 w-full flex-1",
tiles && "h-[231px] flex-none",
variant === "stacked" && !tiles && "max-sm:min-h-[180px]",
)}
onFocus={() => setActiveIndex((current) => current ?? 0)}
onBlur={clearFocus}
onKeyDown={moveActiveIndex}
onMouseLeave={() => setActiveIndex(null)}
role="group"
aria-label={`${title} radial chart. ${chartData.map((item) => `${item.label}: ${format(item.value)}`).join(", ")}`}
>
<ResponsiveContainer width="100%" height="100%">
{chart}
</ResponsiveContainer>
{centerReadout && (
<div
className={
variant === "stacked"
? "pointer-events-none absolute inset-x-0 top-0 h-[78%]"
: "pointer-events-none absolute inset-0"
}
>
<CenterReadout
value={centerReadout.value}
caption={centerReadout.caption}
fadeKey={`${selectedId ?? ""}:${activeIndex}`}
className={variant === "stacked" ? "justify-end" : undefined}
/>
</div>
)}
</div>
{variant === "stacked" && !tiles && (
<ChartLegend
items={plottedData.map((item, index) => ({
label: item.label,
value: format(item.value),
color: tones[index].color,
}))}
activeIndex={hasActiveItem ? activeIndex : null}
onActiveChange={setActiveIndex}
/>
)}
{tiles && (
<ChartStatTiles
items={plottedData.map((item, index) => ({
label: item.label,
value: format(item.value),
color: tones[index].color,
activeColor: tones[index].activeColor,
}))}
activeIndex={hasActiveItem ? activeIndex : null}
onActiveChange={setActiveIndex}
/>
)}
</section>
);
}"use client";
import {
useCallback,
useMemo,
useState,
type FocusEvent,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useReducedMotion } from "motion/react";
import {
Cell,
LabelList,
Pie,
PieChart,
PolarAngleAxis,
PolarGrid,
RadialBar,
RadialBarChart,
ResponsiveContainer,
} 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 RadialChartVariant =
| "rings"
| "labels"
| "grid"
| "gauge"
| "solid"
| "stacked";
export interface RadialChartDatum {
label: string;
value: number;
/** Any CSS color. Defaults follow BoardCN's chart tone cycle. */
color?: string;
/** Hover/active color. A custom color is darkened when this is omitted. */
activeColor?: string;
}
export interface RadialChartRange {
id: string;
label: string;
data: RadialChartDatum[];
max?: number;
headline?: number;
/** Decimal change, for example 0.052 renders as +5.2%. */
delta?: number;
}
export interface RadialChartCardProps
extends Omit<HTMLAttributes<HTMLElement>, "title" | "children"> {
variant?: RadialChartVariant;
title?: string;
data: RadialChartDatum[];
/** Full-circle value. Ring variants default to 110% of the largest item. */
max?: number;
/** Resting header value. Defaults to the sum of the active data. */
headline?: number;
/** Decimal change, for example 0.052 renders as +5.2%. */
delta?: number;
/** Static period label. */
range?: string;
ranges?: RadialChartRange[];
defaultRange?: string;
onRangeChange?: (id: string) => void;
format?: (value: number) => string;
centerCaption?: string;
/** Adds linked stat tiles below the plot and lets the card grow to fit. */
tiles?: boolean;
}
interface ResolvedTone {
color: string;
activeColor: string;
}
const TONE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const formatNumber = (value: number) => value.toLocaleString("en-US");
const formatPercent = (value: number) => `${value}%`;
function resolveTone(index: number, color?: string, activeColor?: string): ResolvedTone {
if (color) {
return {
color,
activeColor: activeColor ?? `color-mix(in srgb, ${color} 82%, black)`,
};
}
const tone = TONE_ORDER[index % TONE_ORDER.length];
return {
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 scale = 10 ** (Number.isInteger(value) ? 0 : Math.abs(value * 10 - Math.round(value * 10)) < 0.000001 ? 1 : 2);
return useCountUp(Math.round(value * scale)) / scale;
}
function useChartRange(
ranges: RadialChartRange[] | undefined,
defaultRange: string | undefined,
onRangeChange: ((id: string) => void) | undefined,
) {
const [selectedId, setSelectedId] = useState(defaultRange);
const selected = ranges?.find((item) => item.id === selectedId) ?? ranges?.[0];
return {
selected,
selectedId: selected?.id,
select(id: string) {
setSelectedId(id);
onRangeChange?.(id);
},
};
}
function ChartHeader({
label,
value,
format,
delta,
hovering,
fadeKey,
range,
ranges,
rangeId,
onRangeChange,
}: {
label: string;
value: number;
format: (value: number) => string;
delta?: ReturnType<typeof describeDelta>;
hovering?: boolean;
fadeKey: string;
range?: string;
ranges?: RadialChartRange[];
rangeId?: string;
onRangeChange: (id: string) => void;
}) {
const animatedValue = useAnimatedNumber(value);
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>
<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>
{delta && (
<Chip variant="bold" color={delta.color} className={hovering ? "invisible" : undefined}>
{delta.label}
</Chip>
)}
</div>
</div>
{ranges?.length ? (
<ChartRangeControl ranges={ranges} value={rangeId} onValueChange={onRangeChange} />
) : (
range && <ChartRangeControl label={range} />
)}
</header>
);
}
function CenterReadout({
value,
caption,
fadeKey,
className,
}: {
value: number;
caption?: string;
fadeKey: string;
className?: string;
}) {
const animatedValue = useAnimatedNumber(value);
return (
<div
className={cx(
"pointer-events-none absolute inset-0 flex flex-col items-center justify-center",
className,
)}
>
<span key={fadeKey} className="animate-number-fade text-display-4-medium text-text-primary tabular-nums">
{formatPercent(animatedValue)}
</span>
{caption && (
<span
key={`caption:${fadeKey}`}
className="animate-number-fade -mt-1 max-w-[120px] truncate text-caption-1-medium text-text-tertiary"
>
{caption}
</span>
)}
</div>
);
}
function InteractiveItem({
active,
dimmed,
onActivate,
onDeactivate,
children,
className,
}: {
active: boolean;
dimmed: boolean;
onActivate: () => void;
onDeactivate: () => void;
children: ReactNode;
className?: string;
}) {
return (
<button
type="button"
data-active={active || undefined}
onMouseEnter={onActivate}
onMouseLeave={onDeactivate}
onFocus={onActivate}
onBlur={onDeactivate}
className={cx(
"text-left outline-none transition-opacity duration-200 ease-out focus-visible:ring-2 focus-visible:ring-border-focus-ring",
dimmed && "opacity-50",
className,
)}
>
{children}
</button>
);
}
function ChartLegend({
items,
activeIndex,
onActiveChange,
}: {
items: Array<{ label: string; value: string; color: string }>;
activeIndex: number | null;
onActiveChange: (index: number | null) => void;
}) {
return (
<div className="flex w-full flex-wrap items-center justify-center gap-x-4 gap-y-1 pb-1">
{items.map((item, index) => (
<InteractiveItem
key={item.label}
active={activeIndex === index}
dimmed={activeIndex !== null && activeIndex !== index}
onActivate={() => onActiveChange(index)}
onDeactivate={() => onActiveChange(null)}
className="flex items-center gap-1.5 rounded-sm"
>
<span className="size-3 shrink-0 rounded-[4px]" style={{ backgroundColor: item.color }} />
<span className="text-body-regular whitespace-nowrap text-text-secondary">{item.label}</span>
<span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">{item.value}</span>
</InteractiveItem>
))}
</div>
);
}
const LAST_ROW_SPAN: Record<number, string> = {
2: "sm:col-span-2",
3: "sm:col-span-3",
6: "sm:col-span-6",
};
function ChartStatTiles({
items,
activeIndex,
onActiveChange,
}: {
items: Array<{ label: string; value: string; color: string; activeColor: string }>;
activeIndex: number | null;
onActiveChange: (index: number | null) => void;
}) {
const remainderByThree = 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 active = activeIndex === index;
const isInLastRow = remainderByThree > 0 && index >= items.length - remainderByThree;
return (
<InteractiveItem
key={`${item.label}-${index}`}
active={active}
dimmed={false}
onActivate={() => onActiveChange(index)}
onDeactivate={() => onActiveChange(null)}
className={cx(
"flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2",
hasOddCount && index === items.length - 1 ? "col-span-2" : "col-span-1",
LAST_ROW_SPAN[isInLastRow ? 6 / remainderByThree : 2],
activeIndex !== null && !active && "opacity-40",
)}
>
<span className="flex min-w-0 max-w-full items-center gap-1.5">
<span
className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out"
style={{ backgroundColor: active ? item.activeColor : item.color }}
/>
<span className="truncate text-body-regular text-text-secondary">{item.label}</span>
</span>
<span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">{item.value}</span>
</InteractiveItem>
);
})}
</div>
);
}
/**
* BoardCN-compatible radial chart card: concentric rings, labelled/grid rings,
* two single-value gauges, and a stacked half gauge from one data contract.
*/
export function RadialChartCard({
variant = "rings",
title = "Visitors",
data,
max,
headline,
delta,
range,
ranges,
defaultRange,
onRangeChange,
format = formatNumber,
centerCaption,
tiles = false,
className,
...props
}: RadialChartCardProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const reduceMotion = useReducedMotion();
const isRingVariant = variant === "rings" || variant === "labels" || variant === "grid";
const isSingleGauge = variant === "gauge" || variant === "solid";
const availableRanges = ranges;
const { selected, selectedId, select } = useChartRange(availableRanges, defaultRange, onRangeChange);
const chartData = selected?.data ?? data;
const selectedHeadline = selected?.headline ?? headline;
const selectedDelta = selected?.delta ?? delta;
const tones = useMemo(
() => chartData.map((item, index) => resolveTone(index, item.color, item.activeColor)),
[chartData],
);
const values = chartData.map((item) => item.value);
const total = values.reduce((sum, value) => sum + value, 0);
const largest = Math.max(1, ...values);
const chartMax =
selected?.max ??
max ??
(isRingVariant
? Math.ceil(largest * 1.1)
: Math.max(1, isSingleGauge ? largest : total));
const hasActiveItem = activeIndex !== null && activeIndex < chartData.length;
const headerLabel = hasActiveItem ? chartData[activeIndex].label : title;
const headerValue = hasActiveItem ? chartData[activeIndex].value : (selectedHeadline ?? total);
const percentOfMax = (value: number) => Math.round((value / Math.max(1, chartMax)) * 100);
const plottedData = useMemo(
() =>
chartData.map((item, index) => ({
...item,
fill: tones[index].color,
index,
})),
[chartData, tones],
);
const [lastAnimatedData, setLastAnimatedData] = useState<RadialChartDatum[] | null>(null);
const shouldAnimate = !reduceMotion && lastAnimatedData !== chartData;
const rememberAnimatedData = useCallback(() => setLastAnimatedData(chartData), [chartData]);
const centerReadout = isSingleGauge
? { value: percentOfMax(chartData[0]?.value ?? 0), caption: centerCaption ?? "of goal" }
: variant === "stacked"
? {
value: percentOfMax(chartData[hasActiveItem ? activeIndex : 0]?.value ?? 0),
caption: centerCaption ?? chartData[hasActiveItem ? activeIndex : 0]?.label ?? "",
}
: null;
const stackedData = useMemo(() => {
const remainder = Math.max(0, chartMax - total);
return [
...plottedData.map((item) => ({ value: item.value, fill: item.fill })),
...(remainder > 0 ? [{ value: remainder, fill: "transparent" }] : []),
];
}, [chartMax, plottedData, total]);
function moveActiveIndex(event: KeyboardEvent<HTMLDivElement>) {
if (
chartData.length === 0 ||
(event.key !== "ArrowLeft" && event.key !== "ArrowRight")
) {
return;
}
event.preventDefault();
const direction = event.key === "ArrowRight" ? 1 : -1;
setActiveIndex((current) => {
const start = current ?? (direction > 0 ? -1 : 0);
return (start + direction + chartData.length) % chartData.length;
});
}
function clearFocus(event: FocusEvent<HTMLDivElement>) {
if (!event.currentTarget.contains(event.relatedTarget)) setActiveIndex(null);
}
const chart =
variant === "stacked" ? (
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={[{ value: 1 }]}
dataKey="value"
cx="50%"
cy="78%"
innerRadius={96}
outerRadius={128}
startAngle={180}
endAngle={0}
cornerRadius={99}
fill="var(--color-chart-track)"
stroke="none"
isAnimationActive={false}
/>
<Pie
data={stackedData}
dataKey="value"
cx="50%"
cy="78%"
innerRadius={96}
outerRadius={128}
startAngle={180}
endAngle={0}
cornerRadius={99}
paddingAngle={3}
stroke="none"
onMouseEnter={(_, index) => setActiveIndex(index < plottedData.length ? index : null)}
onMouseLeave={() => setActiveIndex(null)}
isAnimationActive={!reduceMotion}
animationDuration={450}
>
{stackedData.map((item, index) => {
const isRealSegment = index < plottedData.length;
const isActive = hasActiveItem && activeIndex === index;
return (
<Cell
key={`${index}-${item.value}`}
fill={isRealSegment && isActive ? tones[index].activeColor : item.fill}
opacity={isRealSegment && hasActiveItem && !isActive ? 0.3 : 1}
className="transition-opacity duration-200 ease-out"
/>
);
})}
</Pie>
</PieChart>
) : (
<RadialBarChart
data={plottedData}
innerRadius={variant === "labels" ? 26 : isSingleGauge ? (variant === "solid" ? 72 : 88) : 44}
outerRadius={isSingleGauge ? 104 : 106}
startAngle={90}
endAngle={-270}
barCategoryGap={variant === "labels" ? "14%" : "22%"}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<PolarAngleAxis type="number" domain={[0, chartMax]} tick={false} axisLine={false} />
{variant === "grid" && (
<PolarGrid
gridType="circle"
stroke="var(--color-chart-cursor)"
strokeWidth={1}
polarAngles={[0, 45, 90, 135, 180, 225, 270, 315]}
/>
)}
{variant === "solid" && (
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
fill="var(--color-background-inner-default)"
polarRadius={[64]}
/>
)}
<RadialBar
dataKey="value"
cornerRadius={99}
background={variant !== "grid" ? { fill: "var(--color-chart-track)" } : undefined}
onMouseEnter={(_, index) => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(null)}
isAnimationActive={shouldAnimate}
animationDuration={450}
onAnimationEnd={rememberAnimatedData}
>
{plottedData.map((item) => (
<Cell
key={item.label}
fill={activeIndex === item.index ? tones[item.index].activeColor : item.fill}
opacity={hasActiveItem && activeIndex !== item.index ? 0.25 : 1}
className="transition-[fill,opacity] duration-200 ease-out"
/>
))}
{variant === "labels" && (
<LabelList
dataKey="label"
position="insideStart"
fill="var(--color-background-secondary-default)"
fontSize={10}
fontWeight={500}
offset={10}
className="pointer-events-none"
/>
)}
</RadialBar>
</RadialBarChart>
);
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",
variant === "stacked" && !tiles && "max-sm:h-auto max-sm:min-h-[329px]",
className,
)}
{...props}
>
<ChartHeader
label={headerLabel}
value={headerValue}
format={format}
delta={selectedDelta === undefined ? undefined : describeDelta(selectedDelta)}
hovering={hasActiveItem}
fadeKey={`${selectedId ?? ""}:${activeIndex}`}
range={range}
ranges={availableRanges}
rangeId={selectedId}
onRangeChange={(id) => {
setActiveIndex(null);
select(id);
}}
/>
<div
className={cx(
"relative min-h-0 w-full flex-1",
tiles && "h-[231px] flex-none",
variant === "stacked" && !tiles && "max-sm:min-h-[180px]",
)}
onFocus={() => setActiveIndex((current) => current ?? 0)}
onBlur={clearFocus}
onKeyDown={moveActiveIndex}
onMouseLeave={() => setActiveIndex(null)}
role="group"
aria-label={`${title} radial chart. ${chartData.map((item) => `${item.label}: ${format(item.value)}`).join(", ")}`}
>
<ResponsiveContainer width="100%" height="100%">
{chart}
</ResponsiveContainer>
{centerReadout && (
<div
className={
variant === "stacked"
? "pointer-events-none absolute inset-x-0 top-0 h-[78%]"
: "pointer-events-none absolute inset-0"
}
>
<CenterReadout
value={centerReadout.value}
caption={centerReadout.caption}
fadeKey={`${selectedId ?? ""}:${activeIndex}`}
className={variant === "stacked" ? "justify-end" : undefined}
/>
</div>
)}
</div>
{variant === "stacked" && !tiles && (
<ChartLegend
items={plottedData.map((item, index) => ({
label: item.label,
value: format(item.value),
color: tones[index].color,
}))}
activeIndex={hasActiveItem ? activeIndex : null}
onActiveChange={setActiveIndex}
/>
)}
{tiles && (
<ChartStatTiles
items={plottedData.map((item, index) => ({
label: item.label,
value: format(item.value),
color: tones[index].color,
activeColor: tones[index].activeColor,
}))}
activeIndex={hasActiveItem ? activeIndex : null}
onActiveChange={setActiveIndex}
/>
)}
</section>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
RadialChartCard
BoardCN-compatible radial chart card: concentric rings, labelled/grid rings, two single-value gauges, and a stacked half gauge from one data contract.
| Prop | Type | Default | Description |
|---|---|---|---|
| datarequired | RadialChartDatum[] | — | — |
| centerCaption | string | — | — |
| defaultRange | string | — | — |
| delta | number | — | Decimal change, for example 0.052 renders as +5.2%. |
| format | (value: number) => string | (value: number) => value.toLocaleString("en-US") | — |
| headline | number | — | Resting header value. Defaults to the sum of the active data. |
| max | number | — | Full-circle value. Ring variants default to 110% of the largest item. |
| onRangeChange | (id: string) => void | — | — |
| range | string | — | Static period label. |
| ranges | RadialChartRange[] | — | — |
| tiles | boolean | false | Adds linked stat tiles below the plot and lets the card grow to fit. |
| title | string | Visitors | — |
| variant | "rings" | "labels" | "grid" | "gauge" | "solid" | "stacked" | rings | — |