"Ever lost an entire weekend wrestling with a CSS animation that won't to cooperate?"
Yeah, I feel your pain. It's a humbling reminder that even after years in the field, there's always something new to learn. That's precisely why I'm taking a collaborative approach to rebuilding my portfolio – to embrace those learning moments, share them with others, and create something truly exceptional. This time, it all starts with FocusFlow, a Pomodoro-inspired web app. (If you haven't already, check out my previous article over here for some background). Now, let's dive into yesterday's progress!
FocusFlow's Central Feature
Have you ever had this idea inside your head about something that you want to design or build, only to realize that in reality, it appears more complex that it might've seemed initially?
Welcome to my world guys. I mean, I knew without a doubt that having some kind of Circular ProgressBar as the central feature is key to designing and crafting a good Pomodoro app experience. And personally, I care deeply about getting it right. Even though it is only a single page application (SPA).
What seemed like an mini showcase project that would only take 2 days to finish, now seems to require a week, maybe a bit more than that. 🤦
Knowing that it was going to take longer, and I care more about the quality than speed, I finally decided to create a KanBan board so that I could monitor and manage my progress over the course of working on this "mini" showcase project 😂.
As a Day 0 product supporter of Trello's KanBan board, I also secretly wish that the product folks at Atlassian will reach out in time, offer a nice sponsorship, and discuss some form of brand collaboration. 😛
Setting Up the CircularProgressBar (static)
Now, for me to begin setting up the ProgressBar, I first needed to create the SVG circles and set up the stroke values. On top of that, I also had to consider the possibility that this CircularProgressBar can be repurposed and used in other projects that I have, so it would be useful to export it into a dedicated component. So I created the <CircularProgressBar {...other props here} />
component.
<div className="w-[75%] max-w-[400px] aspect-square relative">
<svg className="w-full h-full" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" stroke="#D1D5DC" strokeWidth="10" style={{ opacity: 0.4 }} fill="none" />
<circle
cx="50"
cy="50"
r="45"
stroke={`${activePeriod === 'focus' ? '#3f51b5' : '#F59E0B'}`}
strokeWidth="10"
fill="none"
strokeDasharray="283"
strokeDashoffset={strokeDashoffset}
style={{
transform: 'rotate(-90deg)',
transformOrigin: '50% 50%'
}}
/>
</svg>
</div>
The next part that I had to figure out was the proper calculations based upon the timeLeft
state variable. I used it to divide the totalTime
for each activePeriod
and then multiply that value by the strokeDasharray
value.
const progressPercent = 100 - (timeLeft / totalTime) * 100;
const strokeDashoffset = (283 * progressPercent) / 100;
It took me several tries and some back-and-forth with the AI tool Gemini before I could figure out what I was doing wrong.
The other thing I kinda knew I needed from the get-go was to set a different colour tone for the "break time" period. I've already decided to use the default indigo blue from the default Material theme by KendoReact, so I needed another colour, something that will give off the feelings of relaxation, like watching a sunset.
After brainstorming with Gemini, we finally decided on using #F59E0B
, we named it "Spiced Honey 🍯". Which felt almost cartoon-y if you consider Winnie the Pooh 🐻.
At this stage, the only way that I could test it to see the difference and whether the colour felt right, was to manually change the value before setting up that ternary operation that you see above.
Now that my CircularProgressBar is finally in place, the next step was where I struggled quite a bit at first. I'll get into that in a moment.
Animating the ProgressBar
One of the most important aspects of any mission-critical project is always performing the calculations in real time. Not that this Pomodoro app is mission-critical by any measure 🤪. But it does involve using time as a factor, so that means the application logic has to be precise and correct.
This is where we step into the main app's useEffect()
hook. For the benefit of anyone who isn't a React dev, useEffect()
is usually where you want to retrieve data from a Web API, or perform certain operations that might cause parts of the UI to rerender. In my case, I want the CircularProgressBar to shrink counter-clockwise.
useEffect(() => {
// The user has started the clock, and it's not paused.
if (isRunning && !isPaused && timeLeft > 0) {
// We will perform some timer-related operations here as well.
}
// Handle cycle or timer completion
if (timeLeft == 0) {
if (activePeriodType === 'focus') {
setActivePeriodType('break');
setTimeLeft(breakTimeInSec(focusTime));
// Increment activePeriod if not the last break period
if (activePeriod < periods.length - 1) {
setActivePeriod(activePeriod + 1);
}
} else if (activePeriodType === 'break' && currCycle < TOTAL_CYCLES) {
setActivePeriodType('focus');
setCurrCycle(prevCycle => prevCycle + 1);
setTimeLeft(focusTimeInSec(focusTime));
// Increment activePeriod unless it's the very last period
if (activePeriod < periods.length - 1) {
setActivePeriod(activePeriod + 1);
}
} else {
appReset();
}
}
// Cleanup function to clear the timeout when the effect runs again
return () => {
if (timerId.current !== null) {
clearTimeout(timerId.current);
}
};
}, [isRunning, isPaused, timeLeft, focusTime]);
Without diving too much into detail about what the code is doing here, I will summarise it for you. From a user's perspective, we want the ProgressBar to start animating/shrinking (in a counter-clockwise direction) when we start the timer. When we paused the timer, the ProgressBar will stop shrinking. When we hit resume, the ProgressBar will continue shrinking from it's Paused position. When we hit stop, the ProgressBar will reset to it's beginning position.
After a couple of tests, I was able to get it working right.
Gradient Effects & Tweaks
The CircularProgressBar was initially just two solid colours (as you might've already observed). For a proof-of-concept, I'm sure that would've been okay. But I didn't want to build just a proof-of-concept. No. I wanted to create a fully functional web app. It might not be "production-ready." It's not something I can sell for money, but at least I wanted it to look and feel like a polished application. Like I've take great care in building and testing it. Which I did so far.
So I decided to take the design one step further. By introducing gradients to the two colour tones.
<svg className="w-full h-full" viewBox="0 0 110 110">
{/* Conic Gradient for Stroke */}
<defs> {/* Define the gradient within the circle element */}
{/* Focus Gradient */}
<linearGradient id="focusGradient" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#3f51b5" /> {/* Main focus colouur - bottom/end stop */}
<stop offset="90%" stopColor="#2563eb" /> {/* Lighter shade colour - top/starting stop */}
</linearGradient>
{/* Break Gradient */}
<linearGradient id="breakGradient" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#F59E0B" /> {/* Main break colour */}
<stop offset="90%" stopColor="#facc15" /> {/* Lighter shade colour */}
</linearGradient>
</defs>
{/* The rest of our SVG circle coding. */}
</svg>
When it comes to working with gradients in SVGs, I will admit that it took me a little while to wrap my head around the properties for properly setting up my gradients.
After becoming very used to using design tools, or other tools that can create gradients easily, it's so easy to take for granted the trickiness of those that actually have to write the code to make it work the way we want it to. Kudos to you if you are one of those devs.
So, to help you better understand how I thought about designing my gradients in SVGs, here are a few steps you can follow:
- Identify your colour to start with (
offset="0%"
) - Where does the colour begin, coordinate-wise (e.g.
x1="100%", y1="0%"
, that is bottom-right) - Identify your next colour stop (
offset="100%"
) - Finally, where does this colour end (e.g.
x2="0%", y2="100%"
, that is top-left)
TWO other crucial points to make:
- When
offset="0%"
, it refers to the bottom of your SVG.offset="100%"
is the top. - If for some reason you need to rotate your gradient, you use
gradientTransform="rotate(angle/degrees)"
.
Tweak No. 1: Adding a "Refilling/Rewind" CSS Animation
One of the very first things you can learn in Interaction Design (IxD) is how to make animations reflect real-world motion. I could use a counter-clockwise animation if I wanted to be adventurous, but most people will find accepting it weird and strange.
That is why I stuck to a simple CCW CSS animation. All I needed to do was to add the following classes to my ProgressBar circle:
<circle {..props here} className={`... transition-all duration-500`} />
Tweak No. 2: Enlarging the Circle & Thickening the Stroke Width
When thinking about enlarging a circle and thickening the borders of your SVG shapes, you need to bear in mind that the viewBox
also needs to be enlarged. Otherwise, the border will be cropped off.
Simple Pointers to follow:
- What thickness (i.e. width) do you want for your shape?
- For my circle, I need to consider the radius and divide the border width by 2.
- Next, I need to enlarge my
viewBox
to cover my circle nicely. - Finally, I need to modify the center point of my circle.
Tweak No. 3: Ensuring My Countdown Timer Looks Okay Across Different Devices.
For that, all I needed to do was introduce some basic media queries to my global.css
file.
.countdown-timer {
font-family: 'Roboto Condensed', sans-serif;
letter-spacing: 2px;
font-weight: 200;
font-size: 4.5rem;
}
@media (max-width: 1600px) {
.countdown-timer {
font-size: 3.5rem;
}
}
@media (max-width: 768px) { /* Adjust the breakpoint as needed */
.countdown-timer {
font-size: 2rem;
letter-spacing: 1px;
}
}
Challenges & Lessons Learnt
As I said at the start of this article, there are still many things I can learn and discover. And I'm truly glad that this "not-so-mini" showcase project still has something to teach me.
Here's a quick summary of everything I've learned working on this CircularProgressBar:
- Learned that
timerId
serves as a unique identifier for each instance ofsetTimeout()
. The "coat checking" analogy helps. We store its value as a reference usingtimerId.current
. - Always clear the timeout when it is no longer needed (stopping or pausing it). Use the
clearTimeout(timerId)
function. - The Best CSS Animations aren't elaborate. They are often simple and often subtle.
- Be patient when testing timer-related animations.
- Start with tiny, incremental tweaks to improve your animations.
Wrapping Up (For Now)
So there you have it—another day, another deep dive into the world of SVG animations. While getting those timers to align visually felt like a victory in itself, it's these little challenges that truly refine our skills.
Through this process, I've learned (and hope you'll take away as well) that even seemingly simple animations often require a thoughtful approach and a willingness to experiment. Don't be afraid to roll up your sleeves, tinker with the code, and embrace those "aha!" moments when it all finally clicks.
Also, if you'd like to take a look at what I have done so far, or perhaps you want to fork a copy of it to tinker on your own, you can visit my GitHub repo here: https://github.com/d2d-weizhi/focus-flow. Remember that this project isn't fully done at this point. I still have a few things in mind that I would like to implement before I can truly consider this web app DONE. For now, though, I'd say that it is demo-worthy at least.
Now, Over to You!
I'd love to hear about your own experiences with SVGs and CSS animations. Have you encountered any head-scratching moments (or triumphant breakthroughs)? Share your stories, tips, or even your own animation experiments in the comments below! Let's learn and build something amazing together.
Hello!
I Have A Question
How you use the video as the cover image on Articles. Can you provide me information plz