How I built a cyberpunk Pomodoro timer.
with vanilla everything.
A focus timer built with plain HTML, CSS, and JavaScript. No frameworks, no build tools. One file you open in a browser.
- What we’re building
- What you need
- The file structure
- Stage 1: A timer that counts down
- Stage 2: The progress ring
- Stage 3: Three modes (focus, short break, long break)
- Stage 3.5: Custom time blocks
- Stage 4: Sound when time’s up
- Stage 5: Saving data with localStorage
- Stage 6: Tracking what you study
- Stage 7: The analytics dashboard
- Bonus: Making it a real app (PWA)
- Publishing it for free
- Where to go next
What we’re building
By the end of this you’ll have a working Pomodoro timer that:
- Counts down focus and break sessions with a glowing progress ring
- Switches between 8 color themes (cyberpunk, neon, synthwave, and more)
- Plays a chime when time’s up, with a volume slider and optional ticking
- Tracks what you study with subjects, tasks, and a “what are you working on?” note
- Shows an analytics dashboard with charts and a GitHub-style activity heatmap
- Saves everything in your browser so your data survives a refresh
- Installs as an app on your phone or desktop (it’s a PWA)
It’s one HTML file. No npm install, no webpack, no React. If you know a little HTML and CSS, you can follow along.
We build it in seven stages. Each stage is a complete, working version you can run, so you’re never staring at 700 lines wondering what does what.
index.html. Open it to run it, or View Source to read every line in one place. The rest of this post is how you get there from nothing.What you need
- A text editor (VS Code, Sublime, even Notepad)
- A web browser
- That’s it
Create a file called index.html and open it in your browser. Every time you save, refresh the browser to see the change.
The file structure
Before we write any code, the big picture. For most of this tutorial, everything lives in a single file, index.html. That one file holds three things:
index.html ├── HTML → the structure (buttons, the timer display, the layout) ├── CSS → the styling (colors, glow, fonts) — inside <style> tags └── JS → the behavior (the countdown, clicks) — inside <script> tags
That’s the whole app for Stages 1 through 7. No folders, no setup, just one file you double-click to open. The browser already knows how to read HTML, CSS, and JavaScript, so there’s nothing to install or compile.
Here’s how those three parts sit inside the file:
<!DOCTYPE html>
<html lang="en">
<head>
<style>
/* ALL your CSS goes here — the look and feel */
</style>
</head>
<body>
<!-- ALL your HTML goes here — the things you see -->
<div id="time">25:00</div>
<button onclick="toggle()">Start</button>
<script>
/* ALL your JavaScript goes here — the logic */
</script>
</body>
</html>The HTML is the structure, the CSS is the styling, the JavaScript is the behavior. They live in one file and talk to each other.
Only at the very end, when we turn this into an installable app (the Bonus section), do we add two small companion files. Then the folder looks like this:
my-pomodoro/ ├── index.html ← everything we build in Stages 1–7 ├── manifest.json ← tells phones/browsers it's an installable app ├── sw.js ← the "service worker" that makes it work offline ├── icon-192.png ← app icon (small) └── icon-512.png ← app icon (large)
Ignore those for now. For the entire core tutorial, it’s just index.html. When you see a block of CSS, it goes inside the <style> tags. When you see JavaScript, it goes inside the <script> tags. When you see HTML, it goes inside the <body>. I’ll remind you which is which as we go.
Stage 1: A timer that counts down
Start with the core: a number that ticks down from 25:00 to 00:00.
Paste this into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>POMO — Focus Timer</title>
<style>
body {
background: #0a0a0f;
color: #e0ffe8;
font-family: monospace;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.time {
font-size: 72px;
letter-spacing: -2px;
}
button {
background: transparent;
border: 2px solid #00ffc8;
color: #00ffc8;
padding: 12px 28px;
font-size: 16px;
border-radius: 8px;
cursor: pointer;
margin-top: 20px;
}
button:hover { background: #00ffc8; color: #0a0a0f; }
</style>
</head>
<body>
<div class="time" id="time">25:00</div>
<button id="startBtn" onclick="toggle()">Start</button>
<script>
let remaining = 25 * 60; // seconds left
let running = false; // is the timer ticking?
let timerId = null; // reference to setInterval so we can stop it
// Turn a number of seconds into "MM:SS"
function format(totalSeconds) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
}
function render() {
document.getElementById('time').textContent = format(remaining);
}
function toggle() {
if (running) {
stop();
} else {
start();
}
}
function start() {
running = true;
document.getElementById('startBtn').textContent = 'Pause';
// setInterval runs the function inside it every 1000ms (1 second)
timerId = setInterval(() => {
remaining = remaining - 1;
render();
if (remaining <= 0) {
clearInterval(timerId);
running = false;
document.getElementById('startBtn').textContent = 'Start';
}
}, 1000);
}
function stop() {
clearInterval(timerId); // stops the repeating function
running = false;
document.getElementById('startBtn').textContent = 'Start';
}
render(); // draw the initial 25:00
</script>
</body>
</html>What’s happening here:
remainingholds the seconds left. We start with25 * 60= 1500 seconds.setInterval(fn, 1000)is the heart of any timer. It runs a function once per second, and we use it to subtract 1 fromremainingand redraw.clearInterval(timerId)stops that loop. We call it when we pause or when we hit zero.format()converts 1500 into“25:00”. ThepadStart(2, ‘0’)adds a leading zero so we get05instead of5.
Save, refresh, click Start. Working timer. Everything else is built on top of this.
Stage 2: The progress ring
A bare number works but it’s dull. Let’s wrap it in a circular progress ring that drains as time runs out. We’ll use SVG, specifically a trick with stroke-dasharray.
The idea: an SVG circle’s outline can be turned into a dashed line. Make the “dash” exactly as long as the circle’s circumference, then offset it, and the stroke appears to fill or empty.
Replace your <body> content with this:
<body>
<div class="timer-wrap">
<svg class="timer-svg" viewBox="0 0 240 240">
<circle class="ring-bg" cx="120" cy="120" r="110"/>
<circle class="ring-track" id="ring" cx="120" cy="120" r="110"/>
</svg>
<div class="timer-inner">
<div class="time" id="time">25:00</div>
<div class="label">FOCUS</div>
</div>
</div>
<button id="startBtn" onclick="toggle()">Start</button>Add these styles inside your <style>:
.timer-wrap {
position: relative;
width: 240px;
height: 240px;
}
.timer-svg {
width: 100%;
height: 100%;
/* rotate so the ring starts draining from the top, not 3 o'clock */
transform: rotate(-90deg);
}
.ring-bg {
fill: none;
stroke: #1e1e2a; /* the dark background track */
stroke-width: 7;
}
.ring-track {
fill: none;
stroke: #00ffc8; /* the glowing colored part */
stroke-width: 7;
stroke-linecap: round;
filter: drop-shadow(0 0 8px #00ffc8); /* the glow */
transition: stroke-dashoffset 1s linear;
}
.timer-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.label {
font-size: 11px;
letter-spacing: 0.2em;
color: #00ffc8;
}Now the JavaScript. We calculate the circumference and update the ring’s offset each second. Add this near the top of your <script>:
const RADIUS = 110; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; // ≈ 691 const total = 25 * 60; // the full duration, used to calculate the fraction
Then update render() to also move the ring:
function render() {
document.getElementById('time').textContent = format(remaining);
const ring = document.getElementById('ring');
ring.style.strokeDasharray = CIRCUMFERENCE;
// As remaining shrinks, the offset grows, "emptying" the ring
const offset = CIRCUMFERENCE * (1 - remaining / total);
ring.style.strokeDashoffset = offset;
}How it works. stroke-dasharray set to the full circumference makes one dash that wraps the whole circle. stroke-dashoffset then pushes that dash around. Offset 0 means the ring is full; offset equal to the circumference means it’s empty. We compute the offset from how much time is left. The transition in the CSS animates it smoothly instead of jumping.
Stage 3: Three modes (focus, short break, long break)
A real Pomodoro timer cycles through focus → short break → focus → … → long break. Let’s add mode tabs and the logic to switch between them.
Add tabs above the timer:
<div class="mode-tabs">
<button class="mode-tab active" id="tab-focus" onclick="setMode('focus')">Focus</button>
<button class="mode-tab" id="tab-short" onclick="setMode('short')">Short Break</button>
<button class="mode-tab" id="tab-long" onclick="setMode('long')">Long Break</button>
</div>Some styling:
.mode-tabs { display: flex; gap: 6px; margin-bottom: 24px; }
.mode-tab {
padding: 9px 16px;
border: 1px solid rgba(0,255,200,0.3);
background: transparent;
color: #3a6a5a;
border-radius: 6px;
cursor: pointer;
font-family: monospace;
text-transform: uppercase;
font-size: 11px;
}
.mode-tab.active {
background: #00ffc8;
color: #0a0a0f;
}Now restructure the JavaScript around a config object and a mode variable:
// How many minutes each mode lasts
const cfg = { focus: 25, short: 5, long: 15 };
let mode = 'focus';
let total = cfg.focus * 60;
let remaining = total;
let running = false;
let timerId = null;
let completedSessions = 0; // counts focus sessions for the long-break cycle
function setMode(newMode, autoStart = false) {
if (running) stop();
mode = newMode;
total = cfg[newMode] * 60;
remaining = total;
// Update which tab looks active
document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
document.getElementById('tab-' + newMode).classList.add('active');
render();
if (autoStart) setTimeout(start, 700); // brief pause, then auto-begin
}The interesting part is what happens when a timer hits zero. Right now the countdown inside start() just stops there. We want it to call a new onComplete() function that decides which mode comes next.
First, the decision-maker:
function onComplete() {
if (mode === 'focus') {
completedSessions++;
if (completedSessions >= 4) {
completedSessions = 0;
setMode('long', true); // every 4th focus → long break
} else {
setMode('short', true); // otherwise → short break
}
} else {
setMode('focus', true); // after any break → back to focus
}
}Now wire it in. Replace your entire start() function with this version. The only change from Stage 1 is the last line inside the if (remaining <= 0) block, which now calls onComplete():
function start() {
running = true;
document.getElementById('startBtn').textContent = 'Pause';
timerId = setInterval(() => {
remaining = remaining - 1;
render();
if (remaining <= 0) {
clearInterval(timerId);
running = false;
document.getElementById('startBtn').textContent = 'Start';
onComplete(); // ← the new line: hand off to the next session
}
}, 1000);
}That single onComplete() call is the hinge the whole cycle turns on. Everything above it is identical to the start() you already wrote, so if you’re copying along, just swap the whole function to be safe.
Now the timer runs a real Pomodoro cycle. It counts four focus sessions, drops short breaks between them, and gives you a long break on the fourth. The autoStart flag carries it from one session to the next without a click.
Stage 3.5: Custom time blocks
The classic Pomodoro is 25/5/15, but plenty of people prefer other rhythms: a 50/10 deep-work cycle, a quick 15/3 sprint, a 90-minute block. Let’s add +/− controls so you can dial in any duration, and make the long-break interval adjustable too.
The durations already live in one place, the cfg object:
const cfg = { focus: 25, short: 5, long: 15 };Because everything reads from cfg, we only need a function that changes those numbers and refreshes the display. Add the settings controls to your HTML (one card per mode, plus an interval card):
<div class="settings">
<div class="setting">
<span class="setting-name">Focus</span>
<button onclick="adjustDuration('focus', -1)">−</button>
<span id="val-focus">25</span>
<button onclick="adjustDuration('focus', 1)">+</button>
</div>
<div class="setting">
<span class="setting-name">Short</span>
<button onclick="adjustDuration('short', -1)">−</button>
<span id="val-short">5</span>
<button onclick="adjustDuration('short', 1)">+</button>
</div>
<div class="setting">
<span class="setting-name">Long</span>
<button onclick="adjustDuration('long', -1)">−</button>
<span id="val-long">15</span>
<button onclick="adjustDuration('long', 1)">+</button>
</div>
<div class="setting">
<span class="setting-name">Interval</span>
<button onclick="adjustInterval(-1)">−</button>
<span id="val-interval">4</span>
<button onclick="adjustInterval(1)">+</button>
</div>
</div>A little styling:
.settings {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 24px;
}
.setting {
background: #16161f;
border: 1px solid rgba(0,255,200,0.13);
border-radius: 8px;
padding: 12px 8px;
text-align: center;
}
.setting-name {
display: block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #5a7a70;
margin-bottom: 8px;
}
.setting button {
background: #1e1e2a;
border: 1px solid rgba(0,255,200,0.3);
color: #00ffc8;
width: 22px;
height: 22px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.setting span:not(.setting-name) {
display: inline-block;
min-width: 26px;
font-size: 16px;
}Now the JavaScript. We need a variable for the interval (replacing the hardcoded 4 from Stage 3) and two small functions:
let longBreakInterval = 4; // focus sessions before a long break
function adjustDuration(which, delta) {
// Clamp between 1 and 90 minutes so nobody sets a 0-minute timer
cfg[which] = Math.max(1, Math.min(90, cfg[which] + delta));
document.getElementById('val-' + which).textContent = cfg[which];
// If we changed the mode we're CURRENTLY in, reset the clock to match.
// (Changing 'short' while you're mid-focus won't disturb your running timer.)
if (mode === which) {
total = cfg[which] * 60;
remaining = total;
if (running) stop();
render();
}
}
function adjustInterval(delta) {
longBreakInterval = Math.max(2, Math.min(8, longBreakInterval + delta));
document.getElementById('val-interval').textContent = longBreakInterval;
}Finally, update onComplete() from Stage 3 to use the variable instead of the hardcoded 4:
function onComplete() {
if (mode === 'focus') {
completedSessions++;
if (completedSessions >= longBreakInterval) { // ← was: >= 4
completedSessions = 0;
setMode('long', true);
} else {
setMode('short', true);
}
} else {
setMode('focus', true);
}
}The design idea here. Every duration lives in cfg, and every screen reads from it. So supporting custom blocks costs almost nothing. Change the numbers in one spot and everything downstream follows: the timer, the ring, the cycle logic. One source of truth. Worth doing in every project.
if (mode === which) check matters. Say you’re 10 minutes into a focus session and you bump the short break up to 10 minutes. You don’t want that resetting the focus timer you’re in the middle of. The check makes sure we only reset the clock for the mode you’re actually running.Stage 4: Sound when time’s up
We want a chime, but shipping an audio file is overkill. The Web Audio API generates tones in code. Here’s a function that plays a three-note chime:
let audioCtx = null;
function getAudio() {
// Browsers require a user click before audio can start,
// so we create the context lazily on first use.
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') audioCtx.resume();
return audioCtx;
}
function beep() {
const ctx = getAudio();
const notes = [880, 1108, 1318]; // an ascending chord (A5, C#6, E6)
notes.forEach((frequency, i) => {
const oscillator = ctx.createOscillator();
const gain = ctx.createGain();
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.type = 'sine';
oscillator.frequency.value = frequency;
const startTime = ctx.currentTime + i * 0.18; // stagger each note
// Fade in quickly, then fade out — avoids an ugly click
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.3, startTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.35);
oscillator.start(startTime);
oscillator.stop(startTime + 0.35);
});
}Then call beep() at the top of onComplete(). Now every session ends with a chime.
How it works. An oscillator generates a raw tone at a given frequency. A gain node controls its volume. Ramp the gain up fast and down slow and each note sounds like a soft bell instead of a harsh blip. Three notes slightly apart make a chord.
getAudio() builds the context on demand and calls resume(). By the time a session ends the user has already clicked Start, so audio is unlocked.Stage 5: Saving data with localStorage
Right now, refreshing the page wipes everything. localStorage fixes that. It’s a tiny key-value store in the browser that survives between visits. It only holds strings, so we use JSON.stringify to save and JSON.parse to load.
function save() {
const data = {
completedSessions,
totalSessions, // a running lifetime count you'd track in onComplete
focusMinutes, // total minutes focused
log, // an array of session records (see below)
};
try {
localStorage.setItem('pomo-data', JSON.stringify(data));
} catch (e) {
// localStorage can fail in private-browsing mode; fail quietly
}
}
function load() {
try {
const saved = JSON.parse(localStorage.getItem('pomo-data') || 'null');
if (saved) {
totalSessions = saved.totalSessions || 0;
focusMinutes = saved.focusMinutes || 0;
log = saved.log || [];
}
} catch (e) {}
}To build study history, push a record onto log every time a focus session finishes. Inside onComplete(), before the mode switch:
if (mode === 'focus') {
const now = new Date();
const pad = v => String(v).padStart(2, '0');
const dateKey = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
log.unshift({
timestamp: now.getTime(),
date: dateKey, // "2026-05-30"
time: pad(now.getHours()) + ':' + pad(now.getMinutes()),
duration: cfg.focus,
subject: activeSubject || '—', // from Stage 6
note: currentNote || '', // from Stage 6
});
totalSessions++;
focusMinutes += cfg.focus;
save(); // persist immediately
}log.unshift() adds to the front of the array so the newest session is first. Each record stores the date (for grouping), the subject, and a note. This array is the raw material for everything in the analytics dashboard.
Stage 6: Tracking what you study
Three small features turn this into a study tool rather than just a timer: subjects, a session note, and a task list.
The “what are you working on?” note
The simplest one. A text input whose value we read when a session completes:
<input id="note" placeholder="What are you working on?" oninput="currentNote = this.value"/>
let currentNote = '';
When the focus session ends, currentNote gets baked into the log record. We wired that in Stage 5.
Subjects
A subject is a category you attach time to: “Math”, “Spanish”, whatever. We store them in an object mapping name to minutes studied:
let subjects = {}; // e.g. { "Math": 75, "History": 50 }
let activeSubject = null; // which one is currently selected
function addSubject() {
const input = document.getElementById('subjectInput');
const name = input.value.trim();
if (!name || subjects[name] !== undefined) return; // skip blanks/dupes
subjects[name] = 0;
input.value = '';
renderSubjects();
save();
}
function selectSubject(name) {
// Click to activate; click again to deactivate
activeSubject = (activeSubject === name) ? null : name;
renderSubjects();
}To accrue time live while you focus, tick the active subject up inside the per-second interval:
// inside setInterval, each second of a focus session:
if (mode === 'focus' && activeSubject && subjects[activeSubject] !== undefined) {
subjects[activeSubject] += 1 / 60; // add one second, expressed in minutes
renderSubjects();
}Tasks
A simple checklist. Each task is an object with text and a done flag:
let tasks = [];
function addTask() {
const input = document.getElementById('taskInput');
const text = input.value.trim();
if (!text) return;
tasks.push({ text, done: false });
input.value = '';
renderTasks();
save();
}
function toggleTask(index) {
tasks[index].done = !tasks[index].done;
renderTasks();
save();
}The render functions loop over the arrays and build HTML. Here’s the tasks one as an example:
function renderTasks() {
const container = document.getElementById('taskList');
if (tasks.length === 0) {
container.innerHTML = '<div class="empty">No tasks yet</div>';
return;
}
container.innerHTML = tasks.map((task, i) => `
<div class="task ${task.done ? 'done' : ''}">
<div class="checkbox ${task.done ? 'done' : ''}" onclick="toggleTask(${i})"></div>
<span>${task.text}</span>
<button onclick="deleteTask(${i})">✕</button>
</div>
`).join('');
}< with <) so nobody can break your layout by typing a tag. The full app does this on every user-supplied string.Stage 7: The analytics dashboard
This is where the saved log pays off. We’ll add three views: summary stats, a bar chart, and a GitHub-style heatmap.
Summary stats
Just reductions over the filtered log:
function getStats(log) {
const focusOnly = log.filter(e => !e.skipped);
const totalMinutes = focusOnly.reduce((sum, e) => sum + e.duration, 0);
const uniqueDays = new Set(focusOnly.map(e => e.date)).size || 1;
const avgPerDay = focusOnly.length / uniqueDays;
return {
sessions: focusOnly.length,
hours: Math.floor(totalMinutes / 60),
avgPerDay: Math.round(avgPerDay * 10) / 10,
};
}The bar chart
For charts we pull in Chart.js from a CDN. One line, no install:
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
Then build a “sessions per day” dataset for the last 7 days and hand it to Chart.js:
function buildChart(log) {
const now = new Date();
const labels = [];
const data = [];
for (let i = 6; i >= 0; i--) {
const day = new Date(now);
day.setDate(day.getDate() - i);
const pad = v => String(v).padStart(2, '0');
const key = day.getFullYear() + '-' + pad(day.getMonth() + 1) + '-' + pad(day.getDate());
labels.push(['Su','Mo','Tu','We','Th','Fr','Sa'][day.getDay()]);
data.push(log.filter(e => e.date === key && !e.skipped).length);
}
new Chart(document.getElementById('dayChart'), {
type: 'bar',
data: { labels, datasets: [{ data, backgroundColor: '#00ffc855', borderColor: '#00ffc8', borderWidth: 1.5 }] },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
}
});
}We count how many sessions happened on each of the last seven days, then let Chart.js draw the bars. Wrap the <canvas> in a div with a fixed height, or the chart grows uncontrollably:
<div style="position: relative; height: 150px;"> <canvas id="dayChart"></canvas> </div>
The heatmap
The GitHub-style activity grid is my favorite part. A grid of small squares, one per day for the last six months, shaded by how many sessions you did that day. We build it by hand with a loop:
function buildHeatmap(log) {
// Count sessions per day
const counts = {};
log.filter(e => !e.skipped).forEach(e => {
counts[e.date] = (counts[e.date] || 0) + 1;
});
const maxCount = Math.max(1, ...Object.values(counts));
// Pick a shade of the accent color based on intensity
function shade(n) {
if (!n) return '#1e1e2a'; // empty day = dark gray
// Clamp to 1–4 so we always land on a valid opacity, even if
// counts and maxCount ever drift (defensive — keeps it bulletproof).
const level = Math.min(4, Math.max(1, Math.ceil((n / maxCount) * 4)));
const opacity = [0, 0.3, 0.5, 0.75, 1][level];
const hex = Math.round(opacity * 255).toString(16).padStart(2, '0');
return '#00ffc8' + hex; // accent + opacity
}
// Walk backwards ~6 months, snapping to week boundaries (Sunday start)
const now = new Date();
const start = new Date(now);
start.setDate(start.getDate() - 181);
start.setDate(start.getDate() - start.getDay());
let html = '';
let cursor = new Date(start);
while (cursor <= now) {
html += '<div class="week">';
for (let d = 0; d < 7; d++) {
const pad = v => String(v).padStart(2, '0');
const key = cursor.getFullYear() + '-' + pad(cursor.getMonth() + 1) + '-' + pad(cursor.getDate());
const count = counts[key] || 0;
html += `<div class="cell" style="background:${shade(count)}" title="${key}: ${count} sessions"></div>`;
cursor.setDate(cursor.getDate() + 1);
}
html += '</div>';
}
document.getElementById('heatmap').innerHTML = html;
}#heatmap { display: flex; gap: 3px; }
.week { display: flex; flex-direction: column; gap: 3px; }
.cell { width: 11px; height: 11px; border-radius: 2px; }The logic. Each column is a week, seven stacked squares, and we lay the weeks side by side. We snap the start date back to a Sunday so the rows line up. shade() maps a day’s session count to an opacity of the accent color: busier day, brighter square. Hovering a square shows the date and count through the title attribute.
Bonus: Making it a real app (PWA)
To make the timer installable on a phone or desktop and work offline, you need two extra files next to index.html.
manifest.json tells the browser what the app is called and what icon to use:
{
"name": "Pomodoro Focus Timer",
"short_name": "POMO",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#00ffc8",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}sw.js is a service worker. It caches your files so the app loads with no internet:
const CACHE = 'pomo-v1';
const ASSETS = ['./index.html', './manifest.json'];
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
});
self.addEventListener('fetch', e => {
e.respondWith(
caches.match(e.request).then(cached => cached || fetch(e.request))
);
});Then link them in your <head> and register the service worker:
<link rel="manifest" href="manifest.json"/>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js');
}
</script>Now when someone visits over HTTPS, Chrome shows an “Install” button in the address bar, and the app runs offline.
Publishing it for free
You don’t need a server. Three options, all free, all give you the HTTPS URL the PWA install needs.
Netlify Drop (fastest)
- Go to app.netlify.com/drop.
- Drag your project folder, or a zip of it, onto the drop zone.
- A few seconds later you have a live URL like
your-site.netlify.app.
No account needed for that first URL, but the anonymous version is temporary. Make a free account when prompted to claim it and keep it. After that you can rename it under Site settings, and you redeploy by dragging a new folder onto the Deploys page.
Cloudflare Pages
- Go to dash.cloudflare.com, open Workers & Pages, and click Create application.
- Choose Upload your static files (or the Pages “Get started” link for direct upload).
- Name the project first. Leave it blank and Cloudflare hands you something like
plain-bar-8396, and there’s no clean rename afterward, you just redeploy. - Drag your unzipped folder in and hit Deploy.
You land on your-project.pages.dev. The free tier has no bandwidth cap, which is the main reason to reach for it.
GitHub Pages
- Create a new public repo and put all your files in the root.
- In the repo, go to Settings, then Pages.
- Under Source, pick Deploy from a branch, choose
mainand/ (root), and Save. - A minute later it’s live at
https://username.github.io/reponame.
Free GitHub Pages only serves public repos, so skip it for anything you want kept private.
Where to go next
That’s a working app, built from scratch, that you understand top to bottom. A few ways to take it further:
- Multiple themes. Store color values in CSS variables (
—accent: #00ffc8) and swap them with a class on<body>. That’s how the full version does its 8 themes. - Saved presets. You can already set any duration (Stage 3.5). Go further and let users save named combos, a “Deep Work” 50/10, a “Sprint” 15/3, switched with one click.
- Ambient sounds. Loop rain or café noise during focus sessions with the same Web Audio API.
- Data export. Turn the
logarray into a CSV string and trigger a download, so users can crunch their habits in a spreadsheet. - A real streak counter. Compare today’s date to the last active date and count consecutive study days.
The point of building with plain HTML, CSS, and JavaScript is that nothing is hidden. Every line is yours to read and change. Open the file, change a color, break something, fix it. That’s how it sticks.