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.

Activity Rings Card 01

A compact view of progress toward three daily goals.

Installation

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

$ pnpm dlx shadcn@latest add @cobalt/activity-rings-card-01

Pass three rings with a label, a formatted value, and a progress fraction between 0 and 1. Hover a ring or focus its tile to highlight it. The rings draw in and the values count up on mount, then stay still when reduced motion is requested.

Source

"use client";

import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Reveal } from "@/components/ui/reveal";

export interface ActivityRing {
  label: string;
  value: string;
  /** Progress as a fraction of the goal, from 0 to 1. */
  progress: number;
}

const colors = [
  "var(--activity-ring-1, var(--chart-3))",
  "var(--activity-ring-2, var(--chart-2))",
  "var(--activity-ring-3, var(--chart-4))",
];

function clampProgress(value: number) {
  if (!Number.isFinite(value)) {
    return 0;
  }
  return Math.min(1, Math.max(0, value));
}

interface ParsedValue {
  decimals: number;
  grouped: boolean;
  numeric: number;
  suffix: string;
}

// Keeps the caller's formatted value, for example "1,592 cal" or "3.8 km",
// and animates only the number in front of the unit.
function parseValue(value: string): ParsedValue | null {
  const match = /^(-?\d[\d,]*(?:\.\d+)?)(.*)$/.exec(value.trim());
  if (!match) {
    return null;
  }
  const [, number, suffix] = match;
  const numeric = Number(number.replaceAll(",", ""));
  if (!Number.isFinite(numeric)) {
    return null;
  }
  return {
    decimals: number.includes(".") ? number.split(".")[1].length : 0,
    grouped: number.includes(","),
    numeric,
    suffix,
  };
}

function formatValue(parsed: ParsedValue, number: number) {
  const fixed = number.toFixed(parsed.decimals);
  if (!parsed.grouped) {
    return `${fixed}${parsed.suffix}`;
  }
  const [whole, fraction] = fixed.split(".");
  const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  return fraction
    ? `${grouped}.${fraction}${parsed.suffix}`
    : `${grouped}${parsed.suffix}`;
}

/** The value of one tile. It rolls up from zero along with the ring (450ms). */
function RingValue({ value }: { value: string }) {
  const parsed = parseValue(value);
  const ref = useCountUp<HTMLSpanElement>(parsed?.numeric ?? 0, {
    format: (number) => (parsed ? formatValue(parsed, number) : value),
    duration: 450,
    precision: parsed?.decimals ?? 0,
    initial: 0,
  });
  return (
    <span
      ref={ref}
      aria-hidden="true"
      className="max-w-full truncate font-medium tabular-nums"
    >
      {parsed ? formatValue(parsed, parsed.numeric) : value}
    </span>
  );
}

export function ActivityRingsCard({
  title = "Daily goals",
  rings,
  className,
}: {
  title?: string;
  rings: [ActivityRing, ActivityRing, ActivityRing];
  className?: string;
}) {
  const [active, setActive] = React.useState<number | null>(null);

  return (
    <Reveal
      variant="card"
      render={
        <Card
          radius={20}
          role="group"
          aria-label={title}
          variant="muted"
          size="xs"
          className={cn("w-full max-w-md", className)}
        />
      }
    >
      <CardHeader>
        <CardTitle variant="secondary">{title}</CardTitle>
      </CardHeader>
      <CardContent className="grid grid-cols-3 gap-2">
        {rings.map((ring, index) => {
          return (
            <Card
              key={ring.label}
              variant="raised"
              size="xs"
              radius={10}
              // oxlint-disable-next-line shadcn/no-restyle -- reference: legend tile is 8px tall padding, 56px high (ref 57)
              className="py-2"
              onMouseEnter={() => setActive(index)}
              onMouseLeave={() => setActive(null)}
            >
              <CardContent>
                <Button
                  variant="unstyled"
                  radius={4}
                  onFocus={() => setActive(index)}
                  onBlur={() => setActive(null)}
                  className="w-full flex-col items-start justify-start text-left"
                >
                  <span className="flex w-full min-w-0 items-center gap-1.5 font-normal text-muted-foreground">
                    <svg
                      viewBox="0 0 12 12"
                      aria-hidden="true"
                      className="size-3 shrink-0"
                    >
                      <rect
                        width="12"
                        height="12"
                        rx="4"
                        fill={colors[index]}
                      />
                    </svg>
                    <span className="min-w-0 truncate">{ring.label}</span>
                  </span>
                  <RingValue value={ring.value} />
                  <span className="sr-only">{ring.value}</span>
                </Button>
              </CardContent>
            </Card>
          );
        })}
      </CardContent>
      <div className="mt-3.5 flex justify-center pb-2">
        <svg viewBox="0 0 200 200" className="size-45.5" aria-hidden="true">
          {rings.map((ring, index) => {
            const progress = clampProgress(ring.progress);
            const arc = progress * 100;
            const radius = 90 - index * 26;
            return (
              <g
                key={ring.label}
                onMouseEnter={() => setActive(index)}
                onMouseLeave={() => setActive(null)}
                className={cn(
                  "transition-opacity duration-150 motion-reduce:transition-none",
                  active === null || active === index
                    ? "opacity-100"
                    : "opacity-35"
                )}
              >
                <circle
                  cx="100"
                  cy="100"
                  r={radius}
                  fill="none"
                  stroke={colors[index]}
                  strokeOpacity="0.16"
                  strokeWidth="20"
                />
                {arc > 0 && (
                  <circle
                    cx="100"
                    cy="100"
                    r={radius}
                    fill="none"
                    stroke={colors[index]}
                    strokeWidth="20"
                    strokeLinecap="round"
                    pathLength="100"
                    strokeDasharray={`${arc} 100`}
                    transform="rotate(-90 100 100)"
                    style={
                      {
                        "--arc": arc,
                        "--delay": `${index * 40}ms`,
                      } as React.CSSProperties
                    }
                    className="activity-rings-card-01-draw"
                  />
                )}
              </g>
            );
          })}
        </svg>
      </div>
    </Reveal>
  );
}