Activity Rings

Activity ring dashboard card.

Rings

Concentric activity rings.

Activity

Move
1,592 kcal
Exercise
1h 45m
Running
5.2 km
function ActivityRingsDemo() {
  return <ActivityRingsCard metrics={ACTIVITY_METRICS} />;
}
function ActivityRingsDemo() {
  return <ActivityRingsCard metrics={ACTIVITY_METRICS} />;
}

Installation

npx shadcn@latest add @boardcn/activity-rings-card
npx shadcn@latest add @boardcn/activity-rings-card

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/activity-rings-card.tsx
"use client";

import { useState } from "react";
import { cx } from "@/utils/cx";

export const ACTIVITY_YEAR = 2026;

export const ACTIVITY_MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
] as const;

export type ActivityMonth = number;

export type ActivityDay = {
  /** Zero-based month index (January is 0), matching JavaScript dates. */
  month: ActivityMonth;
  day: number;
};

export type ActivityMetric = {
  label: "Move" | "Exercise" | "Running";
  value: string;
  goalPct: number;
  color: string;
  hoverColor: string;
};

export type ActivityRingsCardProps = {
  /** Resting ring values. Required — pass demo/product metrics from the caller. */
  metrics: readonly ActivityMetric[];
  /** Replaces the resting metrics with deterministic values for this 2026 date. */
  selectedDay?: ActivityDay | null;
  className?: string;
};

export type DayActivity = {
  move: { pct: number; value: string };
  exercise: { pct: number; value: string };
  running: { pct: number; value: string };
};

const RING_RADII = [82, 58, 34] as const;

/** Stable pseudo-random value used by this card and its calendar companion. */
export function activityRingPct(month: number, day: number, series: number): number {
  let hash = 0x9e3779b9 ^ (1000 * month + 10 * day + series);
  hash = Math.imul(hash, 0x9e3779b1);
  hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b);
  hash = Math.imul(hash ^ (hash >>> 13), 0xc2b2ae35);
  hash = (hash ^ (hash >>> 16)) >>> 0;

  const unit = hash / 0x100000000;
  return series === 0 ? 0.18 + unit * unit * 0.77 : 0.3 + unit * 0.66;
}

/** Returns the same deterministic values for a date on every render. */
export function getDayActivity(month: number, day: number): DayActivity {
  const move = activityRingPct(month, day, 0);
  const exercise = activityRingPct(month, day, 1);
  const running = activityRingPct(month, day, 2);
  const exerciseMinutes = Math.round(20 + 130 * exercise);
  const exerciseHours = Math.floor(exerciseMinutes / 60);
  const remainingMinutes = exerciseMinutes % 60;

  return {
    move: {
      pct: move,
      value: `${Math.round(500 + 1500 * move).toLocaleString()} kcal`,
    },
    exercise: {
      pct: exercise,
      value:
        exerciseHours > 0
          ? `${exerciseHours}h ${remainingMinutes}m`
          : `${remainingMinutes}m`,
    },
    running: {
      pct: running,
      value: `${(1 + 5.5 * running).toFixed(1)} km`,
    },
  };
}

export function ActivityRingsCard({
  metrics: restingMetrics,
  selectedDay = null,
  className,
}: ActivityRingsCardProps) {
  const [activeRing, setActiveRing] = useState<ActivityMetric["label"] | null>(null);
  const activity = selectedDay
    ? getDayActivity(selectedDay.month, selectedDay.day)
    : null;

  const byLabel = activity
    ? {
        Move: activity.move,
        Exercise: activity.exercise,
        Running: activity.running,
      }
    : null;

  const metrics: readonly ActivityMetric[] = byLabel
    ? restingMetrics.map((metric) => ({
        ...metric,
        value: byLabel[metric.label].value,
        goalPct: Math.round(byLabel[metric.label].pct * 100),
      }))
    : restingMetrics;

  return (
    <section
      className={cx(
        "flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
        className,
      )}
    >
      <div className="flex w-full flex-col gap-[11px]">
        <p className="px-1.5 pt-1.5 text-body-medium text-text-secondary">
          {selectedDay
            ? `Activity for ${ACTIVITY_MONTHS[selectedDay.month]} ${selectedDay.day}, ${ACTIVITY_YEAR}`
            : "Activity"}
        </p>

        <div className="flex h-[57px] w-full shrink-0 items-stretch gap-2">
          {metrics.map((metric) => (
            <div
              key={metric.label}
              className={cx(
                "flex flex-1 flex-col items-start justify-end gap-px rounded-2lg bg-background-inner-default px-2.5 py-2",
                "transition-opacity duration-200 ease-out motion-reduce:transition-none",
                activeRing !== null && activeRing !== metric.label && "opacity-50",
              )}
            >
              <div className="flex items-center gap-1.5">
                <span
                  className="size-3 shrink-0 rounded-[4px]"
                  style={{ backgroundColor: metric.color }}
                  aria-hidden="true"
                />
                <span className="text-body-regular whitespace-nowrap text-text-secondary">
                  {metric.label}
                </span>
              </div>
              <span className="text-body-medium whitespace-nowrap text-text-primary">
                {metric.value}
              </span>
            </div>
          ))}
        </div>
      </div>

      <div className="flex min-h-0 w-full flex-1 items-center justify-center">
        <svg
          viewBox="0 0 200 200"
          className="h-full max-h-[210px] w-full overflow-visible"
          role="img"
          aria-label="Move, exercise, and running activity progress"
          onMouseLeave={() => setActiveRing(null)}
        >
          {metrics.map((metric, index) => {
            const isDimmed = activeRing !== null && activeRing !== metric.label;

            return (
              <g
                key={metric.label}
                className="cursor-pointer"
                transform="rotate(-90 100 100)"
                onMouseEnter={() => setActiveRing(metric.label)}
              >
                <circle
                  cx={100}
                  cy={100}
                  r={RING_RADII[index]}
                  fill="none"
                  stroke={metric.color}
                  strokeWidth={18}
                  opacity={isDimmed ? 0.06 : 0.16}
                  className="transition-opacity duration-200 ease-out motion-reduce:transition-none"
                />
                <circle
                  cx={100}
                  cy={100}
                  r={RING_RADII[index]}
                  pathLength={100}
                  fill="none"
                  stroke={activeRing === metric.label ? metric.hoverColor : metric.color}
                  strokeWidth={18}
                  strokeLinecap="round"
                  strokeDasharray={`${metric.goalPct} ${100 - metric.goalPct}`}
                  opacity={isDimmed ? 0.5 : 1}
                  className="transition-[stroke,stroke-dasharray,opacity] duration-200 ease-out motion-reduce:transition-none"
                />
              </g>
            );
          })}
        </svg>
      </div>
    </section>
  );
}
"use client";

import { useState } from "react";
import { cx } from "@/utils/cx";

export const ACTIVITY_YEAR = 2026;

export const ACTIVITY_MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
] as const;

export type ActivityMonth = number;

export type ActivityDay = {
  /** Zero-based month index (January is 0), matching JavaScript dates. */
  month: ActivityMonth;
  day: number;
};

export type ActivityMetric = {
  label: "Move" | "Exercise" | "Running";
  value: string;
  goalPct: number;
  color: string;
  hoverColor: string;
};

export type ActivityRingsCardProps = {
  /** Resting ring values. Required — pass demo/product metrics from the caller. */
  metrics: readonly ActivityMetric[];
  /** Replaces the resting metrics with deterministic values for this 2026 date. */
  selectedDay?: ActivityDay | null;
  className?: string;
};

export type DayActivity = {
  move: { pct: number; value: string };
  exercise: { pct: number; value: string };
  running: { pct: number; value: string };
};

const RING_RADII = [82, 58, 34] as const;

/** Stable pseudo-random value used by this card and its calendar companion. */
export function activityRingPct(month: number, day: number, series: number): number {
  let hash = 0x9e3779b9 ^ (1000 * month + 10 * day + series);
  hash = Math.imul(hash, 0x9e3779b1);
  hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b);
  hash = Math.imul(hash ^ (hash >>> 13), 0xc2b2ae35);
  hash = (hash ^ (hash >>> 16)) >>> 0;

  const unit = hash / 0x100000000;
  return series === 0 ? 0.18 + unit * unit * 0.77 : 0.3 + unit * 0.66;
}

/** Returns the same deterministic values for a date on every render. */
export function getDayActivity(month: number, day: number): DayActivity {
  const move = activityRingPct(month, day, 0);
  const exercise = activityRingPct(month, day, 1);
  const running = activityRingPct(month, day, 2);
  const exerciseMinutes = Math.round(20 + 130 * exercise);
  const exerciseHours = Math.floor(exerciseMinutes / 60);
  const remainingMinutes = exerciseMinutes % 60;

  return {
    move: {
      pct: move,
      value: `${Math.round(500 + 1500 * move).toLocaleString()} kcal`,
    },
    exercise: {
      pct: exercise,
      value:
        exerciseHours > 0
          ? `${exerciseHours}h ${remainingMinutes}m`
          : `${remainingMinutes}m`,
    },
    running: {
      pct: running,
      value: `${(1 + 5.5 * running).toFixed(1)} km`,
    },
  };
}

export function ActivityRingsCard({
  metrics: restingMetrics,
  selectedDay = null,
  className,
}: ActivityRingsCardProps) {
  const [activeRing, setActiveRing] = useState<ActivityMetric["label"] | null>(null);
  const activity = selectedDay
    ? getDayActivity(selectedDay.month, selectedDay.day)
    : null;

  const byLabel = activity
    ? {
        Move: activity.move,
        Exercise: activity.exercise,
        Running: activity.running,
      }
    : null;

  const metrics: readonly ActivityMetric[] = byLabel
    ? restingMetrics.map((metric) => ({
        ...metric,
        value: byLabel[metric.label].value,
        goalPct: Math.round(byLabel[metric.label].pct * 100),
      }))
    : restingMetrics;

  return (
    <section
      className={cx(
        "flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
        className,
      )}
    >
      <div className="flex w-full flex-col gap-[11px]">
        <p className="px-1.5 pt-1.5 text-body-medium text-text-secondary">
          {selectedDay
            ? `Activity for ${ACTIVITY_MONTHS[selectedDay.month]} ${selectedDay.day}, ${ACTIVITY_YEAR}`
            : "Activity"}
        </p>

        <div className="flex h-[57px] w-full shrink-0 items-stretch gap-2">
          {metrics.map((metric) => (
            <div
              key={metric.label}
              className={cx(
                "flex flex-1 flex-col items-start justify-end gap-px rounded-2lg bg-background-inner-default px-2.5 py-2",
                "transition-opacity duration-200 ease-out motion-reduce:transition-none",
                activeRing !== null && activeRing !== metric.label && "opacity-50",
              )}
            >
              <div className="flex items-center gap-1.5">
                <span
                  className="size-3 shrink-0 rounded-[4px]"
                  style={{ backgroundColor: metric.color }}
                  aria-hidden="true"
                />
                <span className="text-body-regular whitespace-nowrap text-text-secondary">
                  {metric.label}
                </span>
              </div>
              <span className="text-body-medium whitespace-nowrap text-text-primary">
                {metric.value}
              </span>
            </div>
          ))}
        </div>
      </div>

      <div className="flex min-h-0 w-full flex-1 items-center justify-center">
        <svg
          viewBox="0 0 200 200"
          className="h-full max-h-[210px] w-full overflow-visible"
          role="img"
          aria-label="Move, exercise, and running activity progress"
          onMouseLeave={() => setActiveRing(null)}
        >
          {metrics.map((metric, index) => {
            const isDimmed = activeRing !== null && activeRing !== metric.label;

            return (
              <g
                key={metric.label}
                className="cursor-pointer"
                transform="rotate(-90 100 100)"
                onMouseEnter={() => setActiveRing(metric.label)}
              >
                <circle
                  cx={100}
                  cy={100}
                  r={RING_RADII[index]}
                  fill="none"
                  stroke={metric.color}
                  strokeWidth={18}
                  opacity={isDimmed ? 0.06 : 0.16}
                  className="transition-opacity duration-200 ease-out motion-reduce:transition-none"
                />
                <circle
                  cx={100}
                  cy={100}
                  r={RING_RADII[index]}
                  pathLength={100}
                  fill="none"
                  stroke={activeRing === metric.label ? metric.hoverColor : metric.color}
                  strokeWidth={18}
                  strokeLinecap="round"
                  strokeDasharray={`${metric.goalPct} ${100 - metric.goalPct}`}
                  opacity={isDimmed ? 0.5 : 1}
                  className="transition-[stroke,stroke-dasharray,opacity] duration-200 ease-out motion-reduce:transition-none"
                />
              </g>
            );
          })}
        </svg>
      </div>
    </section>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

ActivityRingsCard

PropTypeDefaultDescription
metricsrequiredreadonly ActivityMetric[]Resting ring values. Required — pass demo/product metrics from the caller.
classNamestring
selectedDayActivityDaynullReplaces the resting metrics with deterministic values for this 2026 date.