Pomodoro
A circular countdown timer that alternates between 25-minute work sessions and 5-minute breaks. An SVG ring fills as time elapses, changing color when the mode switches. Pass an accent prop with any hex color to change the work-mode ring and button — defaults to Tailwind red-500 (#ef4444).
The display is two concentric SVG circles — a gray background track and a colored foreground ring. The SVG is rotated -90° so the ring starts from 12 o'clock rather than 3 o'clock. Inside the ring, absolutely positioned text shows the countdown and current mode. Two buttons below: Start (toggles to Pause/Resume) and Reset.
The container sets --pom-accent from the accent prop. The ring and start button both reference this variable, so changing it recolors the whole component.
<div
style="--pom-accent: {{ accent: #ef4444 }}"
class="inline-flex flex-col items-center gap-3 p-6 rounded-2xl bg-gray-100 dark:bg-gray-800 select-none"
>
<div class="relative w-40 h-40">
<svg class="w-full h-full -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
stroke-width="4"
class="text-gray-300 dark:text-gray-600"
/>
<circle
data-pom="ring"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="4"
stroke-dasharray="282.74"
stroke-dashoffset="0"
stroke-linecap="round"
/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span
data-pom="time"
class="text-3xl font-mono font-semibold text-gray-800 dark:text-gray-100"
>25:00</span
>
<span
data-pom="label"
class="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 mt-1"
>work</span
>
</div>
</div>
<div class="flex gap-2">
<button
data-pom="start"
class="px-4 py-1.5 text-sm font-medium rounded-lg text-white transition-colors"
>
Start
</button>
<button
data-pom="reset"
class="px-4 py-1.5 text-sm font-medium rounded-lg bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 transition-colors"
>
Reset
</button>
</div>
</div>
The ring and button both pull their color from --pom-accent. A second variable, --pom-break, defines the break-mode color (green). The ring transitions both its stroke-dashoffset (the countdown sweep) and stroke (the color change between modes) so neither jumps abruptly. The button uses filter on hover so it darkens regardless of what accent color is set.
[data-tag="pomodoro"] {
--pom-break: #22c55e;
}
[data-pom="ring"] {
stroke: var(--pom-accent);
transition:
stroke-dashoffset 500ms ease,
stroke 300ms ease;
}
[data-pom="start"] {
background-color: var(--pom-accent);
}
[data-pom="start"]:hover {
filter: brightness(0.9);
}
The timer is a small state machine: remaining seconds, total duration for the current mode, an interval handle, and a work/break boolean. Each tick decrements remaining and re-renders. When it hits zero, the mode flips and the duration resets.
At init, the script finds all uninitialized pomodoro instances on the page and sets up each one independently — so multiple timers can run side by side with different accent colors. For each instance it reads --pom-accent and --pom-break from the computed styles to know which colors to swap between. The render function calculates how much ring to reveal — circumference times the fraction elapsed — and sets that as strokeDashoffset. It also swaps the ring's stroke between the accent and break colors.
const WORK = 25 * 60
const BREAK = 5 * 60
const CIRCUMFERENCE = 2 * Math.PI * 45
document
.querySelectorAll('[data-tag="pomodoro"]:not([data-pom-init])')
.forEach(function (el) {
el.setAttribute("data-pom-init", "")
const styles = getComputedStyle(el)
const accentColor = styles.getPropertyValue("--pom-accent").trim()
const breakColor = styles.getPropertyValue("--pom-break").trim()
const ring = el.querySelector('[data-pom="ring"]')
const time = el.querySelector('[data-pom="time"]')
const label = el.querySelector('[data-pom="label"]')
const btn = el.querySelector('[data-pom="start"]')
const resetBtn = el.querySelector('[data-pom="reset"]')
let remaining = WORK
let total = WORK
let interval = null
let isWork = true
function fmt(s) {
s = Math.max(0, s)
const m = Math.floor(s / 60)
const sec = s % 60
return String(m).padStart(2, "0") + ":" + String(sec).padStart(2, "0")
}
function render() {
time.textContent = fmt(remaining)
const offset = CIRCUMFERENCE * (1 - remaining / total)
ring.style.strokeDashoffset = offset
ring.style.stroke = isWork ? accentColor : breakColor
label.textContent = isWork ? "work" : "break"
}
function tick() {
remaining--
if (!(remaining > 0)) {
clearInterval(interval)
interval = null
isWork = !isWork
total = isWork ? WORK : BREAK
remaining = total
btn.textContent = "Start"
render()
return
}
render()
}
btn.addEventListener("click", function () {
if (interval) {
clearInterval(interval)
interval = null
btn.textContent = "Resume"
} else {
interval = setInterval(tick, 1000)
btn.textContent = "Pause"
}
})
resetBtn.addEventListener("click", function () {
clearInterval(interval)
interval = null
isWork = true
total = WORK
remaining = WORK
btn.textContent = "Start"
render()
})
ring.style.strokeDasharray = CIRCUMFERENCE
render()
})