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:
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.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.
# 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
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.
// 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;
Now, let's use this component in our App.jsx.
// 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;
You'll need some basic CSS to center the content. In src/index.css or src/App.css:
/* 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); }
How it works
- SVG Container: We create an
<svg>element. The-rotate-90class 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). - Radius & Circumference: We calculate the circle's
circumference. This is the total length of the stroke. - Background Circle: This is a simple, full circle that acts as the track.
- 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. Whenprogressis 0, the offset is the fullcircumference, so the visible part (the dash) is completely hidden. Whenprogressis 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.
// 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;
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'smotioncomponent, which can be prefixed to any HTML or SVG element to make it animatable.variants: We define two states for our animation:initialandanimate.initial: ThestrokeDashoffsetis set to the fullcircumference, making the ring empty at the start.animate: ThestrokeDashoffsetanimates to the value determined by ourprogressprop.
transition: We define the animation's behavior, setting adurationof 1.5 seconds and aneaseInOuteasing 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.
// 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',
},
];
Now, create the dashboard component src/ActivityRingsDashboard.jsx.
// 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;
Finally, update App.jsx to use the new dashboard.
// 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;
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
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;
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-motionadds 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
progressprop 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
- Framer Motion Documentation: https://www.framer.com/motion/
- MDN Docs for SVG
<circle>: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle - MDN Docs for
stroke-dasharray: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray