For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Radar Chart Card 01

Compare values across dimensions in a compact radar chart.

Usage

Provide an array of labeled data points and series keys. Choose filled, dots, lines, or score with the variant prop. Set tiles to show values beneath the chart. Pass ranges and onRangeChange to enable the period menu; use popupClassName when the menu needs a scoped theme.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/radar-chart-card-01

Source

"use client";

import { RiCalendarLine, RiArrowDownSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Reveal } from "@/components/ui/reveal";

export interface RadarPoint {
  label: string;
  [key: string]: string | number;
}
export interface RadarSeries {
  key: string;
  label: string;
  color?: string;
}
export interface RadarRange {
  id: string;
  label: string;
  data: RadarPoint[];
  headline?: number;
  delta?: number;
}

const value = (point: RadarPoint, key: string) =>
  typeof point[key] === "number" ? Math.max(0, point[key] as number) : 0;

const DEFAULT_SERIES: RadarSeries[] = [{ key: "value", label: "Total" }];

const COLORS = [
  "var(--revenue-chart-active, var(--chart-1))",
  "var(--primary)",
  "var(--chart-3)",
];

const coord = (count: number, index: number, radius: number) => {
  const angle = (index * 2 * Math.PI) / count - Math.PI / 2;
  return [
    150 + Math.cos(angle) * radius,
    150 + Math.sin(angle) * radius,
  ] as const;
};

const easeOut = (t: number) => 1 - (1 - t) ** 3;

// Tweens every vertex radius toward its target when the data changes
// (period switch), over 450ms. The first render and reduced motion show the
// target as is.
function useTween(target: number[], duration = 450) {
  const reduced = useReducedMotion();
  const key = target.join(",");
  const values = React.useMemo(
    () => (key ? key.split(",").map(Number) : []),
    [key]
  );
  const [display, setDisplay] = React.useState(values);
  const displayRef = React.useRef(values);
  React.useEffect(() => {
    const from = displayRef.current;
    const still = from.length !== values.length || reduced;
    if (still || from.every((v, i) => v === values[i])) {
      displayRef.current = values;
      setDisplay(values);
      return;
    }
    const start = performance.now();
    let frame = 0;
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / duration);
      const e = easeOut(t);
      const next = values.map((v, i) => from[i] + (v - from[i]) * e);
      displayRef.current = next;
      setDisplay(next);
      if (t < 1) {
        frame = requestAnimationFrame(tick);
      }
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [values, duration, reduced]);
  return display.length === values.length ? display : values;
}

/**
 * The data polygons. The period-switch tween re-renders this component every
 * frame, so it is kept apart from the card: only these shapes update, not the
 * header, menu and tiles around them.
 */
const RadarShapes = React.memo(function RadarShapes({
  points,
  series,
  ceiling,
  variant,
  active,
}: {
  points: RadarPoint[];
  series: RadarSeries[];
  ceiling: number;
  variant: "filled" | "dots" | "lines" | "score";
  active: number | null;
}) {
  const count = points.length;
  const radii = useTween(
    series.flatMap((item) =>
      points.map((point) => Math.min(1, value(point, item.key) / ceiling))
    )
  );
  return (
    <g className="radar-chart-card-01-grow origin-center [transform-box:fill-box]">
      {/* Invisible full-size ring so the grow starts at the chart centre. */}
      <circle cx="150" cy="150" r="82" fill="none" />
      {series.map((item, s) => {
        const color = item.color ?? COLORS[s % COLORS.length];
        const positions = points.map((_, i) =>
          coord(count, i, 82 * (radii[s * count + i] ?? 0))
        );
        const peak = active === null ? undefined : positions[active];
        return (
          <g key={item.key}>
            <polygon
              points={positions.map((p) => p.join(",")).join(" ")}
              fill={variant === "filled" ? color : "none"}
              fillOpacity={0.18}
              stroke={color}
              strokeWidth="2"
              strokeLinejoin="round"
              className="[vector-effect:non-scaling-stroke]"
            />
            {variant === "dots" &&
              positions.map(([x, y], i) => (
                <circle
                  key={points[i].label}
                  cx={x}
                  cy={y}
                  r="3"
                  fill={color}
                />
              ))}
            {peak && (
              <g key={active}>
                <circle
                  cx={peak[0]}
                  cy={peak[1]}
                  r="4"
                  fill={color}
                  className="radar-chart-card-01-pulse origin-center [transform-box:fill-box]"
                />
                <circle
                  cx={peak[0]}
                  cy={peak[1]}
                  r="4"
                  fill={color}
                  strokeWidth="2"
                  className="stroke-card"
                />
              </g>
            )}
          </g>
        );
      })}
    </g>
  );
});

export function RadarChartCard({
  title = "Activity",
  data,
  series = DEFAULT_SERIES,
  variant = "filled",
  tiles = false,
  max,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  alertBelow = 30,
  popupClassName,
  className,
}: {
  title?: string;
  data: RadarPoint[];
  series?: RadarSeries[];
  variant?: "filled" | "dots" | "lines" | "score";
  tiles?: boolean;
  max?: number;
  headline?: number;
  delta?: number;
  range?: string;
  ranges?: RadarRange[];
  defaultRange?: string;
  onRangeChange?: (id: string) => void;
  alertBelow?: number;
  popupClassName?: string;
  className?: string;
}) {
  const [selected, setSelected] = React.useState(
    defaultRange ?? ranges?.[0]?.id
  );
  const [active, setActive] = React.useState<number | null>(null);
  const current = ranges?.find((item) => item.id === selected);
  const points = current?.data ?? data;
  const [primary] = series;
  const ceiling = Math.max(
    1,
    max ?? 0,
    ...points.flatMap((point) => series.map((item) => value(point, item.key)))
  );
  const total =
    current?.headline ??
    headline ??
    points.reduce(
      (sum, point) => sum + (primary ? value(point, primary.key) : 0),
      0
    );
  const change = current?.delta ?? delta;
  const shown =
    active === null
      ? total
      : primary && points[active]
        ? value(points[active], primary.key)
        : total;
  const shownRef = useCountUp<HTMLParagraphElement>(shown);
  const count = points.length;
  const polygon = (radius: number) =>
    points.map((_, i) => coord(count, i, radius).join(",")).join(" ");
  const pickAxis = (event: React.MouseEvent<HTMLDivElement>) => {
    if (count < 3) {
      return;
    }
    const rect = event.currentTarget.getBoundingClientRect();
    const x = ((event.clientX - rect.left) / rect.width) * 300 - 150;
    const y = 30 + ((event.clientY - rect.top) / rect.height) * 240 - 150;
    const turn = (Math.atan2(y, x) + Math.PI / 2 + 2 * Math.PI) % (2 * Math.PI);
    setActive(Math.round(turn / ((2 * Math.PI) / count)) % count);
  };
  const score =
    count && primary
      ? Math.round(
          points.reduce((sum, point) => sum + value(point, primary.key), 0) /
            count
        )
      : 0;
  return (
    <Reveal
      variant="card"
      render={
        <Card
          role="region"
          aria-label={title}
          variant="muted"
          className={cn("w-full min-w-0 overflow-visible", className)}
        />
      }
    >
      <CardHeader className="@container flex min-w-0 items-start justify-between">
        <div className="min-w-0">
          <p className="text-sm leading-5 font-medium text-muted-foreground">
            {active === null ? title : points[active]?.label}
          </p>
          {variant !== "score" && (
            <div className="flex items-center gap-2">
              <p
                ref={shownRef}
                className="text-2xl leading-7.5 font-medium tabular-nums"
              >
                {shown.toLocaleString("en-US")}
              </p>
              {change !== undefined && active === null && (
                <Badge variant="label" tone={change >= 0 ? "lime" : "rose"}>
                  {change > 0 ? "+" : ""}
                  {(change * 100).toFixed(1)}%
                </Badge>
              )}
            </div>
          )}
        </div>
        {ranges?.length ? (
          <DropdownMenu>
            <DropdownMenuTrigger
              render={
                <Button
                  variant="outline"
                  size="sm"
                  aria-label={`Range: ${current?.label ?? range ?? "Select range"}`}
                />
              }
            >
              <RiCalendarLine data-icon="inline-start" />
              <span className="hidden @[220px]:inline">
                {current?.label ?? range}
              </span>
              <RiArrowDownSLine
                data-icon="inline-end"
                className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
              />
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end" className={popupClassName}>
              <DropdownMenuGroup>
                {ranges.map((item) => (
                  <DropdownMenuItem
                    key={item.id}
                    onClick={() => {
                      setSelected(item.id);
                      setActive(null);
                      onRangeChange?.(item.id);
                    }}
                  >
                    {item.label}
                  </DropdownMenuItem>
                ))}
              </DropdownMenuGroup>
            </DropdownMenuContent>
          </DropdownMenu>
        ) : range ? (
          <Badge variant="outline">
            <RiCalendarLine data-icon="inline-start" />
            {range}
          </Badge>
        ) : null}
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <div
          className="relative mx-auto w-full max-w-[300px]"
          onMouseMove={pickAxis}
          onMouseLeave={() => setActive(null)}
        >
          <svg
            viewBox="0 30 300 240"
            className="w-full overflow-visible"
            role="img"
            aria-label={`${title} radar chart`}
          >
            {count >= 3 && (
              <g fill="none" stroke="var(--border)" strokeWidth="1">
                {[0.25, 0.5, 0.75, 1].map((step) => (
                  <polygon key={step} points={polygon(82 * step)} />
                ))}
                {points.map((point, i) => {
                  const [x, y] = coord(count, i, 82);
                  return (
                    <line key={point.label} x1="150" y1="150" x2={x} y2={y} />
                  );
                })}
              </g>
            )}
            {count >= 3 && (
              <RadarShapes
                points={points}
                series={series}
                ceiling={ceiling}
                variant={variant}
                active={active}
              />
            )}
            {points.map((point, i) => {
              const [x, y] = coord(count, i, 108);
              const v = primary ? value(point, primary.key) : 0;
              return (
                <g
                  key={point.label}
                  onFocus={() => setActive(i)}
                  onBlur={() => setActive(null)}
                  tabIndex={0}
                  role="img"
                  aria-label={`${point.label}: ${v}`}
                  className="group outline-none"
                  onMouseDown={(event) => event.preventDefault()}
                >
                  <circle
                    cx={x}
                    cy={y}
                    r="24"
                    fill="transparent"
                    strokeWidth="1.5"
                    className="group-focus-visible:stroke-ring"
                  />
                  <text
                    x={x}
                    y={y - (variant === "score" ? 4 : 0)}
                    textAnchor="middle"
                    dominantBaseline="middle"
                    className={cn(
                      "text-xs transition-colors duration-100 ease-out motion-reduce:transition-none",
                      active === i
                        ? "fill-foreground font-medium"
                        : "fill-muted-foreground"
                    )}
                  >
                    {point.label}
                  </text>
                  {variant === "score" && (
                    <text
                      x={x}
                      y={y + 12}
                      textAnchor="middle"
                      className={cn(
                        "text-sm font-semibold",
                        v < alertBelow ? "fill-destructive" : "fill-foreground"
                      )}
                    >
                      {v}
                    </text>
                  )}
                </g>
              );
            })}
            {variant === "score" && (
              <g>
                <circle
                  cx="150"
                  cy="150"
                  r="27"
                  fill="var(--surface-subtle, var(--muted))"
                  stroke="var(--border)"
                />
                <text
                  x="150"
                  y="150"
                  textAnchor="middle"
                  className="fill-foreground text-xl font-semibold"
                >
                  {score}
                </text>
                <text
                  x="150"
                  y="164"
                  textAnchor="middle"
                  className="fill-muted-foreground"
                  fontSize={9}
                >
                  Average
                </text>
              </g>
            )}
          </svg>
        </div>
        {variant === "lines" && (
          <div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
            {series.map((item, i) => (
              <div
                key={item.key}
                className="flex items-center gap-1.5 text-xs text-muted-foreground"
              >
                <span
                  className="size-2.5 rounded-full bg-(--dot)"
                  style={
                    {
                      "--dot": item.color ?? COLORS[i % COLORS.length],
                    } as React.CSSProperties
                  }
                />
                {item.label}{" "}
                <span className="font-semibold text-foreground tabular-nums">
                  {points
                    .reduce((sum, point) => sum + value(point, item.key), 0)
                    .toLocaleString()}
                </span>
              </div>
            ))}
          </div>
        )}
        {tiles && (
          <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
            {points.map((point, i) => (
              <div key={point.label} className="min-w-0">
                <div
                  className={cn(
                    "h-full rounded-[12px] transition-opacity duration-150 ease-out *:outline-none has-focus-visible:ring-2 has-focus-visible:ring-ring motion-reduce:transition-none",
                    active !== null && active !== i && "opacity-40"
                  )}
                >
                  <Card
                    variant="flat"
                    size="sm"
                    tabIndex={0}
                    onMouseDown={(event) => event.preventDefault()}
                    onMouseEnter={() => setActive(i)}
                    onMouseLeave={() => setActive(null)}
                    onFocus={() => setActive(i)}
                    onBlur={() => setActive(null)}
                    className="h-full"
                  >
                    <CardContent className="flex min-w-0 flex-col items-start gap-0">
                      <span className="block w-full text-xs break-words text-muted-foreground">
                        {point.label}
                      </span>
                      <span className="block text-sm font-semibold tabular-nums">
                        {primary
                          ? value(point, primary.key).toLocaleString()
                          : 0}
                      </span>
                    </CardContent>
                  </Card>
                </div>
              </div>
            ))}
          </div>
        )}
      </CardContent>
    </Reveal>
  );
}