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.json
npx shadcn@latest add https://boardcn.dev/r/radial-chart-card.json

npm 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.

components/charts/radial-chart-card.tsx
"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.

PropTypeDefaultDescription
datarequiredRadialChartDatum[]
centerCaptionstring
defaultRangestring
deltanumberDecimal change, for example 0.052 renders as +5.2%.
format(value: number) => string(value: number) => value.toLocaleString("en-US")
headlinenumberResting header value. Defaults to the sum of the active data.
maxnumberFull-circle value. Ring variants default to 110% of the largest item.
onRangeChange(id: string) => void
rangestringStatic period label.
rangesRadialChartRange[]
tilesbooleanfalseAdds linked stat tiles below the plot and lets the card grow to fit.
titlestringVisitors
variant"rings" | "labels" | "grid" | "gauge" | "solid" | "stacked"rings