Pomodoro Tag
25:00 work

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()
  })

Demo

25:00 work
25:00 work
25:00 work