Mantine Rings Progress

Logo

@gfazioli/mantine-rings-progress

A Mantine component that replicates the progress rings of Apple Watch.

Installation

yarn add @gfazioli/mantine-rings-progress

After installation import package styles at the root of your application:

import '@gfazioli/mantine-rings-progress/styles.css';

You can import styles within a layer @layer mantine-rings-progress by importing @gfazioli/mantine-rings-progress/styles.layer.css file.

import '@gfazioli/mantine-rings-progress/styles.layer.css';

Usage

Size
Gap
Thickness
Root color alpha
Transition duration
Stagger delay
Glow
Start angle
import { RingsProgress } from '@gfazioli/mantine-rings-progress';

function Demo() {
  const rings = [
    { value: 20, color: 'cyan' },
    { value: 50, color: 'red' },
    { value: 80, color: '#f90' },
  ];

  return (
    <RingsProgress rings={rings} size={180} transitionDuration={1000} label="" />
  );
}

The RingsProgress component renders multiple concentric RingProgress rings from @mantine/core. Each ring supports color, value, and tooltip props.

The label prop can be a string or a component. Try to change the label on the above configurator. You may use also an emoji 😊 or a custom component.

Per-ring Customization

Each ring can override the global thickness and roundCaps props, allowing you to create rings with different widths. You can also set a custom rootColor per ring to control the background track color independently.

import { RingsProgress } from '@gfazioli/mantine-rings-progress';

function Demo() {
  const rings = [
    { value: 40, color: 'cyan', thickness: 18 },
    { value: 65, color: 'red', thickness: 10 },
    { value: 90, color: '#f90', thickness: 6 },
  ];

  return (
    <RingsProgress size={180} rings={rings} />
  );
}

Entrance Animation

Set animate to enable entrance animation — rings mount with value: 0 and smoothly transition to their target values. Control the speed with transitionDuration (in ms). When animate is enabled the component automatically uses a 1000ms transition if no explicit transitionDuration is set.

Staggered Animation

Use staggerDelay (in ms) to animate rings one after another instead of simultaneously. The outer ring animates first, followed by each inner ring after the specified delay.

import { useState } from 'react';
import { RingsProgress } from '@gfazioli/mantine-rings-progress';
import { ActionIcon, Tooltip } from '@mantine/core';
import { IconRefresh } from '@tabler/icons-react';

function Demo() {
  const [key, setKey] = useState(0);

  const rings = [
    { value: 75, color: 'green' },
    { value: 50, color: 'blue' },
    { value: 90, color: 'orange' },
  ];

  return (
    <>
      <RingsProgress
        key={key}
        size={180}
        rings={rings}
        animate
        staggerDelay={300}
        transitionDuration={1000}
      />
      <Tooltip label="Replay animation">
        <ActionIcon variant="subtle" onClick={() => setKey((k) => k + 1)}>
          <IconRefresh size={16} />
        </ActionIcon>
      </Tooltip>
    </>
  );
}

Animated value changes

By default, animate only controls the entrance animation: rings mount from 0 and reach their target once. After mount, the underlying transition duration drops back to 0 and any change to a ring's value snaps to the new state.

animateValueChanges keeps the transition active after mount so every subsequent value change interpolates smoothly — useful for live dashboards or "Apply changes" flows. The duration is taken from transitionDuration, or 500 ms when transitionDuration is the default 0. prefers-reduced-motion is respected automatically.

If you already pass an explicit transitionDuration greater than 0, value changes will animate with that duration even without animateValueChanges — Mantine's underlying RingProgress reads the same CSS variable. animateValueChanges is most useful when you want smooth value transitions without setting a custom duration.

The demo below renders two RingsProgress side by side bound to the same state — both with animate for the entrance, but only the right one with animateValueChanges. Click "Randomize values" and watch the left snap while the right interpolates.

animate

entrance only — value changes snap

animate + animateValueChanges

entrance + every value change interpolates (500 ms default)

import { RingsProgress } from '@gfazioli/mantine-rings-progress';
import { Button, Group, Stack } from '@mantine/core';
import { useState } from 'react';

function Demo() {
  const [rings, setRings] = useState([
    { value: 30, color: 'red' },
    { value: 60, color: 'green' },
    { value: 90, color: 'cyan' },
  ]);

  const randomize = () =>
    setRings(rings.map((r) => ({ ...r, value: Math.round(Math.random() * 100) })));

  return (
    <Stack align="center">
      <Button onClick={randomize}>Randomize values</Button>
      <Group>
        {/* Entrance animation only — value changes snap */}
        <RingsProgress animate rings={rings} />

        {/* Entrance + smoothly animates each value change (500 ms default) */}
        <RingsProgress animate animateValueChanges rings={rings} />
      </Group>
    </Stack>
  );
}

Gradient Rings

Each ring accepts a gradient prop ({ from, to, deg? }) that paints its stroke with a two-stop linear gradient instead of a solid colour. deg follows the CSS convention ( = bottom→top, 90° = left→right, 180° = top→bottom, default ). When gradient is set, it overrides the ring's color.

<RingsProgress
  rings={[
    { value: 80, color: 'red', gradient: { from: 'red', to: 'orange', deg: 45 } },
    { value: 65, color: 'cyan', gradient: { from: 'indigo', to: 'cyan', deg: 90 } },
  ]}
/>

Internally we inject a <linearGradient> into Mantine's RingProgress SVG and rewrite the foreground circle's stroke to reference it. If you remove the gradient prop the ring falls back to the solid color automatically. Multiple RingsProgress on the same page get unique gradient IDs so they never collide.

import { RingsProgress } from '@gfazioli/mantine-rings-progress';

function Demo() {
  return (
    <RingsProgress
      size={220}
      thickness={16}
      rings={[
        { value: 80, color: 'red', gradient: { from: 'red', to: 'orange', deg: 45 } },
        { value: 65, color: 'cyan', gradient: { from: 'indigo', to: 'cyan', deg: 90 } },
        { value: 90, color: 'pink', gradient: { from: 'pink', to: 'violet', deg: 135 } },
      ]}
    />
  );
}

Glow Effect

Enable a neon-like glow behind rings with the glow prop. Set it to true for a default 6px glow, or pass a number for a custom blur radius. Each ring can override the glow with glowIntensity and glowColor props. Best visible on dark backgrounds.

import { RingsProgress } from '@gfazioli/mantine-rings-progress';

function Demo() {
  const rings = [
    { value: 75, color: 'cyan' },
    { value: 50, color: '#ff6b6b' },
    { value: 90, color: '#ffd43b' },
  ];

  return (
    <RingsProgress size={180} glow={8} rings={rings} />
  );
}

Pulse on Completion

Set pulseOnComplete to trigger a subtle pulse animation when a ring reaches 100%. This provides visual feedback when a goal is achieved — inspired by the Apple Watch ring completion celebration. Drag the sliders to 100% or click the button to see the effect.

You can also use the onRingComplete callback to run custom logic when a ring reaches 100% (e.g., show a notification, play a sound).

Move

72%

Exercise

45%

Stand

88%

import { useState } from 'react';
import { RingsProgress } from '@gfazioli/mantine-rings-progress';
import { Button, Slider, Stack, Text, Group } from '@mantine/core';

function Demo() {
  const [move, setMove] = useState(72);
  const [exercise, setExercise] = useState(45);
  const [stand, setStand] = useState(88);

  const rings = [
    { value: move, color: '#fa5252' },
    { value: exercise, color: '#94d82d' },
    { value: stand, color: '#22b8cf' },
  ];

  return (
    <Stack gap="lg">
      <RingsProgress size={180} rings={rings} glow={4} pulseOnComplete />

      <Group gap="xs">
        <Text size="sm" w={70}>Move</Text>
        <Slider flex={1} color="red" value={move} onChange={setMove} />
      </Group>

      <Group gap="xs">
        <Text size="sm" w={70}>Exercise</Text>
        <Slider flex={1} color="lime" value={exercise} onChange={setExercise} />
      </Group>

      <Group gap="xs">
        <Text size="sm" w={70}>Stand</Text>
        <Slider flex={1} color="cyan" value={stand} onChange={setStand} />
      </Group>

      <Button
        size="xs"
        variant="light"
        onClick={() => { setMove(100); setExercise(100); setStand(100); }}
      >
        Complete all rings
      </Button>
    </Stack>
  );
}

Start Angle & Direction

Use startAngle (in degrees) to change where rings start filling from — 0 is the 12 o'clock position. Set direction to "counterclockwise" to reverse the fill direction.

startAngle=90

counterclockwise

import { RingsProgress } from '@gfazioli/mantine-rings-progress';
import { Group } from '@mantine/core';

function Demo() {
  const rings = [
    { value: 40, color: 'cyan' },
    { value: 65, color: 'red' },
    { value: 90, color: '#f90' },
  ];

  return (
    <Group justify="center" gap="xl">
      <RingsProgress size={140} rings={rings} startAngle={90} />
      <RingsProgress size={140} rings={rings} direction="counterclockwise" />
    </Group>
  );
}

Value Labels

Set showValues to render a label at the endpoint of each ring's arc. Each ring's position is computed from its own value, thickness, startAngle, and direction. Customise the displayed string with formatValue (global) or per-ring formatValue. Per-ring showValue lets you turn the label on or off for individual rings.

<RingsProgress
  showValues
  formatValue={(v) => `${Math.round(v)}%`}
  rings={[
    { value: 75, color: 'red' },
    { value: 50, color: 'green' },
    // Per-ring override:
    { value: 90, color: 'cyan', formatValue: (v) => `${v} / 100` },
  ]}
/>

Style the labels via the valueLabel Styles API selector — for example to add a background pill, or to change the font.

75%
50%
90%
import { RingsProgress } from '@gfazioli/mantine-rings-progress';
import { Button, Stack } from '@mantine/core';
import { useState } from 'react';

function Demo() {
  const [rings, setRings] = useState([
    { value: 75, color: 'red' },
    { value: 50, color: 'green' },
    { value: 90, color: 'cyan' },
  ]);

  const randomize = () =>
    setRings(rings.map((r) => ({ ...r, value: Math.round(Math.random() * 100) })));

  return (
    <Stack align="center">
      <Button onClick={randomize}>Randomize values</Button>
      <RingsProgress
        showValues
        animateValueChanges
        transitionDuration={700}
        formatValue={(v) => `${Math.round(v)}%`}
        rings={rings}
      />
    </Stack>
  );
}

Tooltip

Set withTooltip to display a unified tooltip on hover showing all rings info. Each ring's tooltip prop defines the content — if omitted, the ring value percentage is shown. The tooltip displays a color swatch next to each entry. You can customize the tooltip with tooltipProps.

import { RingsProgress } from '@gfazioli/mantine-rings-progress';

function Demo() {
  const rings = [
    { value: 20, color: 'green', tooltip: 'Fitness – 40 Gb' },
    { value: 80, color: 'blue', tooltip: 'Running – 50 minutes' },
  ];

  return (
    <RingsProgress
      size={140}
      rings={rings}
      withTooltip
      label={
        <ActionIcon color="yellow" variant="filled" radius="xl" size="xl">
          <IconCheck style={{ width: rem(22), height: rem(22) }} />
        </ActionIcon>
      }
    />
  );
}

Interactive rings

Each ring accepts optional onClick and onHover callbacks. When onClick is provided the ring is keyboard-focusable (Enter and Space activate it), the cursor switches to pointer when the ring is under it, and the ring is exposed to assistive tech as a role="button". onHover fires on both pointer enter and leave with a third boolean argument so consumers can react to either edge.

<RingsProgress
  rings={[
    {
      value: 75,
      color: 'blue',
      onClick: (ring, index) => showDetail(index),
      onHover: (ring, index, hovered) => highlight(hovered ? index : null),
    },
  ]}
/>

Internally RingsProgress does geometric hit-testing: it measures the cursor's radial distance from the centre and matches it against each ring's stroke band. This sidesteps the issue that each ring's underlying SVG sits inside a rectangular wrapper that overlaps the inner rings, so a click on the outer ring's curve always lands on the right callback.

Hover a ring to inspect it · click to pin (click again to unpin)

import { RingsProgress, type RingsProgressRing } from '@gfazioli/mantine-rings-progress';
import { Badge, Box, Stack, Text } from '@mantine/core';
import { useState } from 'react';

const ACTIVITIES = [
  { name: 'Move', value: 75, color: 'red', detail: '450 / 600 kcal' },
  { name: 'Exercise', value: 50, color: 'green', detail: '15 / 30 min' },
  { name: 'Stand', value: 90, color: 'cyan', detail: '11 / 12 hours' },
];

function Demo() {
  const [hovered, setHovered] = useState<number | null>(null);
  const [selected, setSelected] = useState<number | null>(null);
  const focused = hovered ?? selected;
  const focusedActivity = focused !== null ? ACTIVITIES[focused] : null;

  const rings: RingsProgressRing[] = ACTIVITIES.map((activity, index) => ({
    value: activity.value,
    color: activity.color,
    glowIntensity: hovered === index ? 12 : 0,
    onClick: () => setSelected((current) => (current === index ? null : index)),
    onHover: (_ring, _i, isHovered) => setHovered(isHovered ? index : null),
  }));

  return (
    <Stack align="center" gap="md" pb="md">
      <Text size="sm" c="dimmed">Hover a ring to inspect it · click to pin</Text>
      <RingsProgress size={200} thickness={14} gap={8} rings={rings} />
      <Box mih={36}>
        {focusedActivity && (
          <Badge color={focusedActivity.color} size="lg" variant="filled">
            {focusedActivity.name} — {focusedActivity.value}% · {focusedActivity.detail}
          </Badge>
        )}
      </Box>
    </Stack>
  );
}

Accessibility

The component renders with role="group" and each ring has role="progressbar" with proper aria-valuenow, aria-valuemin, and aria-valuemax attributes. You can customize the accessible label with aria-label on the component or ariaLabel per-ring.

The component automatically respects prefers-reduced-motion — when enabled, all animations are disabled.

Label

import { RingsProgress } from '@gfazioli/mantine-rings-progress';

function Demo() {
  const rings = [{ value: 20, color: 'green' }];

  return (
    <RingsProgress
      size={100}
      rings={rings}
      label={
        <ActionIcon color="teal" variant="light" radius="xl" size="xl">
          <IconCheck style={{ width: rem(22), height: rem(22) }} />
        </ActionIcon>
      }
    />
  );
}

The label prop can be a string or a component.

import { RingsProgress } from '@gfazioli/mantine-rings-progress';

function Demo() {
  const rings = [
    { value: 20, color: 'green' },
    { value: 80, color: 'blue' },
  ];

  return (
    <RingsProgress
      size={140}
      rings={rings}
      label={
        <ActionIcon color="yellow" variant="filled" radius="xl" size="xl">
          <IconCheck style={{ width: rem(22), height: rem(22) }} />
        </ActionIcon>
      }
    />
  );
}

Example: Countdown

Below, another example using RingsProgress to render a countdown timer.

import { useEffect, useState } from 'react';
import { RingsProgress } from '@gfazioli/mantine-rings-progress';
import {
  ActionIcon,
  Box,
  Button,
  Center,
  Group,
  NumberInput,
  Stack,
  Switch,
  Text,
} from '@mantine/core';

function Demo() {
  const [inputMinutes, setInputMinutes] = useState(10);
  const [timeLeft, setTimeLeft] = useState(inputMinutes * 60 * 100);
  const [isRunning, setIsRunning] = useState(false);
  const [displayHundredthsSeconds, setDisplayHundredthsSeconds] = useState(false);

  useEffect(() => {
    if (!isRunning) return;

    const timer = setInterval(() => {
      setTimeLeft((prevTime) => (prevTime > 0 ? prevTime - 1 : 0));
    }, 10);

    return () => clearInterval(timer);
  }, [isRunning]);

  const handleStartPause = () => {
    setIsRunning((prev) => !prev);
  };

  const handleReset = () => {
    setIsRunning(false);
    setTimeLeft(inputMinutes * 60 * 100);
  };

  const handleInputChange = (value: string | number) => {
    const minutes = typeof value === 'number' ? value : parseInt(value, 10);
    setInputMinutes(isNaN(minutes) ? 0 : minutes);
    setTimeLeft((isNaN(minutes) ? 0 : minutes) * 60 * 100);
  };

  const totalSeconds = Math.floor(timeLeft / 100);
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = totalSeconds % 60;
  const hundredthsSeconds = timeLeft % 100;

  const minutesPercent = (minutes / inputMinutes) * 100;
  const secondsPercent = (seconds / 60) * 100;
  const hundredthsSecondsPercent = (hundredthsSeconds / 100) * 100;

  const rings = [
    { value: minutesPercent, color: 'green' },
    { value: secondsPercent, color: 'blue' },
    ...(displayHundredthsSeconds ? [{ value: hundredthsSecondsPercent, color: 'red' }] : []),
  ];

  return (
    <Stack h={250} align="stretch">
      <Group align="center" justify="center">
        <NumberInput
          size="xs"
          disabled={isRunning}
          value={inputMinutes}
          onChange={handleInputChange}
          placeholder="Minutes"
          min={0}
        />
        <Button size="xs" onClick={handleStartPause} ml={10}>
          {isRunning ? 'Pause' : 'Start'}
        </Button>
        <Button size="xs" onClick={handleReset} ml={10}>
          Reset
        </Button>
        <Switch
          checked={displayHundredthsSeconds}
          onChange={(event) => setDisplayHundredthsSeconds(event.currentTarget.checked)}
          label="Hundredths of Seconds"
        />
      </Group>

      <Center>
        <Box h={200} w={200}>
          <RingsProgress
            size={200}
            thickness={12}
            transitionDuration={0}
            rings={rings}
            label={
              <ActionIcon color="yellow" variant="outline" radius="xl" size={64}>
                <Text size="md">
                  {minutes < 10 ? `0${minutes}` : minutes}:{seconds < 10 ? `0${seconds}` : seconds}
                </Text>
              </ActionIcon>
            }
          />
        </Box>
      </Center>
    </Stack>
  );
}