Activity Rings
Activity ring dashboard card.
Rings
Concentric activity rings.
Activity
Move
1,592 kcalExercise
1h 45mRunning
5.2 kmfunction ActivityRingsDemo() {
return <ActivityRingsCard metrics={ACTIVITY_METRICS} />;
}function ActivityRingsDemo() {
return <ActivityRingsCard metrics={ACTIVITY_METRICS} />;
}Installation
npx shadcn@latest add @boardcn/activity-rings-cardnpx shadcn@latest add @boardcn/activity-rings-cardBoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| metricsrequired | readonly ActivityMetric[] | — | Resting ring values. Required — pass demo/product metrics from the caller. |
| className | string | — | — |
| selectedDay | ActivityDay | null | Replaces the resting metrics with deterministic values for this 2026 date. |