Lunia DS

Base components

Progress circle

Progress shown as a ring or a half ring with the value in the middle, where the arc sweeps to each new value and the number rolls along with it.

Loading preview

The five sizes as rings at 40%, the same as half rings, and a storage card where the ring goes from 40% to 72% with the caption "Storage used". Replay sweeps every arc again from 0.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add progress-circle

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { ProgressCircle } from './progress-circle';
import type { ProgressCircleSize } from './progress-circle-variants';

const SIZES: ProgressCircleSize[] = ['xxs', 'xs', 'sm', 'md', 'lg'];

export default function ProgressCircleDemo({ replay = 0 }: { replay?: number }) {
  const [storage, setStorage] = React.useState(72);

  React.useEffect(() => {
    if (replay === 0) return;
    setStorage(40);
    const timer = window.setTimeout(() => setStorage(72), 1200);
    return () => window.clearTimeout(timer);
  }, [replay]);

  return (
    // The key remounts the circles on Replay, so every arc sweeps again from 0.
    <div key={replay} className="flex flex-col items-center gap-10">
      <div className="flex flex-wrap items-center justify-center gap-6">
        {SIZES.map((size) => (
          <ProgressCircle
            key={size}
            size={size}
            value={40}
            caption={size === 'xxs' ? 'Users' : 'Active users'}
            aria-label="Active users"
          />
        ))}
      </div>
      <div className="flex flex-wrap items-end justify-center gap-6">
        {SIZES.map((size) => (
          <ProgressCircle
            key={size}
            size={size}
            shape="half-circle"
            value={40}
            caption={size === 'xxs' ? 'Users' : 'Active users'}
            aria-label="Active users"
          />
        ))}
      </div>
      <div className="flex items-center gap-5 rounded-xl border border-secondary bg-primary p-5">
        <ProgressCircle size="xxs" value={storage} aria-labelledby="storage-title" />
        <div className="flex flex-col">
          <span id="storage-title" className="text-sm font-semibold text-primary">
            Storage used
          </span>
          <span className="text-sm text-tertiary">7.2 GB of 10 GB</span>
        </div>
      </div>
    </div>
  );
}

API

Parts: root, track (full ring or top half), arc (round caps), value label (optional caption above the number), under the ring at xxs.

PropTypeDefaultDescription
valuenumber0Progress from min to max.
minnumber0Lowest value.
maxnumber100Highest value.
size"xxs" | "xs" | "sm" | "md" | "lg""sm"64, 160, 200, 240 or 280px. xxs puts the label under the ring.
shape"circle" | "half-circle""circle"Full ring, or an arc over the top that fills from left to right.
showLabelbooleantrueShows the value in the middle (or under the ring at xxs).
captionstringundefinedSmall line above the number (Figma), for example "Active users".
format(value: number, max: number) => stringpercent, for example "40%"Text of the value label and of aria-valuetext.
animateOnMountbooleantrueSweeps the arc from 0 to the first value when the circle first shows.
SlotDescription
labelCustom content in the middle, replaces the number and caption.

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
PCR1value changesarcstroke-dashoffset (the arc length)old value → new valueduration-5xl (640)ease-outResizes at once
PCR2value changesvalue label numbernumber roll (number-roll)old percent → new percentduration-5xl (640)ease-outSwaps at once
PCR3first time the circle is in view (animateOnMount)arc, labelsweep and number roll from 0 (as PCR1 and PCR2)0 → valueduration-5xl (640)ease-outShows at once

Accessibility

  • Built on a progress primitive: role progressbar with aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext from format.
  • Needs a name: aria-label or aria-labelledby, for example the caption.
  • The SVG is aria-hidden; the rolling number is aria-hidden during the roll and screen readers read aria-valuetext.
  • Reduced motion: the arc and the number jump to the new value.