Charts
Activity rings
Up to three daily goals as concentric rings over a week of small rings, where the rings sweep round from the top, picking a day sweeps them to that day while the figures roll, and a beaten goal runs a second lap with a dot at its end.
Live preview
Loading preview
Three goals (Move in kcal, Exercise in minutes, Stand in hours) over a week where Thursday beats the exercise goal and the last two days are still to come. On first view the big rings sweep round outer first and the small rings follow one day after another; Replay picks Monday, then Thursday so the second lap and its dot show, then points at the Stand row.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add activity-ringsUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { ActivityRings, type RingDay, type RingGoal } from './activity-rings';
const GOALS: RingGoal[] = [
{ id: 'move', label: 'Move', goal: 600, unit: 'kcal' },
{ id: 'exercise', label: 'Exercise', goal: 30, unit: 'min' },
{ id: 'stand', label: 'Stand', goal: 12, unit: 'h' },
];
const DAYS: RingDay[] = [
{ key: 'mon', label: 'Mon', title: 'Monday, Oct 5', values: [490, 22, 9] },
{ key: 'tue', label: 'Tue', title: 'Tuesday, Oct 6', values: [540, 25, 10] },
{ key: 'wed', label: 'Wed', title: 'Wednesday, Oct 7', values: [420, 18, 12] },
{ key: 'thu', label: 'Thu', title: 'Thursday, Oct 8', values: [570, 39, 8] },
{ key: 'fri', label: 'Fri', title: 'Friday, Oct 9', values: [305, 12, 6] },
{ key: 'sat', label: 'Sat', title: 'Saturday, Oct 10', values: [null, null, null] },
{ key: 'sun', label: 'Sun', title: 'Sunday, Oct 11', values: [null, null, null] },
];
/** Points a pretend pointer at a legend row, so Replay can show the dimming. */
function pointAtGoal(root: HTMLElement | null, index: number | null) {
const rows = root?.querySelectorAll<HTMLElement>('[data-slot="activity-rings"] li');
if (!rows) return;
if (index === null) {
rows[0]?.parentElement?.parentElement?.dispatchEvent(
new PointerEvent('pointerleave', { bubbles: false }),
);
return;
}
rows[index]?.dispatchEvent(new PointerEvent('pointerenter', { bubbles: false }));
}
export default function ActivityRingsDemo({ replay = 0 }: { replay?: number }) {
const [day, setDay] = React.useState('thu');
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (replay === 0) return;
const steps: [number, () => void][] = [
[0, () => setDay('mon')],
[1500, () => setDay('thu')],
[3200, () => pointAtGoal(ref.current, 2)],
[4600, () => pointAtGoal(ref.current, null)],
];
const timers = steps.map(([at, run]) => window.setTimeout(run, at));
return () => {
for (const id of timers) window.clearTimeout(id);
};
}, [replay]);
return (
<div ref={ref} className="w-full max-w-[400px]">
<ActivityRings goals={GOALS} days={DAYS} value={day} onValueChange={setDay} locales="en-US" />
</div>
);
}
API
Parts: card, day title (text morph), big rings (per goal: track, first lap, second lap, lap dot), legend list (per goal: color dot, label, value / goal, percent), week picker (radio group of seven day buttons: small rings + day label).
| Prop | Type | Default | Description |
|---|---|---|---|
| goals | { id: string; label: string; goal: number; format?: Intl.NumberFormatOptions; unit?: string }[] | - | Up to three goals, outermost ring first; more than three are left out. Required. |
| days | { key: string; label: string; title?: string; values: (number | null)[] }[] | - | The week, oldest first, with one value per goal; null marks a day still to come. label goes under the small ring, title is the long name. Required. |
| value | string | undefined | Key of the shown day (controlled). |
| defaultValue | string | last day with data | Key of the day shown first (uncontrolled). |
| locales | string | string[] | "en-US" | Locale for figures and percents. |
| Event | Description |
|---|---|
| valueChange | Fires with the day key when a day with data is picked. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| ARG1 | card first scrolls into view (10% above the bottom edge), or another day is picked | big rings, first lap | stroke-dashoffset (sweep clockwise from the top, outer ring first) | 0 (or old share) → share of goal, up to one lap | duration-6xl (900)ease-outdelay 120 x ring | Resizes at once |
| ARG2 | card first scrolls into view, or another day is picked | big rings, second lap | stroke-dashoffset | 0 (or old share above the goal) → share above the goal, up to one more lap | duration-6xl (900)ease-outdelay 120 x ring | Resizes at once |
| ARG3 | first or second lap starts or ends at 0 | lap arc | opacity | 0 → 1 | duration-lg (320)ease-out | Shows at once |
| ARG4 | second lap passes 2% of a lap | lap dot | opacity (shows after the sweep is nearly done) | 0 → 1 | duration-lg (320)ease-outdelay 900 + 120 x ring | Shows at once |
| ARG5 | card first scrolls into view | small rings in the week picker | stroke-dashoffset (sweep, one day after another) | 0 → share of goal | duration-6xl (900)ease-outdelay 40 x day | Resizes at once |
| ARG6 | another day is picked | day title | text morph | old day name → new day name | duration-3xl (460)ease-out | Swaps at once |
| ARG7 | card first scrolls into view, or another day is picked | legend value + percent | number roll | 0 (or old value) → day value and share of goal | duration-6xl (900)ease-out | Swaps at once |
| ARG8 | pointer enters a legend row or leaves the ring area | other big rings | opacity | 1 → 0.3 | duration-lg (320)ease-out | Swaps at once |
| ARG9 | pointer enters a legend row or leaves the ring area | other legend rows | opacity | 1 → 0.45 | duration-lg (320)ease-out | Swaps at once |
| ARG10 | a day is picked, or hover | day button | background-color | transparent → bg-quaternary (selected) or bg-tertiary (hover) | duration-lg (320)ease-out | Swaps at once |
Accessibility
- The rings are aria-hidden decoration; the legend list is real text with each goal's label, value, goal and percent.
- The week picker is a radio group named "Day"; Left and Right move and pick, Home and End go to the first and the last day with data, and only the selected day is in the tab order.
- Each day button is named with the day and every goal's share, for example "Tuesday, Oct 7: Move 82%, Exercise 104%, Stand 67%".
- Days still to come keep aria-disabled, are named "<day>, no data yet", are skipped by the arrow keys and draw at 35% opacity.
- The day title is a polite live region, so picking a day announces it.
- A met goal shows its percent in text-primary and medium weight as well as the second lap, so it does not depend on the ring alone.
- Reduced motion: rings take their share at once, the lap dot shows at once, numbers and the title swap at once.