Pomodoro Timer with Task Management
Build a browser-based Pomodoro timer with task tracking in vanilla JS.
Introduction
30 Second Summary
Staying focused is hard. Between notifications, open tabs, and wandering thoughts, deep work often slips away without a system to protect it. The Pomodoro Technique is one of the most popular productivity methods in the world. It uses timed work sessions and short breaks to keep you locked in.
In this project, you will build a fully functional Pomodoro timer web app with built-in task management using vanilla HTML, CSS, and JavaScript.
What You'll Build
A browser-based productivity app that combines a countdown timer with a task list, all built from scratch with no frameworks or dependencies.
By the end of this project, you'll have:
- A countdown timer that cycles between 25-minute work sessions and 5-minute breaks with audio feedback.
- A task manager that lets you add, complete, and delete tasks with data that persists across page refreshes using localStorage.
- A polished, responsive web app styled with CSS custom properties that visually shifts between work mode and break mode.
- Secret Mission: Add a session counter that tracks completed Pomodoros and triggers a long break every 4 sessions.
Are there any prerequisites?
None. This guide includes full setup instructions for all required tools. You just need a computer with internet access.
Before We Start
Before you can build a Pomodoro timer, you need a place to write code and a way to see it in action.
In this step, you will set up VS Code as your code editor and create the three files that make up your app. Then you will open your project in Google Chrome to confirm everything is connected.
In this step, get ready to:
- Install VS Code and Google Chrome (or confirm they are installed).
- Create a project folder with three files: index.html, style.css, and script.js.
- Open your project in the browser to confirm the development workflow.
Install VS Code
VS Code is a free code editor that you will use to write all three project files.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter.
✔️ VS Code opened
VS Code is ready to go. Move on to the next section.
ⓧ I don't have VS Code installed
- Open Google Chrome (or any browser) and go to https://code.visualstudio.com.
- Click the download button for your operating system.
- Run the installer and follow the prompts to complete the installation.
- Once installed, open VS Code using the search bar instructions above.
Confirm Google Chrome is installed
You will use Google Chrome to view your app as you build it.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Google Chrome and press Enter.
✔️ Chrome opened
Chrome is ready. Move on to the next section.
ⓧ I don't have Chrome installed
- Open any browser you currently have and go to https://www.google.com/chrome.
- Click Download Chrome and run the installer.
- Once installed, open Chrome using the search bar instructions above.
Create your project folder and files
Your Pomodoro timer app is made up of three files that each handle a different responsibility. You will create these files inside a dedicated project folder on your Desktop.
- In VS Code, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir pomodoro-timer
cd pomodoro-timer
- Create the three project files by running this command:
macOS
touch index.html style.css script.js
Windows
New-Item index.html, style.css, script.js -ItemType File
- Confirm the files exist by running:
ls
You should see index.html, style.css, and script.js listed in the output.
Don't see all three files?
Make sure you ran the file creation command from inside the pomodoro-timer folder. Run pwd to check your current location.
If you are in the wrong folder, run cd ~/Desktop/pomodoro-timer and try the file creation command again.
help me create files in my project folder
Open the project folder in VS Code
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the pomodoro-timer folder, and click Open.
You should see your three files listed in the Explorer sidebar on the left.
What are these three files for?
- index.html defines the structure of your app (the buttons, text, and input fields).
- style.css controls how the app looks (colors, layout, fonts).
- script.js makes the app interactive (the countdown, task management, and data saving).
Open your project in Google Chrome
Opening your HTML file in Chrome lets you see every change you make to the app.
- In the VS Code Explorer sidebar, right-click on index.html.
- Click Reveal in Finder (macOS) or Reveal in File Explorer (Windows).
- Double-click index.html in the file browser to open it in your default browser.
- If it does not open in Chrome, copy the URL from the address bar and paste it into Chrome.
You should see a completely blank white page. This is correct because your HTML file is empty.
Keep Chrome and VS Code side by side
Position your browser next to VS Code so you can see changes as you make them. Every time you save a file in VS Code, refresh Chrome to see the update.
Seeing a file download instead of a web page?
Make sure you are opening the file in Chrome, not downloading it. Copy the file path from the address bar (it starts with file://) and paste it directly into a new Chrome tab.
help me open my HTML file in Chrome
Your development environment is ready. Next up, you will build the HTML structure that gives your timer and task list their shape.
Build the HTML Structure
Every web app starts with HTML as its foundation. HTML defines the structure and content that users see and interact with.
In this step, you'll build the skeleton of your Pomodoro timer app. You'll create a timer display with a countdown and controls, plus a task list for managing your to-dos.
In this step, get ready to:
- Build the page skeleton with the timer countdown and control buttons.
- Add the task management interface below the timer.
- Connect your stylesheet and script files to the page.
Create the page skeleton and timer display
The timer section is the heart of your app. It needs a label showing whether you're in a work session or break, a large countdown display, and three buttons to control the timer.
- Open index.html in VS Code and replace any existing content with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pomodoro Timer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Pomodoro Timer</h1>
<div class="timer-section">
<div class="session-label" id="session-label">Work Session</div>
<div class="timer-display" id="timer-display">25:00</div>
<div class="timer-controls">
<button id="start-btn" class="btn btn-start">Start</button>
<button id="pause-btn" class="btn btn-pause" disabled>Pause</button>
<button id="reset-btn" class="btn btn-reset">Reset</button>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
What does this code do?
The <head> section sets up the page metadata and links your stylesheet. The <link rel="stylesheet" href="style.css"> tag connects your CSS file so styles will apply once you write them.
Inside timer-section, the session-label div shows whether you're working or on break. The timer-display div holds the countdown starting at 25:00. The three buttons each have unique IDs so your JavaScript can target them later.
The Pause button has a disabled attribute because you can't pause a timer that hasn't started yet.
- Save index.html.
- Refresh your browser. You should see the text "Pomodoro Timer", "Work Session", "25:00", and three buttons labeled Start, Pause, and Reset.
Don't see any content in the browser?
- Make sure you saved the file in VS Code. Look for the dot on the file tab that indicates unsaved changes.
- Confirm you're refreshing the correct tab in Chrome. The URL should point to your index.html file.
- Check that your opening and closing HTML tags are properly matched. A missing > or </div> can hide content.
Why isn't my HTML showing up in the browser?
Add the task management section
Below the timer, your app needs a place to manage tasks. This section includes a text input for adding new tasks and a list container where they'll appear.
- In index.html, find the closing </div> of the timer-section (the line right above the closing container </div>).
- Add the following code between the timer-section closing </div> and the container closing </div>:
<div class="tasks-section">
<h2>Tasks</h2>
<div class="task-input-container">
<input type="text" id="task-input" placeholder="Add a new task..." />
<button id="add-task-btn" class="btn btn-add">Add</button>
</div>
<ul id="task-list" class="task-list"></ul>
</div>
What does this code do?
The task-input-container groups the text input and the Add button together. The input has a placeholder attribute so learners see helpful hint text before typing.
The empty <ul> with id task-list is where tasks will appear. It starts empty because your JavaScript will dynamically add list items when you create tasks.
- Save index.html.
- Refresh your browser. You should now see a "Tasks" heading, a text input with placeholder text "Add a new task...", and an Add button below the timer.
Don't see the Tasks section?
- Make sure you pasted the code inside the container div, not outside it. The tasks section should sit between the timer-section closing tag and the container closing tag.
- Check that you didn't accidentally delete the container's closing </div> tag when pasting.
My task section isn't showing up after the timer. Can you help?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete index.html file. Make sure yours matches this exactly.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pomodoro Timer</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Pomodoro Timer</h1>
<div class="timer-section">
<div class="session-label" id="session-label">Work Session</div>
<div class="timer-display" id="timer-display">25:00</div>
<div class="timer-controls">
<button id="start-btn" class="btn btn-start">Start</button>
<button id="pause-btn" class="btn btn-pause" disabled>Pause</button>
<button id="reset-btn" class="btn btn-reset">Reset</button>
</div>
</div>
<div class="tasks-section">
<h2>Tasks</h2>
<div class="task-input-container">
<input type="text" id="task-input" placeholder="Add a new task..." />
<button id="add-task-btn" class="btn btn-add">Add</button>
</div>
<ul id="task-list" class="task-list"></ul>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
Understand your CSS and JavaScript connections
Your HTML file already has two important lines that connect it to the other files in your project. These connections are what make your app look styled and behave interactively.
How do the three files connect?
The <link rel="stylesheet" href="style.css"> tag in the <head> tells the browser to load your stylesheet. Any CSS rules you write in style.css will automatically apply to this page.
The <script src="script.js"></script> tag at the bottom of the body loads your JavaScript. It's placed at the bottom so the HTML elements load first, giving your script access to all the buttons and displays on the page.
- Refresh your browser one more time to confirm you see the full page: the title, timer display showing 25:00, three control buttons, and the task input area.
Why does it look unstyled?
Right now your page is plain black text on a white background. That's expected. Your style.css file is still empty. In the next step, you'll add CSS to transform this raw structure into a polished, modern interface.
Your HTML skeleton is complete. Next up, you'll style the app with CSS to give it a clean, modern look with color-coded sessions.
Style the App with CSS
In the previous step, you built the full HTML structure for your Pomodoro timer and task manager. Right now, if you open the page in Chrome, you see unstyled text and default browser buttons.
In this step, you will transform that bare HTML into a polished, dark-themed productivity app using CSS custom properties. The app will visually shift between work mode and break mode by swapping color variables.
In this step, get ready to:
- Define CSS custom properties for a dark color scheme that switches between work and break modes.
- Style the timer display with a large countdown and color-coded session indicator.
- Style the task list with completion states, hover effects, and a responsive layout.
Define CSS custom properties and base styles
CSS custom properties (also called CSS variables) let you define colors once and reuse them throughout the file. Later, your JavaScript will swap a class to toggle between work and break colors without rewriting any CSS.
- Open style.css in VS Code.
- Add the custom properties, a universal reset, and the base layout by pasting this code:
:root {
--bg-color: #1a1a2e;
--container-bg: #16213e;
--text-color: #eaeaea;
--accent-color: #e94560;
--accent-hover: #ff6b6b;
--success-color: #4ecdc4;
--break-color: #4ecdc4;
--work-color: #e94560;
--task-bg: #0f3460;
--task-completed: #6c757d;
--border-radius: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background-color: var(--container-bg);
border-radius: var(--border-radius);
padding: 40px;
max-width: 500px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
h1 {
text-align: center;
margin-bottom: 30px;
font-size: 1.8rem;
}
h2 {
margin-bottom: 15px;
font-size: 1.3rem;
}
What does this code do?
The :root selector defines 11 color and spacing variables. Every style below references these variables with var(--name), so changing a single value updates the entire app.
The universal selector * removes default browser margins and padding. The body uses flexbox to center the container vertically and horizontally on the page.
- Save style.css and refresh your browser.
- You should see a dark blue background filling the entire page, with your content centered in a slightly lighter card container.
Still seeing a white background?
Make sure you saved style.css and not a different file.
Check that your index.html contains <link rel="stylesheet" href="style.css"> inside the <head> tag. The filename must match exactly, including lowercase.
help me debug why my CSS file is not loading
Style the timer display and controls
The timer is the visual centerpiece of the app. You will make the countdown large and prominent, add a color-coded session label, and style the control buttons.
- In style.css, add the timer section layout and session label styles below your existing code:
.timer-section {
text-align: center;
margin-bottom: 40px;
}
.session-label {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 10px;
color: var(--work-color);
text-transform: uppercase;
letter-spacing: 2px;
}
.session-label.break-mode {
color: var(--break-color);
}
.timer-display {
font-size: 4.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-bottom: 25px;
letter-spacing: 2px;
}
.timer-controls {
display: flex;
gap: 12px;
justify-content: center;
}
What does this code do?
The .session-label is red by default (work mode). When JavaScript adds the .break-mode class, it switches to teal. This is how the app visually communicates which session you are in.
The font-variant-numeric: tabular-nums property makes digits equal-width. Without it, the countdown display would jitter left and right as numbers like 1 (narrow) swap with 0 (wide).
- Save style.css and refresh your browser.
- You should see "WORK SESSION" in red uppercase text, a large 25:00 countdown below it, and three unstyled buttons in a row.
Timer text looks tiny or left-aligned?
Check that you typed .timer-section with the period prefix. Without the dot, CSS targets an HTML element called timer-section which does not exist.
help me figure out why my timer styles are not applying
- Now add the button styles below the timer controls code you just pasted:
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.btn:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-start {
background-color: var(--success-color);
color: #1a1a2e;
}
.btn-start:hover:not(:disabled) {
background-color: #5fe0d8;
}
.btn-pause {
background-color: #f39c12;
color: #1a1a2e;
}
.btn-pause:hover:not(:disabled) {
background-color: #f5b041;
}
.btn-reset {
background-color: var(--accent-color);
color: #fff;
}
.btn-reset:hover:not(:disabled) {
background-color: var(--accent-hover);
}
What does this code do?
The .btn class provides shared styles for all buttons. Each variant (.btn-start, .btn-pause, .btn-reset) adds its own color.
The :hover:not(:disabled) selector means "apply this style on hover, but only if the button is not disabled." This prevents the Pause button from looking clickable when it is greyed out.
- Save style.css and refresh your browser.
- The three buttons should now be teal (Start), amber (Pause), and red (Reset). The Pause button should look faded because it has the disabled attribute.
Buttons are colored but Pause does not look faded?
Open your index.html and confirm the Pause button has the disabled attribute. It should read <button id="pause-btn" class="btn btn-pause" disabled>Pause</button>.
help me debug why my disabled button style is not working
Style the task list and add responsiveness
The task section sits below the timer. You will style the input field, give task items a card look, and add a strikethrough effect for completed tasks.
- In style.css, add the task input and container styles below your button code:
.tasks-section {
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 25px;
}
.task-input-container {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
#task-input {
flex: 1;
padding: 12px 16px;
border: none;
border-radius: 8px;
background-color: var(--task-bg);
color: var(--text-color);
font-size: 1rem;
}
#task-input::placeholder {
color: #8892a4;
}
.btn-add {
background-color: var(--accent-color);
color: #fff;
}
.btn-add:hover {
background-color: var(--accent-hover);
}
What does this code do?
The .tasks-section has a subtle top border to visually separate it from the timer. The input uses flex: 1 to fill all available space, pushing the Add button to the right edge.
- Save style.css and refresh your browser.
- You should see a dark input field with placeholder text and a red Add button sitting side by side below the Tasks heading.
Input and button stacking vertically?
Check that .task-input-container has display: flex. Without it, the input and button sit in the default block flow.
help me fix my task input layout
- Now add the task item styles and the responsive media query at the bottom of style.css:
.task-list {
list-style: none;
}
.task-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background-color: var(--task-bg);
border-radius: 8px;
margin-bottom: 8px;
transition: opacity 0.2s;
}
.task-item.completed .task-text {
text-decoration: line-through;
color: var(--task-completed);
}
.task-text {
flex: 1;
font-size: 0.95rem;
}
.task-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--success-color);
}
.task-delete-btn {
background: none;
border: none;
color: var(--accent-color);
font-size: 1.2rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.task-delete-btn:hover {
background-color: rgba(233, 69, 96, 0.2);
}
@media (max-width: 480px) {
.container {
padding: 25px;
}
.timer-display {
font-size: 3.5rem;
}
.timer-controls {
flex-wrap: wrap;
}
}
What does this code do?
The .task-item.completed .task-text rule adds a strikethrough to any task marked done. JavaScript will add and remove the completed class.
The @media query at the bottom shrinks the timer font and allows buttons to wrap on screens narrower than 480px. This keeps the app usable on phones.
- Save style.css and refresh your browser.
- Your app should now have a complete dark-themed look. The timer shows a large countdown in the center, the buttons are color-coded, and the task input area has a clean card design.
Where are the task items?
The task list is empty because no tasks have been added yet. The .task-item styles will take effect once your JavaScript creates list items dynamically in a later step.
Page looks correct on desktop but not on mobile?
To test the responsive styles, open DevTools (press F12) and click the device toolbar icon to simulate a narrow screen. If buttons still overflow, confirm you have flex-wrap: wrap inside the @media block.
help me test my responsive CSS in Chrome DevTools
✔️ Awesome, I've got everything!
Great. Double check you have saved style.css before moving on.
ⓧ I'd like to double check the full code
Here is the complete style.css file. Compare it with yours line by line.
:root {
--bg-color: #1a1a2e;
--container-bg: #16213e;
--text-color: #eaeaea;
--accent-color: #e94560;
--accent-hover: #ff6b6b;
--success-color: #4ecdc4;
--break-color: #4ecdc4;
--work-color: #e94560;
--task-bg: #0f3460;
--task-completed: #6c757d;
--border-radius: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background-color: var(--container-bg);
border-radius: var(--border-radius);
padding: 40px;
max-width: 500px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
h1 {
text-align: center;
margin-bottom: 30px;
font-size: 1.8rem;
}
h2 {
margin-bottom: 15px;
font-size: 1.3rem;
}
.timer-section {
text-align: center;
margin-bottom: 40px;
}
.session-label {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 10px;
color: var(--work-color);
text-transform: uppercase;
letter-spacing: 2px;
}
.session-label.break-mode {
color: var(--break-color);
}
.timer-display {
font-size: 4.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-bottom: 25px;
letter-spacing: 2px;
}
.timer-controls {
display: flex;
gap: 12px;
justify-content: center;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.btn:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-start {
background-color: var(--success-color);
color: #1a1a2e;
}
.btn-start:hover:not(:disabled) {
background-color: #5fe0d8;
}
.btn-pause {
background-color: #f39c12;
color: #1a1a2e;
}
.btn-pause:hover:not(:disabled) {
background-color: #f5b041;
}
.btn-reset {
background-color: var(--accent-color);
color: #fff;
}
.btn-reset:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.tasks-section {
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 25px;
}
.task-input-container {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
#task-input {
flex: 1;
padding: 12px 16px;
border: none;
border-radius: 8px;
background-color: var(--task-bg);
color: var(--text-color);
font-size: 1rem;
}
#task-input::placeholder {
color: #8892a4;
}
.btn-add {
background-color: var(--accent-color);
color: #fff;
}
.btn-add:hover {
background-color: var(--accent-hover);
}
.task-list {
list-style: none;
}
.task-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background-color: var(--task-bg);
border-radius: 8px;
margin-bottom: 8px;
transition: opacity 0.2s;
}
.task-item.completed .task-text {
text-decoration: line-through;
color: var(--task-completed);
}
.task-text {
flex: 1;
font-size: 0.95rem;
}
.task-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: var(--success-color);
}
.task-delete-btn {
background: none;
border: none;
color: var(--accent-color);
font-size: 1.2rem;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.task-delete-btn:hover {
background-color: rgba(233, 69, 96, 0.2);
}
@media (max-width: 480px) {
.container {
padding: 25px;
}
.timer-display {
font-size: 3.5rem;
}
.timer-controls {
flex-wrap: wrap;
}
}
Your app looks polished and ready. Next up, you will write the JavaScript that makes the timer count down, switch sessions, and play an alert when time is up.
Build the Timer Logic
Your app looks great, but right now the timer is just a static number on the screen. In this step, you will bring it to life with JavaScript.
A Pomodoro timer needs to count down every second, respond to button clicks, and automatically switch between work and break modes. You will use setInterval to drive the countdown and manage the timer's state with a few simple variables.
In this step, get ready to:
- Implement a countdown that ticks every second using setInterval.
- Add Start, Pause, and Reset controls with proper state management.
- Build automatic session switching with an audio alert when the timer hits zero.
Set up the timer state and countdown function
Before the timer can count down, your code needs to know how much time is left, whether it is running, and which HTML elements to update. These variables and references form the foundation for every timer function.
- Open script.js in VS Code and add the following code:
// Timer State
let timeRemaining = 25 * 60;
let isRunning = false;
let intervalId = null;
let isWorkSession = true;
const WORK_DURATION = 25 * 60;
const BREAK_DURATION = 5 * 60;
// DOM Elements
const timerDisplay = document.getElementById('timer-display');
const sessionLabel = document.getElementById('session-label');
const startBtn = document.getElementById('start-btn');
const pauseBtn = document.getElementById('pause-btn');
const resetBtn = document.getElementById('reset-btn');
const taskInput = document.getElementById('task-input');
const addTaskBtn = document.getElementById('add-task-btn');
const taskList = document.getElementById('task-list');
// Timer Functions
function updateDisplay() {
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
timerDisplay.textContent =
minutes.toString().padStart(2, '0') + ':' +
seconds.toString().padStart(2, '0');
}
function startTimer() {
if (isRunning) return;
isRunning = true;
startBtn.disabled = true;
pauseBtn.disabled = false;
intervalId = setInterval(function () {
timeRemaining--;
updateDisplay();
if (timeRemaining <= 0) {
clearInterval(intervalId);
intervalId = null;
isRunning = false;
playAlertSound();
switchSession();
}
}, 1000);
}
What does this code do?
The first section declares state variables. timeRemaining stores the countdown in seconds (25 minutes = 1500 seconds). isRunning tracks whether the timer is actively counting. intervalId holds the reference returned by setInterval so you can cancel it later.
The DOM Elements section grabs references to every HTML element your JavaScript needs to read or update. Caching these in variables avoids repeated lookups.
The updateDisplay() function converts timeRemaining (in seconds) into a MM:SS string and writes it to the page. startTimer() fires a callback every 1000 milliseconds (1 second) that decrements the counter, refreshes the display, and checks if time has run out.
What is setInterval?
setInterval repeatedly calls a function with a fixed time delay between each call. It returns an interval ID that you pass to clearInterval() when you want to stop it. For a Pomodoro timer that only needs second-level accuracy, this approach is simple and reliable.
- Save script.js.
You will not see a change yet. These variables and functions need event listeners to activate, which you will add in the next substep.
Add Pause and Reset controls
A timer that can only start is not very useful. The learner needs to pause mid-session and reset back to 25 minutes if they want a fresh start.
- In script.js, add the following code below the startTimer() function:
function pauseTimer() {
if (!isRunning) return;
isRunning = false;
clearInterval(intervalId);
intervalId = null;
startBtn.disabled = false;
pauseBtn.disabled = true;
}
function resetTimer() {
pauseTimer();
isWorkSession = true;
timeRemaining = WORK_DURATION;
updateDisplay();
sessionLabel.textContent = 'Work Session';
sessionLabel.classList.remove('break-mode');
startBtn.disabled = false;
pauseBtn.disabled = true;
}
What does this code do?
pauseTimer() stops the countdown by calling clearInterval(intervalId). This cancels the repeating callback. It also flips the button states so the learner can click Start again to resume.
resetTimer() calls pauseTimer() first (to stop any active countdown), then resets all state back to a fresh 25-minute work session. It updates the display and the session label so the UI matches.
- Save script.js.
You will not see a change yet. The next substep adds session switching, event listeners, and the initialization call that brings everything online.
Implement session switching and wire up the buttons
The Pomodoro Technique alternates between 25-minute work sessions and 5-minute breaks. When the timer hits zero, the app needs to toggle modes, update the display color, and play a short beep so you know the session ended.
- In script.js, add the following code below the resetTimer() function:
function switchSession() {
isWorkSession = !isWorkSession;
if (isWorkSession) {
timeRemaining = WORK_DURATION;
sessionLabel.textContent = 'Work Session';
sessionLabel.classList.remove('break-mode');
} else {
timeRemaining = BREAK_DURATION;
sessionLabel.textContent = 'Break Time';
sessionLabel.classList.add('break-mode');
}
updateDisplay();
startBtn.disabled = false;
pauseBtn.disabled = true;
}
function playAlertSound() {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.value = 0.3;
oscillator.start();
gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.8);
oscillator.stop(audioContext.currentTime + 0.8);
}
// Timer Event Listeners
startBtn.addEventListener('click', startTimer);
pauseBtn.addEventListener('click', pauseTimer);
resetBtn.addEventListener('click', resetTimer);
// Initialize
updateDisplay();
What does this code do?
switchSession() toggles between work and break modes. It flips isWorkSession, sets the new duration, updates the label text, and adds or removes the break-mode CSS class that changes the label color from red to teal.
playAlertSound() uses the Web Audio API to generate a short beep without needing an audio file. It creates an oscillator at 800 Hz, ramps the volume down over 0.8 seconds, and stops.
The event listeners connect each button to its function. The final updateDisplay() call ensures the timer shows the correct time as soon as the page loads.
- Save script.js and refresh your browser.
- Click Start and watch the timer count down from 25:00. The number should decrease by one second each tick.
- Click Pause to stop the countdown, then click Start again to resume from where it left off.
- Click Reset to return the timer to 25:00 and the label to Work Session.
Test session switching quickly
To test session switching without waiting 25 minutes, temporarily change WORK_DURATION to 5 (5 seconds) at the top of script.js. Save, refresh, and click Start. After 5 seconds you should hear a beep and see the label change to Break Time in teal. Remember to change it back to 25 * 60 when you are done testing.
Timer not counting down?
Make sure you saved script.js and did a hard refresh in Chrome (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to clear cached scripts.
Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and check for red error messages. A typo in an element ID like timer-display will cause a "Cannot read properties of null" error.
help me debug my Pomodoro timer - the countdown is not starting when I click Start
✔️ Awesome, I've got everything!
Great. Double check you have saved script.js and confirmed the timer counts down, pauses, and resets.
ⓧ I'd like to double check the full code
Here is the complete script.js file at the end of this step:
// Timer State
let timeRemaining = 25 * 60;
let isRunning = false;
let intervalId = null;
let isWorkSession = true;
const WORK_DURATION = 25 * 60;
const BREAK_DURATION = 5 * 60;
// DOM Elements
const timerDisplay = document.getElementById('timer-display');
const sessionLabel = document.getElementById('session-label');
const startBtn = document.getElementById('start-btn');
const pauseBtn = document.getElementById('pause-btn');
const resetBtn = document.getElementById('reset-btn');
const taskInput = document.getElementById('task-input');
const addTaskBtn = document.getElementById('add-task-btn');
const taskList = document.getElementById('task-list');
// Timer Functions
function updateDisplay() {
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
timerDisplay.textContent =
minutes.toString().padStart(2, '0') + ':' +
seconds.toString().padStart(2, '0');
}
function startTimer() {
if (isRunning) return;
isRunning = true;
startBtn.disabled = true;
pauseBtn.disabled = false;
intervalId = setInterval(function () {
timeRemaining--;
updateDisplay();
if (timeRemaining <= 0) {
clearInterval(intervalId);
intervalId = null;
isRunning = false;
playAlertSound();
switchSession();
}
}, 1000);
}
function pauseTimer() {
if (!isRunning) return;
isRunning = false;
clearInterval(intervalId);
intervalId = null;
startBtn.disabled = false;
pauseBtn.disabled = true;
}
function resetTimer() {
pauseTimer();
isWorkSession = true;
timeRemaining = WORK_DURATION;
updateDisplay();
sessionLabel.textContent = 'Work Session';
sessionLabel.classList.remove('break-mode');
startBtn.disabled = false;
pauseBtn.disabled = true;
}
function switchSession() {
isWorkSession = !isWorkSession;
if (isWorkSession) {
timeRemaining = WORK_DURATION;
sessionLabel.textContent = 'Work Session';
sessionLabel.classList.remove('break-mode');
} else {
timeRemaining = BREAK_DURATION;
sessionLabel.textContent = 'Break Time';
sessionLabel.classList.add('break-mode');
}
updateDisplay();
startBtn.disabled = false;
pauseBtn.disabled = true;
}
function playAlertSound() {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.value = 0.3;
oscillator.start();
gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.8);
oscillator.stop(audioContext.currentTime + 0.8);
}
// Timer Event Listeners
startBtn.addEventListener('click', startTimer);
pauseBtn.addEventListener('click', pauseTimer);
resetBtn.addEventListener('click', resetTimer);
// Initialize
updateDisplay();
Your timer is fully functional. You can start, pause, and reset work sessions, and the app automatically switches to break mode with a satisfying beep. Next up, you will build the task manager so you can track what to work on during each Pomodoro.
Build the Task Manager
Your timer is counting down, switching sessions, and playing sounds. Now you need something to actually track what you're working on during those 25-minute sessions.
In this step, you'll build a task manager that lets you add, complete, and delete tasks. You'll also wire up localStorage so your tasks survive page refreshes.
In this step, get ready to:
- Implement add-task functionality that creates task objects.
- Build mark-complete and delete features with DOM updates.
- Add localStorage persistence so tasks survive page reloads.
Implement add-task functionality
Each task in the app is a JavaScript object with three properties: an id (a unique number), text (what the task says), and completed (whether it's done).
- In script.js, find the comment // Initialize near the bottom of the file.
- Add the following code above the // Initialize comment:
// Task Management
let tasks = [];
function addTask() {
const text = taskInput.value.trim();
if (text === '') return;
const task = {
id: Date.now(),
text: text,
completed: false
};
tasks.push(task);
saveTasks();
renderTasks();
taskInput.value = '';
}
What does this code do?
let tasks = [] creates an empty array that holds all your task objects.
The addTask() function reads the input field, creates a task object with a unique ID (using Date.now() for a timestamp-based ID), then pushes it into the array. It calls saveTasks() and renderTasks() which you'll build next.
You won't see a change yet. The addTask() function calls saveTasks() and renderTasks() which don't exist yet. The next two substeps build those.
Implement mark-complete and delete functionality
Tasks need two more interactions: toggling their completion state and deleting them entirely.
- In script.js, add the following code directly below the addTask() function you just wrote:
function toggleTask(id) {
const task = tasks.find(function (t) { return t.id === id; });
if (task) {
task.completed = !task.completed;
saveTasks();
renderTasks();
}
}
function deleteTask(id) {
tasks = tasks.filter(function (t) { return t.id !== id; });
saveTasks();
renderTasks();
}
What does this code do?
- toggleTask(id) uses .find() to locate the task by its unique ID, then flips its completed property between true and false.
- deleteTask(id) uses .filter() to create a new array with every task except the one being deleted.
Now you need the function that actually draws tasks on screen. The renderTasks() function rebuilds the entire task list in the DOM every time something changes.
- Add the following code directly below the deleteTask() function:
function renderTasks() {
taskList.innerHTML = '';
tasks.forEach(function (task) {
const li = document.createElement('li');
li.className = 'task-item' + (task.completed ? ' completed' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'task-checkbox';
checkbox.checked = task.completed;
checkbox.addEventListener('change', function () {
toggleTask(task.id);
});
const span = document.createElement('span');
span.className = 'task-text';
span.textContent = task.text;
const deleteBtn = document.createElement('button');
deleteBtn.className = 'task-delete-btn';
deleteBtn.textContent = '\u00D7';
deleteBtn.addEventListener('click', function () {
deleteTask(task.id);
});
li.appendChild(checkbox);
li.appendChild(span);
li.appendChild(deleteBtn);
taskList.appendChild(li);
});
}
What does this code do?
This function uses DOM manipulation to build each task as a list item with three child elements:
- A checkbox that calls toggleTask() when checked or unchecked.
- A span showing the task text.
- A delete button (the × symbol) that calls deleteTask() when clicked.
It clears the list first with taskList.innerHTML = '', then rebuilds it from scratch. This "clear and redraw" approach keeps the DOM in sync with the data.
You still won't see results yet. The final piece is connecting everything with localStorage and event listeners.
Add localStorage persistence
Without persistence, refreshing the page wipes all your tasks. localStorage stores data in the browser that survives page reloads.
- In script.js, find the line let tasks = []; that you added earlier.
- Add the following two functions directly below let tasks = []; and above the addTask() function:
function loadTasks() {
const stored = localStorage.getItem('pomodoroTasks');
if (stored) {
tasks = JSON.parse(stored);
}
renderTasks();
}
function saveTasks() {
localStorage.setItem('pomodoroTasks', JSON.stringify(tasks));
}
What does this code do?
- loadTasks() reads the stored string from localStorage using the key pomodoroTasks, then converts it back into a JavaScript array with JSON.parse().
- saveTasks() converts the tasks array into a string with JSON.stringify() and writes it to localStorage. This runs every time a task is added, toggled, or deleted.
Why convert to a string?
localStorage can only store strings. JSON.stringify() converts your array of objects into a string for storage. JSON.parse() converts it back when you load.
Now connect the input field and button to the addTask() function with event listeners.
- Scroll to the bottom of script.js.
- Find the // Initialize comment.
- Add the event listeners above the // Initialize comment, and update the Initialize section to also call loadTasks():
// Task Event Listeners
addTaskBtn.addEventListener('click', addTask);
taskInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
addTask();
}
});
// Initialize
updateDisplay();
loadTasks();
What does this code do?
- The click listener on addTaskBtn triggers addTask() when the learner clicks the **Add** button.
- The keypress listener on taskInput also triggers addTask() when the Enter key is pressed.
- loadTasks() in the Initialize section loads any previously saved tasks when the page first opens.
- Save script.js.
- Refresh your browser.
- Type a task (like Read documentation) in the input field and click **Add**.
- You should see the task appear below with a checkbox on the left and an × button on the right.
- Click the checkbox to mark it complete. The text should get a strikethrough.
- Refresh the page. Your task should still be there.
Tasks not appearing?
- Open DevTools with **Cmd+Option+J** (macOS) or **Ctrl+Shift+J** (Windows) and check the Console for errors. A typo in a function name causes a "not defined" error.
- Make sure loadTasks() and saveTasks() are placed above addTask() in the file. JavaScript reads top to bottom.
- Confirm the old // Initialize section now has both updateDisplay(); and loadTasks(); calls.
Help me debug why my tasks aren't appearing in the Pomodoro app.
✔️ Awesome, I've got everything!
Great. Double check you've saved script.js and confirmed tasks persist across page refreshes.
ⓧ I'd like to double check the full code
Here is the complete script.js file at this point:
// Timer State
let timeRemaining = 25 * 60;
let isRunning = false;
let intervalId = null;
let isWorkSession = true;
const WORK_DURATION = 25 * 60;
const BREAK_DURATION = 5 * 60;
// DOM Elements
const timerDisplay = document.getElementById('timer-display');
const sessionLabel = document.getElementById('session-label');
const startBtn = document.getElementById('start-btn');
const pauseBtn = document.getElementById('pause-btn');
const resetBtn = document.getElementById('reset-btn');
const taskInput = document.getElementById('task-input');
const addTaskBtn = document.getElementById('add-task-btn');
const taskList = document.getElementById('task-list');
// Timer Functions
function updateDisplay() {
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
timerDisplay.textContent =
minutes.toString().padStart(2, '0') + ':' +
seconds.toString().padStart(2, '0');
}
function startTimer() {
if (isRunning) return;
isRunning = true;
startBtn.disabled = true;
pauseBtn.disabled = false;
intervalId = setInterval(function () {
timeRemaining--;
updateDisplay();
if (timeRemaining <= 0) {
clearInterval(intervalId);
intervalId = null;
isRunning = false;
playAlertSound();
switchSession();
}
}, 1000);
}
function pauseTimer() {
if (!isRunning) return;
isRunning = false;
clearInterval(intervalId);
intervalId = null;
startBtn.disabled = false;
pauseBtn.disabled = true;
}
function resetTimer() {
pauseTimer();
isWorkSession = true;
timeRemaining = WORK_DURATION;
updateDisplay();
sessionLabel.textContent = 'Work Session';
sessionLabel.classList.remove('break-mode');
startBtn.disabled = false;
pauseBtn.disabled = true;
}
function switchSession() {
isWorkSession = !isWorkSession;
if (isWorkSession) {
timeRemaining = WORK_DURATION;
sessionLabel.textContent = 'Work Session';
sessionLabel.classList.remove('break-mode');
} else {
timeRemaining = BREAK_DURATION;
sessionLabel.textContent = 'Break Time';
sessionLabel.classList.add('break-mode');
}
updateDisplay();
startBtn.disabled = false;
pauseBtn.disabled = true;
}
function playAlertSound() {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.value = 0.3;
oscillator.start();
gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.8);
oscillator.stop(audioContext.currentTime + 0.8);
}
// Timer Event Listeners
startBtn.addEventListener('click', startTimer);
pauseBtn.addEventListener('click', pauseTimer);
resetBtn.addEventListener('click', resetTimer);
// Task Management
let tasks = [];
function loadTasks() {
const stored = localStorage.getItem('pomodoroTasks');
if (stored) {
tasks = JSON.parse(stored);
}
renderTasks();
}
function saveTasks() {
localStorage.setItem('pomodoroTasks', JSON.stringify(tasks));
}
function addTask() {
const text = taskInput.value.trim();
if (text === '') return;
const task = {
id: Date.now(),
text: text,
completed: false
};
tasks.push(task);
saveTasks();
renderTasks();
taskInput.value = '';
}
function toggleTask(id) {
const task = tasks.find(function (t) { return t.id === id; });
if (task) {
task.completed = !task.completed;
saveTasks();
renderTasks();
}
}
function deleteTask(id) {
tasks = tasks.filter(function (t) { return t.id !== id; });
saveTasks();
renderTasks();
}
function renderTasks() {
taskList.innerHTML = '';
tasks.forEach(function (task) {
const li = document.createElement('li');
li.className = 'task-item' + (task.completed ? ' completed' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'task-checkbox';
checkbox.checked = task.completed;
checkbox.addEventListener('change', function () {
toggleTask(task.id);
});
const span = document.createElement('span');
span.className = 'task-text';
span.textContent = task.text;
const deleteBtn = document.createElement('button');
deleteBtn.className = 'task-delete-btn';
deleteBtn.textContent = '\u00D7';
deleteBtn.addEventListener('click', function () {
deleteTask(task.id);
});
li.appendChild(checkbox);
li.appendChild(span);
li.appendChild(deleteBtn);
taskList.appendChild(li);
});
}
// Task Event Listeners
addTaskBtn.addEventListener('click', addTask);
taskInput.addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
addTask();
}
});
// Initialize
updateDisplay();
loadTasks();
Your Pomodoro timer now has a fully working task manager. You can add tasks, check them off, delete them, and they all persist in the browser. Your productivity app is complete.
Secret mission
Session Counter with Long Breaks
Ready for a real productivity upgrade? Track how many Pomodoros you complete each day and reward yourself with a longer break every 4 sessions. This mirrors the original Pomodoro Technique and gives you practice with conditional logic and persistent counters.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine with no cloud services or paid APIs. There are no ongoing costs.
Decide whether to keep your project files, pause by leaving them in place, or delete everything entirely.
Resources you used:
- Project folder containing index.html, style.css, and script.js.
- Browser localStorage data (the pomodoroTasks key).
- VS Code (if installed specifically for this project).
Keep everything running
No action needed. Choose this if you plan to keep using your Pomodoro timer as a daily productivity tool.
- Your project files stay on your Desktop in the project folder.
- Your tasks remain saved in localStorage and will load whenever you open index.html in Chrome.
- VS Code stays installed for future projects.
Pause - I'll come back to this later
Since this project is entirely local with no running processes, pausing is the same as keeping. Your files and localStorage data sit idle until you return.
- Close the browser tab running your Pomodoro timer.
- Close VS Code if you have the project open.
- Your files and saved tasks will be waiting when you come back.
Delete - I don't want to use this again
Remove all project files, stored data, and optionally VS Code.
- Delete your project folder by running the command for your operating system below.
macOS
rm -rf ~/Desktop/pomodoro-timer
Windows
rmdir /s /q %USERPROFILE%\Desktop\pomodoro-timer
- Clear your localStorage data by opening Chrome DevTools (press Cmd+Option+J on macOS or Ctrl+Shift+J on Windows), then run this in the Console:
localStorage.removeItem('pomodoroTasks');
- Uninstall VS Code (optional, only if you installed it for this project):
- On macOS, open your Applications folder in Finder and drag Visual Studio Code to the Trash.
- On Windows, open Settings, select Apps, then Installed apps. Find Visual Studio Code and click Uninstall.
Nice Work!
Nice Work!
You just built a fully functional Pomodoro Timer with task management using nothing but vanilla HTML, CSS, and JavaScript.
You've learned how to:
- Built a countdown timer with start, pause, and reset controls using setInterval and clearInterval.
- Created automatic work/break session cycling with audio feedback powered by the Web Audio API.
- Built a complete task manager with add, complete, and delete functionality, persisted across page reloads using localStorage with JSON.stringify and JSON.parse.
- Secret Mission: Added a session counter with long breaks every 4 Pomodoros.
Ready to quiz yourself?