Lunia DS

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.

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-rings

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
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.
valuestringundefinedKey of the shown day (controlled).
defaultValuestringlast day with dataKey of the day shown first (uncontrolled).
localesstring | string[]"en-US"Locale for figures and percents.
EventDescription
valueChangeFires 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
ARG1card first scrolls into view (10% above the bottom edge), or another day is pickedbig rings, first lapstroke-dashoffset (sweep clockwise from the top, outer ring first)0 (or old share) → share of goal, up to one lapduration-6xl (900)ease-outdelay 120 x ringResizes at once
ARG2card first scrolls into view, or another day is pickedbig rings, second lapstroke-dashoffset0 (or old share above the goal) → share above the goal, up to one more lapduration-6xl (900)ease-outdelay 120 x ringResizes at once
ARG3first or second lap starts or ends at 0lap arcopacity0 → 1duration-lg (320)ease-outShows at once
ARG4second lap passes 2% of a laplap dotopacity (shows after the sweep is nearly done)0 → 1duration-lg (320)ease-outdelay 900 + 120 x ringShows at once
ARG5card first scrolls into viewsmall rings in the week pickerstroke-dashoffset (sweep, one day after another)0 → share of goalduration-6xl (900)ease-outdelay 40 x dayResizes at once
ARG6another day is pickedday titletext morphold day name → new day nameduration-3xl (460)ease-outSwaps at once
ARG7card first scrolls into view, or another day is pickedlegend value + percentnumber roll0 (or old value) → day value and share of goalduration-6xl (900)ease-outSwaps at once
ARG8pointer enters a legend row or leaves the ring areaother big ringsopacity1 → 0.3duration-lg (320)ease-outSwaps at once
ARG9pointer enters a legend row or leaves the ring areaother legend rowsopacity1 → 0.45duration-lg (320)ease-outSwaps at once
ARG10a day is picked, or hoverday buttonbackground-colortransparent → bg-quaternary (selected) or bg-tertiary (hover)duration-lg (320)ease-outSwaps 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.