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.

Radial Chart Card 01

A compact radial chart with optional breakdown tiles.

Usage

Provide a title and an array of { label, value, color? } items. Set max to control the scale, and use variant to choose rings, labels, grid, gauge, solid, or stacked. The tiles option adds an interactive breakdown. Supply ranges for the period menu and popupClassName when the popup needs a scoped theme.

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 { 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 RadialChartItem {
  label: string;
  value: number;
  color?: string;
}
export interface RadialChartRange {
  label: string;
  data: RadialChartItem[];
  max?: number;
  delta?: string;
}
export type RadialChartVariant =
  | "rings"
  | "labels"
  | "grid"
  | "gauge"
  | "solid"
  | "stacked";

const colors = [
  "var(--radial-chart-1, var(--chart-1))",
  "var(--radial-chart-2, var(--chart-2))",
  "var(--radial-chart-3, var(--chart-3))",
  "var(--radial-chart-4, var(--chart-4))",
  "var(--radial-chart-5, var(--chart-5))",
];
const clamp = (n: number) => Math.max(0, Math.min(1, n));
const percent = (value: number) => `${value}%`;

export function RadialChartCard({
  title,
  data,
  max,
  delta,
  ranges,
  format = (value) => value.toLocaleString(),
  variant = "rings",
  tiles = false,
  className,
  popupClassName,
}: {
  title: string;
  data: RadialChartItem[];
  max?: number;
  delta?: string;
  ranges?: RadialChartRange[];
  /** Formats the headline and per-item values, for example as currency. */
  format?: (value: number) => string;
  variant?: RadialChartVariant;
  tiles?: boolean;
  className?: string;
  popupClassName?: string;
}) {
  const [rangeIndex, setRangeIndex] = React.useState(0);
  const [active, setActive] = React.useState<number | null>(null);
  const selected = ranges?.[rangeIndex];
  const items = selected?.data ?? data;
  const total = items.reduce((sum, item) => sum + Math.max(0, item.value), 0);
  const limit = Math.max(
    selected?.max ??
      max ??
      Math.max(...items.map((item) => item.value), 1) * 1.1,
    1
  );
  const change = selected?.delta ?? delta;
  const gauge = variant === "gauge" || variant === "solid";
  const percentage = Math.round(clamp((items[0]?.value ?? 0) / limit) * 100);
  const shown = active === null ? total : (items[active]?.value ?? total);
  const shownRef = useCountUp<HTMLSpanElement>(shown, { format });
  const center =
    variant === "stacked"
      ? total
        ? Math.round(
            (Math.max(0, items[active ?? 0]?.value ?? 0) / total) * 100
          )
        : 0
      : percentage;
  const centerRef = useCountUp<SVGTextElement>(center, {
    format: percent,
    fade: false,
  });
  let stackedOffset = 0;
  // The stacked gauge sweeps 190 degrees of a radius-100 circle, open below.
  const gaugeArc = (190 / 360) * 2 * Math.PI * 88;

  return (
    <Reveal
      variant="card"
      render={
        <Card
          variant="muted"
          className={cn(
            "w-full max-w-[400px]",
            variant === "grid" && "self-start",
            className
          )}
          aria-label={title}
        />
      }
    >
      <CardHeader className="flex min-w-0 items-start justify-between">
        <div className="min-w-0">
          <p className="truncate text-sm leading-5 font-medium text-muted-foreground">
            {active === null ? title : items[active]?.label}
          </p>
          <div className="flex items-center gap-2">
            <span
              ref={shownRef}
              className="text-2xl leading-8.5 font-medium tabular-nums"
            >
              {format(shown)}
            </span>
            {change && (
              <Badge variant="caption" tone="lime">
                {change}
              </Badge>
            )}
          </div>
        </div>
        {ranges && ranges.length > 0 && (
          <DropdownMenu>
            <DropdownMenuTrigger
              render={<Button variant="outline" size="sm" />}
            >
              <RiCalendarLine data-icon="inline-start" />
              {selected?.label}
              <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((range, index) => (
                  <DropdownMenuItem
                    key={range.label}
                    onClick={() => {
                      setRangeIndex(index);
                      setActive(null);
                    }}
                  >
                    {range.label}
                  </DropdownMenuItem>
                ))}
              </DropdownMenuGroup>
            </DropdownMenuContent>
          </DropdownMenu>
        )}
      </CardHeader>
      <CardContent className="-mt-4 gap-0">
        <div
          className={cn(
            "relative mx-auto flex w-full max-w-[240px] items-center justify-center",
            variant === "stacked"
              ? "h-[210px] max-w-[260px] items-start"
              : variant === "grid"
                ? "h-[200px]"
                : "h-[220px]"
          )}
        >
          <svg
            key={rangeIndex}
            viewBox="0 0 200 200"
            className={cn(
              "size-[200px] max-w-full overflow-visible",
              variant === "stacked" && "size-[250px]"
            )}
            role="img"
            aria-label={`${title}: ${items.map((item) => `${item.label} ${item.value}`).join(", ")}`}
          >
            {variant === "stacked" ? (
              <g transform="rotate(175 100 100)">
                <circle
                  cx="100"
                  cy="100"
                  r="88"
                  fill="none"
                  className="stroke-surface-strong"
                  strokeWidth="18"
                  strokeLinecap="round"
                  strokeDasharray={`${gaugeArc} ${2 * Math.PI * 88}`}
                  pointerEvents="none"
                />
                {items.map((item, i) => {
                  const fraction = total ? Math.max(0, item.value) / total : 0;
                  const length = fraction * gaugeArc;
                  const offset = stackedOffset;
                  stackedOffset += length;
                  return (
                    <circle
                      key={item.label}
                      cx="100"
                      cy="100"
                      r="88"
                      fill="none"
                      stroke={item.color ?? colors[i % colors.length]}
                      strokeWidth="18"
                      strokeLinecap="round"
                      strokeDasharray={`${Math.max(0, length - 12)} ${2 * Math.PI * 88}`}
                      strokeDashoffset={-offset - 6}
                      className="radial-chart-card-01-draw transition-opacity duration-150 motion-reduce:transition-none"
                      opacity={active === null || active === i ? 1 : 0.3}
                      onMouseEnter={() => setActive(i)}
                      onMouseLeave={() => setActive(null)}
                    />
                  );
                })}
              </g>
            ) : gauge ? (
              <g transform="rotate(-90 100 100)">
                <circle
                  cx="100"
                  cy="100"
                  r="75"
                  fill="none"
                  className="stroke-(color:--border-subtle,var(--border)) dark:stroke-(color:--border-strong,var(--border))"
                  strokeWidth={variant === "solid" ? 14 : 6}
                />
                <circle
                  cx="100"
                  cy="100"
                  r="75"
                  fill="none"
                  stroke={items[0]?.color ?? colors[0]}
                  strokeWidth={variant === "solid" ? 14 : 6}
                  strokeLinecap="round"
                  strokeDasharray={`${clamp((items[0]?.value ?? 0) / limit) * 2 * Math.PI * 75} ${2 * Math.PI * 75}`}
                  className="radial-chart-card-01-draw transition-all duration-300 motion-reduce:transition-none"
                />
              </g>
            ) : (
              <g transform="rotate(-90 100 100)">
                {variant === "grid" &&
                  [30, 45, 60, 75, 90].map((r) => (
                    <circle
                      key={r}
                      cx="100"
                      cy="100"
                      r={r}
                      fill="none"
                      className="stroke-(color:--border-subtle,var(--border)) dark:stroke-(color:--border-strong,var(--border))"
                      strokeWidth="1"
                      pointerEvents="none"
                    />
                  ))}
                {items.map((item, i) => {
                  const radius =
                    46 + i * Math.min(12.5, 50 / Math.max(items.length - 1, 1));
                  const circumference = 2 * Math.PI * radius;
                  return (
                    <g key={item.label}>
                      {variant !== "grid" && (
                        <circle
                          cx="100"
                          cy="100"
                          r={radius}
                          fill="none"
                          className="stroke-surface-strong"
                          strokeWidth="7"
                          pointerEvents="none"
                        />
                      )}
                      <circle
                        cx="100"
                        cy="100"
                        r={radius}
                        fill="none"
                        stroke={item.color ?? colors[i % colors.length]}
                        strokeWidth={variant === "grid" ? 5 : 6}
                        strokeLinecap="round"
                        strokeDasharray={`${clamp(Math.max(0, item.value) / limit) * circumference} ${circumference}`}
                        opacity={active === null || active === i ? 1 : 0.3}
                        className="cursor-pointer radial-chart-card-01-draw transition-all duration-150 motion-reduce:transition-none"
                        pointerEvents="none"
                      />
                      <circle
                        cx="100"
                        cy="100"
                        r={radius}
                        fill="none"
                        stroke="transparent"
                        strokeWidth="11"
                        strokeLinecap="round"
                        strokeDasharray={`${clamp(Math.max(0, item.value) / limit) * circumference} ${circumference}`}
                        pointerEvents="stroke"
                        className="cursor-pointer"
                        onMouseEnter={() => setActive(i)}
                        onMouseLeave={() => setActive(null)}
                      />
                    </g>
                  );
                })}
              </g>
            )}
            {(gauge || variant === "stacked") && (
              <g textAnchor="middle" fill="currentColor">
                <text
                  ref={centerRef}
                  x="100"
                  y={variant === "stacked" ? "106" : "101"}
                  fontSize={variant === "stacked" ? "32" : "26"}
                  fontWeight={variant === "stacked" ? "500" : "600"}
                >
                  {percent(center)}
                </text>
                {(gauge || variant === "stacked") && (
                  <text
                    x="100"
                    y={variant === "stacked" ? "124" : "117"}
                    fontSize={variant === "stacked" ? "12" : "10"}
                    fill="var(--muted-foreground)"
                  >
                    {variant === "stacked"
                      ? (items[active ?? 0]?.label ?? "")
                      : "of goal"}
                  </text>
                )}
              </g>
            )}
          </svg>
        </div>
        {(tiles || variant === "labels" || variant === "stacked") && (
          <div
            className={cn(
              tiles
                ? "grid grid-cols-6 gap-1"
                : "flex flex-wrap justify-center gap-x-3 gap-y-1"
            )}
          >
            {items.map((item, i) => {
              const dot = (
                <span
                  className="size-2.5 shrink-0 rounded-full bg-(--dot)"
                  style={
                    {
                      "--dot": item.color ?? colors[i % colors.length],
                    } as React.CSSProperties
                  }
                />
              );
              const dim = active !== null && active !== i;
              return tiles ? (
                <div
                  key={item.label}
                  className={cn(
                    "col-span-2 min-w-0 transition-opacity duration-150 last:grow motion-reduce:transition-none",
                    items.length % 3 === 2 &&
                      i >= items.length - 2 &&
                      "col-span-3",
                    dim && "opacity-40"
                  )}
                >
                  <Card
                    variant="flat"
                    size="sm"
                    interactive
                    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="flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground">
                        {dot}
                        <span className="min-w-0 break-all">{item.label}</span>
                      </span>
                      <span className="text-xs font-medium tabular-nums">
                        {format(item.value)}
                      </span>
                    </CardContent>
                  </Card>
                </div>
              ) : (
                <span
                  key={item.label}
                  className={cn(
                    "transition-opacity duration-150 motion-reduce:transition-none",
                    dim && "opacity-40"
                  )}
                >
                  <Button
                    type="button"
                    variant="ghost"
                    size="xs"
                    onMouseEnter={() => setActive(i)}
                    onMouseLeave={() => setActive(null)}
                    onFocus={() => setActive(i)}
                    onBlur={() => setActive(null)}
                  >
                    <span
                      className={cn(
                        "flex min-w-0 items-center gap-1 text-muted-foreground",
                        variant === "stacked" && "text-sm"
                      )}
                    >
                      {dot}
                      <span className="min-w-0 break-all">{item.label}</span>
                      <span className="font-medium text-foreground">
                        {format(item.value)}
                      </span>
                    </span>
                  </Button>
                </span>
              );
            })}
          </div>
        )}
      </CardContent>
    </Reveal>
  );
}