my eye

author Angelo Gladding

name Timer

published 2026-10-09T01:51:11.402406-07:00

type entry

updated 2026-10-09T16:56:59.722698-07:00

url /timer, /2026/10/09/ru

visibility public

Content

$ minutes = 5

<section class=timer aria-label="Countdown timer">
    <h1>Timer</h1>
    <form class=timer-settings>
        <label>
            Minutes
            <input name=minutes type=number min=0 max=999
                   step=1 value=$minutes required>
        </label>
        <label>
            Seconds
            <input name=seconds type=number min=0 max=59
                   step=1 value=0 required>
        </label>
    </form>
    <div class=timer-presets aria-label="Preset durations">
        <button type=button data-minutes=1>1 min</button>
        <button type=button data-minutes=5>5 min</button>
        <button type=button data-minutes=10>10 min</button>
        <button type=button data-minutes=25>25 min</button>
    </div>
    <output class=timer-display aria-label="Time remaining"></output>
    <progress class=timer-progress max=1 value=0
              aria-label="Elapsed time"></progress>
    <div class=timer-controls>
        <button type=button class=timer-toggle>Start</button>
        <button type=button class=timer-reset>Reset</button>
    </div>
    <label class=timer-sound>
        <input type=checkbox checked> Sound when finished
    </label>
    <p class=timer-status role=status>Ready.</p>
</section>

<style>
.timer {
    max-width: 24rem;
    margin: 2rem auto;
    padding: 1.5rem;
    border: 1px solid #8886;
    border-radius: 1rem;
    font-family: system-ui, sans-serif;
    text-align: center;
}
.timer-settings, .timer-presets, .timer-controls {
    display: flex;
    justify-content: center;
    gap: .6rem;
    flex-wrap: wrap;
}
.timer-settings label {
    display: grid;
    gap: .35rem;
    text-align: left;
}
.timer input[type="number"] {
    width: 6rem;
    padding: .6rem;
    font: inherit;
}
.timer button {
    min-height: 44px;
    padding: .6rem 1rem;
    border: 1px solid #8888;
    border-radius: .5rem;
    background: transparent;
    color: inherit;
    font: inherit;
    cursor: pointer;
}
.timer button:hover { background: #8882; }
.timer :focus-visible { outline: 3px solid #478bea; outline-offset: 3px; }
.timer-presets { margin-top: 1rem; }
.timer-display {
    display: block;
    margin: 1rem 0 .5rem;
    font-size: clamp(3rem, 15vw, 5rem);
    font-variant-numeric: tabular-nums;
}
.timer-progress { width: 100%; margin-bottom: 1rem; }
.timer-sound { display: block; margin-top: 1rem; }
.timer-status { min-height: 1.5em; margin-bottom: 0; }
.timer.finished .timer-display { color: #c05020; }
</style>

<script>
(() => {
    const root = document.querySelector(".timer");
    const form = root.querySelector("form");
    const minutes = form.elements.minutes;
    const seconds = form.elements.seconds;
    const display = root.querySelector("output");
    const progress = root.querySelector("progress");
    const toggle = root.querySelector(".timer-toggle");
    const resetButton = root.querySelector(".timer-reset");
    const status = root.querySelector(".timer-status");
    const sound = root.querySelector('[type="checkbox"]');
    const originalTitle = document.title;

    let duration = 0;
    let remaining = 0;
    let deadline = 0;
    let running = false;
    let interval = null;
    let audio = null;

    function readDuration() {
        return (Number(minutes.value) * 60 + Number(seconds.value)) * 1000;
    }

    function format(ms) {
        const total = Math.ceil(ms / 1000);
        return String(Math.floor(total / 60)).padStart(2, "0")
            + ":" + String(total % 60).padStart(2, "0");
    }

    function paint() {
        const text = format(remaining);
        display.textContent = text;
        progress.value = duration ? 1 - remaining / duration : 0;
        toggle.textContent = running ? "Pause"
            : remaining < duration && remaining > 0 ? "Resume" : "Start";

        minutes.disabled = seconds.disabled = running || remaining < duration;
        root.querySelectorAll("[data-minutes]").forEach(button => {
            button.disabled = minutes.disabled;
        });

        document.title = running ? text + " · Timer" : originalTitle;
    }

    function prepareAudio() {
        if (!sound.checked) return;
        try {
            const AudioContext = window.AudioContext || window.webkitAudioContext;
            audio = audio || new AudioContext();
            audio.resume().catch(() => {});
        } catch (_) {}
    }

    function beep() {
        if (!sound.checked || !audio || audio.state !== "running") return;
        const now = audio.currentTime;
        for (let i = 0; i < 3; i++) {
            const oscillator = audio.createOscillator();
            const gain = audio.createGain();
            const start = now + i * .3;
            oscillator.frequency.value = 880;
            gain.gain.setValueAtTime(0, start);
            gain.gain.linearRampToValueAtTime(.15, start + .01);
            gain.gain.exponentialRampToValueAtTime(.001, start + .2);
            oscillator.connect(gain);
            gain.connect(audio.destination);
            oscillator.start(start);
            oscillator.stop(start + .21);
        }
    }

    function stop() {
        running = false;
        clearInterval(interval);
        interval = null;
    }

    function tick() {
        remaining = Math.max(0, deadline - Date.now());
        if (remaining === 0) {
            stop();
            paint();
            root.classList.add("finished");
            status.textContent = "Time’s up!";
            document.title = "Time’s up! · " + originalTitle;
            beep();
            return;
        }
        paint();
    }

    function reset() {
        stop();
        duration = remaining = readDuration();
        root.classList.remove("finished");
        status.textContent = "Ready.";
        paint();
    }

    function startOrPause() {
        if (running) {
            tick();
            if (!running) return;
            stop();
            status.textContent = "Paused.";
            paint();
            return;
        }

        if (!form.reportValidity()) return;
        if (remaining === duration || remaining === 0) {
            duration = remaining = readDuration();
        }
        if (remaining <= 0) {
            status.textContent = "Enter a duration greater than zero.";
            minutes.focus();
            return;
        }

        prepareAudio();
        root.classList.remove("finished");
        deadline = Date.now() + remaining;
        running = true;
        status.textContent = "Running.";
        tick();
        interval = setInterval(tick, 200);
    }

    toggle.addEventListener("click", startOrPause);
    resetButton.addEventListener("click", reset);
    form.addEventListener("submit", event => {
        event.preventDefault();
        startOrPause();
    });
    form.addEventListener("input", reset);

    root.querySelectorAll("[data-minutes]").forEach(button => {
        button.addEventListener("click", () => {
            minutes.value = button.dataset.minutes;
            seconds.value = 0;
            reset();
        });
    });

    document.addEventListener("visibilitychange", () => {
        if (running) tick();
    });

    reset();
})();
</script>

The countdown uses a deadline rather than subtracting seconds, so background throttling won’t make it drift. Browsers can still delay the completion sound while a tab is suspended.