At first glance, this looks like a design problem.
Make it circular. Make it smooth. Add some pastel colors and a calming font.
Maybe animate an SVG stroke so it “feels” like time is passing.
That’s where most implementations land.
And that’s exactly why most Pomodoro timers feel… slightly off.
Not broken. Just… untrustworthy.
Because the moment you switch tabs and come back — the illusion collapses.
This isn’t a timer UI
It’s a time reconciliation system
What you’re really building here is not a countdown.
You’re building a system that answers one question:
“What time is it supposed to be right now?”
Not:
“How many seconds have I counted?”
That difference is subtle.
And it’s where most AI-generated solutions quietly fail.
The illusion of setInterval
This is the default move:
setInterval(() => {
timeLeft--;
}, 1000);
It looks correct.
It behaves correctly… as long as the tab is active.
But browsers don’t respect your timer.
They throttle it. Pause it. Delay it.
So your “1 second” becomes:
- 1.3 seconds
- 2 seconds
- sometimes… nothing at all
And your UI keeps pretending everything is fine.
That’s the bug.
Not visible. Not crashing.
Just drifting.
Where it gets interesting
A human usually doesn’t try to “fix” setInterval.
They abandon the idea entirely.
Instead of counting time…
They anchor it.
let endTime = Date.now() + duration;
Now everything changes.
You’re no longer tracking ticks.
You’re calculating truth.
The timer becomes a projection
Every frame, you ask:
const remaining = endTime - Date.now();
That’s it.
No drift.
No accumulation error.
No dependency on browser behavior.
Just a recalculation.
And suddenly:
- tab switching doesn’t matter
- lag doesn’t matter
- animation stays honest
This is the moment the system becomes real.
The orbit isn’t visual
It’s mathematical
The circular progress bar looks like a styling challenge.
It isn’t.
It’s a mapping problem:
time → circumference
That mapping is where most implementations get shaky.
Because they treat the circle like decoration.
Instead of a visual encoding of state
A small line that carries the entire illusion
const progress = remaining / duration;
That ratio drives everything:
- stroke offset
- animation smoothness
- perception of time
If this value is wrong — even slightly — the user feels it.
Not consciously.
But enough to break immersion.
HTML (nothing fancy, and that’s the point)
<div class="app">
<div class="orbit-container">
<svg class="orbit" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="54" class="bg"></circle>
<circle cx="60" cy="60" r="54" class="progress"></circle>
</svg>
<div class="time" id="time">25:00</div>
</div>
<div class="controls">
<button id="focus">Focus</button>
<button id="chill">Chill</button>
<button id="start">Start</button>
<button id="pause">Pause</button>
<button id="reset">Reset</button>
</div>
<div id="message" class="message hidden">Time to breathe</div>
</div>
The structure is intentionally quiet.
Because the behavior is doing the heavy lifting.
CSS (this is where most people over-design)
body {
margin: 0;
font-family: "Inter", sans-serif;
background: #f4f1ee;
color: #2a2a2a;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.app {
text-align: center;
}
.orbit-container {
position: relative;
width: 200px;
height: 200px;
margin: auto;
}
.orbit {
transform: rotate(-90deg);
}
circle {
fill: none;
stroke-width: 8;
}
.bg {
stroke: #e0dcd7;
}
.progress {
stroke: #a3c9a8;
stroke-linecap: round;
stroke-dasharray: 339.292;
stroke-dashoffset: 0;
transition: stroke 0.3s ease;
}
.time {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 28px;
font-weight: 500;
}
.controls {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: center;
}
button {
padding: 10px 14px;
border: none;
border-radius: 20px;
background: #e8e3dd;
cursor: pointer;
}
.message {
margin-top: 20px;
opacity: 0;
transition: opacity 0.5s ease;
}
.message.show {
opacity: 1;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 0.3; }
50% { opacity: 1; }
100% { opacity: 0.3; }
}
The mistake AI models often make here is excess.
Too many gradients. Too much glow.
But “zen” UI is about restraint.
The absence of noise.
JavaScript (this is where the system lives)
const FULL_DASH = 339.292;
let duration = 25 * 60 * 1000;
let endTime = null;
let timerRunning = false;
const timeEl = document.getElementById("time");
const progressEl = document.querySelector(".progress");
const messageEl = document.getElementById("message");
function format(ms) {
const total = Math.max(0, Math.floor(ms / 1000));
const m = String(Math.floor(total / 60)).padStart(2, "0");
const s = String(total % 60).padStart(2, "0");
return `${m}:${s}`;
}
function update() {
if (!timerRunning) return;
const remaining = endTime - Date.now();
if (remaining <= 0) {
timerRunning = false;
timeEl.textContent = "00:00";
progressEl.style.strokeDashoffset = FULL_DASH;
messageEl.classList.add("show");
return;
}
const progress = remaining / duration;
progressEl.style.strokeDashoffset = FULL_DASH * (1 - progress);
timeEl.textContent = format(remaining);
requestAnimationFrame(update);
}
document.getElementById("start").onclick = () => {
if (!endTime) {
endTime = Date.now() + duration;
} else {
endTime = Date.now() + (endTime - Date.now());
}
timerRunning = true;
messageEl.classList.remove("show");
update();
};
document.getElementById("pause").onclick = () => {
timerRunning = false;
duration = endTime - Date.now();
};
document.getElementById("reset").onclick = () => {
timerRunning = false;
endTime = null;
duration = 25 * 60 * 1000;
timeEl.textContent = "25:00";
progressEl.style.strokeDashoffset = 0;
messageEl.classList.remove("show");
};
document.getElementById("focus").onclick = () => {
duration = 25 * 60 * 1000;
endTime = null;
timeEl.textContent = "25:00";
};
document.getElementById("chill").onclick = () => {
duration = 5 * 60 * 1000;
endTime = null;
timeEl.textContent = "05:00";
};
AI vs AI vs Human — where the split happens
This challenge looks harmless.
But in Vibe Code Arena, it exposes something deeper.
Model 1: Animation-first thinking
- Gets the orbit working
- Uses
setInterval - Feels smooth… initially
Until:
- tab switch breaks timing
- animation desyncs from reality
It optimizes for appearance, not truth.
Model 2: Over-correcting
- Uses timestamps
- But introduces complex state handling
- Adds unnecessary abstractions
It becomes harder to reason about than the problem itself.
The human approach
Usually lands somewhere quieter:
- one source of truth →
endTime - everything derived from
Date.now() - animation tied to reality, not ticks
Not because it’s “clean”
But because it’s trustworthy
What most people miss
This isn’t about Pomodoro.
It’s about temporal systems
Anywhere you deal with:
- timers
- progress
- countdowns
- animations tied to time
You face the same choice:
Do you simulate time… or reference it?
Simulation drifts.
Reference stays anchored.
The Reality
After building this, you stop seeing:
“a circular timer UI”
And start seeing:
“a continuously reconciled system between real time and visual state”
And once that clicks…
You start noticing how many apps quietly get this wrong.
If you want to see how different models handle this (and where they drift), try this exact challenge in AI duel mode on Vibe Code Arena.
That’s where “working code” and “reliable systems” stop being the same thing.
👉 Try it out here: https://vibecodearena.ai/share/d8d9208b-9adc-4d11-91ab-d893900f4222



Top comments (0)