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>
    </>
  );
}

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>
  );
}

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>
      }
    />
  );
}

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>
  );
}