WellAlly Logo
WellAlly康心伴
Development

Recreating Apple's Iconic Activity Rings with React, SVG, and Framer Motion

Learn to build Apple's iconic activity rings from scratch. This tutorial guides you through using React, SVG, and Framer Motion to create stunning, data-driven UI animations.

W
2025-12-11
8 min read

The Apple Watch's activity rings are a masterclass in data visualization—simple, elegant, and instantly understandable. They motivate users by turning fitness goals into a satisfying game of "closing your rings." For developers, they represent a fascinating UI challenge. How do you build such a fluid, visually appealing element for the web?

In this tutorial, we'll recreate Apple's iconic activity rings from scratch. We'll dive deep into the power of SVG, control it with React state, and bring it to life with the butter-smooth animations of Framer Motion. This isn't just about cloning a design; it's about understanding the core principles of SVG manipulation and modern web animation.

What we'll build: A set of three nested, animated activity rings that respond to data changes, just like the real thing.

Prerequisites:

  • Basic understanding of React and React Hooks.
  • Node.js and npm/yarn installed on your machine.
  • A passion for building beautiful user interfaces. ✨

Why this matters to developers: This project is a perfect way to level up your front-end skills. You'll move beyond basic CSS to master powerful SVG attributes and learn how a professional-grade animation library like Framer Motion can simplify complex animations, making your UIs feel more dynamic and responsive.

Understanding the Problem

At its core, an activity ring is a circular progress bar. The main technical challenge is drawing a partial circle that corresponds to a percentage value (e.g., 75% complete).

The magic lies in two SVG properties for strokes:

  1. stroke-dasharray: This property turns a solid line into a dashed line. You can control the length of the dashes and the gaps between them.
  2. stroke-dashoffset: This property defines where along the path the dashing starts.

By setting the stroke-dasharray to the exact circumference of our circle, we create a "dash" that is the full length of the circle, with an equally long "gap". We can then animate the stroke-dashoffset to reveal the circle, creating the illusion of it being drawn. Framer Motion simplifies this process immensely, allowing us to animate a pathLength property from 0 to 1.

Our approach is to layer two SVG <circle> elements for each ring:

  • A background ring: A semi-transparent, static circle that acts as a track.
  • A foreground ring: The colored, animated circle that represents the actual progress.

We'll stack three of these ring pairs concentrically to replicate the final design.

Prerequisites

Before we start coding, let's set up our development environment.

  • Node.js: v16 or later
  • React: v18 or later
  • Framer Motion: The animation library we'll be using.

Let's create a new React project and install the necessary dependencies.

code
# Create a new React app with Vite (recommended for speed)
npm create vite@latest apple-rings-tutorial -- --template react

# Navigate into the project directory
cd apple-rings-tutorial

# Install Framer Motion
npm install framer-motion

# Start the development server
npm run dev
Code collapsed

After running npm run dev, you should see the default Vite React page at http://localhost:5173.

Step 1: Building a Single Progress Ring

Let's start by creating a single, reusable ring component. This component will be the foundation for all three activity rings.

What we're doing

We'll create a new component called ActivityRing.js. This component will take a progress prop (a number between 0 and 1) and render an SVG circle that visually represents that progress.

Implementation

Create a new file src/ActivityRing.jsx.

code
// src/ActivityRing.jsx
import React from 'react';
import { motion } from 'framer-motion';

const ActivityRing = ({ progress = 0, radius = 50, strokeWidth = 10 }) => {
  const innerRadius = radius - strokeWidth / 2;
  const circumference = 2 * Math.PI * innerRadius;

  return (
    <svg width={radius * 2} height={radius * 2} className="transform -rotate-90">
      {/* Background Ring */}
      <circle
        cx={radius}
        cy={radius}
        r={innerRadius}
        fill="transparent"
        stroke="#e6e6e6"
        strokeWidth={strokeWidth}
      />
      
      {/* Foreground Ring */}
      <motion.circle
        cx={radius}
        cy={radius}
        r={innerRadius}
        fill="transparent"
        stroke="#f87171" // A default color, we'll make this dynamic later
        strokeWidth={strokeWidth}
        strokeDasharray={circumference}
        strokeDashoffset={circumference * (1 - progress)}
        strokeLinecap="round"
      />
    </svg>
  );
};

export default ActivityRing;
Code collapsed

Now, let's use this component in our App.jsx.

code
// src/App.jsx
import React from 'react';
import ActivityRing from './ActivityRing';
import './App.css';

function App() {
  return (
    <div className="flex items-center justify-center min-h-screen bg-black">
      <ActivityRing progress={0.75} radius={80} strokeWidth={20} />
    </div>
  );
}

export default App;
Code collapsed

You'll need some basic CSS to center the content. In src/index.css or src/App.css:

code
/* Ensure you have Tailwind CSS or basic CSS for centering */
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.flex { display: flex; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.min-h-screen { min-height: 100vh; }
.bg-black { background-color: #000; }
.transform { transform-origin: center; }
.-rotate-90 { transform: rotate(-90deg); }
Code collapsed

How it works

  1. SVG Container: We create an <svg> element. The -rotate-90 class is crucial because, by default, SVG circles start drawing from the 3 o'clock position. We rotate the entire SVG to make it start from the top (12 o'clock).
  2. Radius & Circumference: We calculate the circle's circumference. This is the total length of the stroke.
  3. Background Circle: This is a simple, full circle that acts as the track.
  4. Foreground Circle: This is where the magic happens.
    • strokeDasharray={circumference}: We create a dash pattern where the dash is the length of the entire circle.
    • strokeDashoffset={circumference * (1 - progress)}: We offset the start of the dash. When progress is 0, the offset is the full circumference, so the visible part (the dash) is completely hidden. When progress is 1, the offset is 0, revealing the entire circle.
    • strokeLinecap="round": This gives the ends of the progress bar a rounded, more appealing look, just like Apple's design.

Step 2: Animating the Ring with Framer Motion

A static ring is cool, but an animated one is much better. Let's add Framer Motion to animate the progress.

What we're doing

We'll replace the manually calculated strokeDashoffset with Framer Motion's declarative animation properties. We'll use the initial, animate, and transition props on the motion.circle component. Framer Motion has a special pathLength variant for SVGs that makes this incredibly simple.

Implementation

Let's update src/ActivityRing.jsx.

code
// src/ActivityRing.jsx
import React from 'react';
import { motion } from 'framer-motion';

const ActivityRing = ({ progress = 0, radius = 50, strokeWidth = 10, color = '#f87171' }) => {
  const innerRadius = radius - strokeWidth / 2;
  const circumference = 2 * Math.PI * innerRadius;

  const variants = {
    initial: {
      strokeDashoffset: circumference,
    },
    animate: {
      strokeDashoffset: circumference * (1 - progress),
      transition: {
        duration: 1.5,
        ease: "easeInOut",
      },
    },
  };

  return (
    <svg width={radius * 2} height={radius * 2} className="transform -rotate-90">
      {/* Background Ring */}
      <circle
        cx={radius}
        cy={radius}
        r={innerRadius}
        fill="transparent"
        stroke={color}
        strokeWidth={strokeWidth}
        className="opacity-20"
      />
      
      {/* Foreground Ring */}
      <motion.circle
        cx={radius}
        cy={radius}
        r={innerRadius}
        fill="transparent"
        stroke={color}
        strokeWidth={strokeWidth}
        strokeDasharray={circumference}
        strokeLinecap="round"
        initial="initial"
        animate="animate"
        variants={variants}
      />
    </svg>
  );
};

export default ActivityRing;
Code collapsed

I've also added a color prop and a subtle opacity to the background ring to match Apple's design. Make sure to add the .opacity-20 class to your CSS if you aren't using Tailwind.

How it works

  • motion.circle: We use Framer Motion's motion component, which can be prefixed to any HTML or SVG element to make it animatable.
  • variants: We define two states for our animation: initial and animate.
    • initial: The strokeDashoffset is set to the full circumference, making the ring empty at the start.
    • animate: The strokeDashoffset animates to the value determined by our progress prop.
  • transition: We define the animation's behavior, setting a duration of 1.5 seconds and an easeInOut easing function for a smooth acceleration and deceleration.

Now when the component mounts, you'll see the ring animate beautifully from 0 to 75%!

Step 3: Composing the Three Rings

With our reusable <ActivityRing> component, creating the full set is now just a matter of composition and styling.

What we're doing

We'll create an ActivityRingsDashboard.jsx component that renders three <ActivityRing> components nested within each other. We will use absolute positioning to stack them.

Implementation

First, let's define our rings' data.

code
// src/ringsData.js
export const RINGS_DATA = [
  {
    id: 'move',
    progress: 0.85,
    radius: 100,
    color: '#FF005C',
  },
  {
    id: 'exercise',
    progress: 0.60,
    radius: 75,
    color: '#92FE9D',
  },
  {
    id: 'stand',
    progress: 0.95,
    radius: 50,
    color: '#00C6FF',
  },
];
Code collapsed

Now, create the dashboard component src/ActivityRingsDashboard.jsx.

code
// src/ActivityRingsDashboard.jsx
import React from 'react';
import ActivityRing from './ActivityRing';
import { RINGS_DATA } from './ringsData';

const ActivityRingsDashboard = () => {
  return (
    <div className="relative flex items-center justify-center">
      {RINGS_DATA.map((ring, index) => (
        <div key={ring.id} className="absolute">
          <ActivityRing
            progress={ring.progress}
            radius={ring.radius}
            strokeWidth={20}
            color={ring.color}
          />
        </div>
      ))}
    </div>
  );
};

export default ActivityRingsDashboard;
Code collapsed

Finally, update App.jsx to use the new dashboard.

code
// src/App.jsx
import React, { useState, useEffect } from 'react';
import ActivityRingsDashboard from './ActivityRingsDashboard';
import './App.css';

function App() {
  // Example of how you might update progress dynamically
  const [data, setData] = useState(RINGS_DATA);

  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-900">
      <ActivityRingsDashboard />
    </div>
  );
}

export default App;
Code collapsed

You'll need to add the .relative and .absolute classes to your CSS for the stacking to work.

How it works

We use a parent div with relative positioning and place each ring inside a div with absolute positioning. This stacks them on top of each other, perfectly centered. Because each <ActivityRing> has a different radius, they appear nested. We simply map over our RINGS_DATA array to render each ring with its specific properties.

Putting It All Together

Here is the final code for our main components.

src/ActivityRing.jsx

code
import React from 'react';
import { motion } from 'framer-motion';

const ActivityRing = ({ progress = 0, radius = 50, strokeWidth = 10, color = '#f87171' }) => {
  const innerRadius = radius - strokeWidth / 2;
  const circumference = 2 * Math.PI * innerRadius;

  const variants = {
    initial: { strokeDashoffset: circumference },
    animate: {
      strokeDashoffset: circumference * (1 - progress),
      transition: {
        duration: 1.5,
        delay: 0.2,
        ease: 'easeOut',
      },
    },
  };

  return (
    <svg width={radius * 2} height={radius * 2} className="transform -rotate-90">
      <circle
        cx={radius}
        cy={radius}
        r={innerRadius}
        fill="transparent"
        stroke={color}
        strokeWidth={strokeWidth}
        className="opacity-30"
      />
      <motion.circle
        cx={radius}
        cy={radius}
        r={innerRadius}
        fill="transparent"
        stroke={color}
        strokeWidth={strokeWidth}
        strokeDasharray={circumference}
        strokeLinecap="round"
        initial="initial"
        animate="animate"
        variants={variants}
      />
    </svg>
  );
};

export default ActivityRing;```
**`src/ActivityRingsDashboard.jsx`**
```jsx
import React from 'react';
import ActivityRing from './ActivityRing';
import { RINGS_DATA } from './ringsData';

const ActivityRingsDashboard = () => {
  return (
    <div className="relative w-64 h-64 flex items-center justify-center">
      {RINGS_DATA.map((ring, index) => (
        <div key={ring.id} className="absolute">
          <ActivityRing
            progress={ring.progress}
            radius={ring.radius}
            strokeWidth={20}
            color={ring.color}
          />
        </div>
      ))}
    </div>
  );
};

export default ActivityRingsDashboard;
Code collapsed

Performance Considerations

  • Hardware Acceleration: Framer Motion is highly performant and leverages hardware acceleration for animations where possible, so performance should be excellent.
  • Bundle Size: While framer-motion adds to the bundle size, its power and ease of use often justify the cost. For simpler animations, you could use CSS transitions.
  • Re-renders: Our component will re-render if the progress prop changes. This is expected and efficient. React's diffing algorithm will only update the necessary SVG attributes.

Security Best Practices

As this is a purely presentational component, the primary security considerations are minimal. However, if the color or other props were derived from user input, it would be essential to sanitize them to prevent XSS attacks (e.g., a user injecting malicious code into an SVG attribute). Always validate data from external sources.

Conclusion

We've successfully recreated one of Apple's most iconic UI elements using the power of React, the flexibility of SVG, and the elegance of Framer Motion. We've learned how to manipulate SVG stroke properties to create dynamic circular progress bars and how to compose simple components into a more complex and beautiful data visualization.

This project is a fantastic starting point. You can now take these concepts and apply them to build your own custom data visualizations, loading indicators, or any other circular UI element you can imagine.

Next steps for you:

  • Try adding gradients to the strokes instead of solid colors.
  • Animate the progress value when a button is clicked.
  • Add labels or icons to the center of the rings.

Resources

#

Article Tags

reactfrontenddatavisualizationanimationsvg
W

WellAlly's core development team, comprised of healthcare professionals, software engineers, and UX designers committed to revolutionizing digital health management.

Expertise

Healthcare TechnologySoftware DevelopmentUser ExperienceAI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey

© 2024 康心伴 WellAlly · Professional Health Management