Web App · Beginner
Build a To-Do App
Vanilla HTML, CSS, and JavaScript. Persists with localStorage. No frameworks, no build step, no dependencies.
What you'll build: A to-do app where you can add tasks, mark them complete, and delete them. Everything saves automatically to your browser — reload the page, and your tasks are still there.
What you'll learn: DOM manipulation, event handling, state management, rendering from data, and localStorage. These four skills power every modern web app.
The three files
Everything lives in one folder. No build step.
todo-app/
├── index.html ← Structure
├── style.css ← Appearance
└── app.js ← Behavior
index.html — the structure
The HTML is deliberately minimal. Three elements matter: an input, an add button, and a list container. The JavaScript will fill the list.
index.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>To-Do App</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app">
<h1>My Tasks</h1>
<div class="input-row">
<input type="text" id="taskInput" placeholder="Add a task..." />
<button id="addBtn">Add</button>
</div>
<div id="taskList"></div>
<div id="counter"></div>
</div>
<script src="app.js"></script>
</body>
</html>
Why so little HTML? In a real app, JavaScript builds most of the UI from data. You render the structure once, then update it as state changes.
style.css — the appearance
Dark mode by default. Clean spacing. The list items animate on hover. Nothing fancy — just tasteful.
style.css* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 3rem 1rem;
}
.app {
width: 100%;
max-width: 500px;
background: #1e293b;
padding: 1.5rem;
border-radius: 1rem;
}
h1 {
font-size: 1.35rem;
margin-bottom: 1.25rem;
}
.input-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
input[type="text"] {
flex: 1;
background: #0f172a;
border: 1px solid #334155;
color: #e2e8f0;
padding: 0.65rem 0.85rem;
border-radius: 0.5rem;
font-size: 0.9rem;
font-family: inherit;
}
input[type="text"]:focus {
outline: none;
border-color: #3b82f6;
}
#addBtn {
background: #3b82f6;
color: #fff;
border: none;
padding: 0 1rem;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
}
#addBtn:hover { background: #2563eb; }
.task {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.85rem;
background: #0f172a;
border-radius: 0.5rem;
margin-bottom: 0.4rem;
}
.task.done .task-text {
text-decoration: line-through;
color: #64748b;
}
.task-text { flex: 1; font-size: 0.9rem; }
.task input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #22c55e;
}
.delete-btn {
background: none;
border: none;
color: #64748b;
cursor: pointer;
font-size: 1.1rem;
padding: 2px 6px;
border-radius: 0.375rem;
}
.delete-btn:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
#counter {
color: #64748b;
font-size: 0.75rem;
text-align: right;
margin-top: 0.5rem;
}
.empty {
color: #64748b;
font-size: 0.85rem;
text-align: center;
padding: 1.5rem 0;
}
app.js — the behavior
This is where everything happens. Three functions: render(), addTask(), and a small save/load pair for localStorage.
// State — the single source of truth
let tasks = [];
// Grab DOM elements once
const input = document.getElementById('taskInput');
const addBtn = document.getElementById('addBtn');
const list = document.getElementById('taskList');
const counter = document.getElementById('counter');
// ============ STORAGE ============
function save() {
localStorage.setItem('tasks', JSON.stringify(tasks));
}
function load() {
const stored = localStorage.getItem('tasks');
if (stored) tasks = JSON.parse(stored);
}
// ============ RENDER ============
function render() {
list.innerHTML = '';
if (tasks.length === 0) {
list.innerHTML = '<div class="empty">No tasks yet. Add one above.</div>';
} else {
tasks.forEach(task => {
const row = document.createElement('div');
row.className = 'task' + (task.done ? ' done' : '');
const check = document.createElement('input');
check.type = 'checkbox';
check.checked = task.done;
check.addEventListener('change', () => {
task.done = !task.done;
save();
render();
});
const text = document.createElement('span');
text.className = 'task-text';
text.textContent = task.text;
const del = document.createElement('button');
del.className = 'delete-btn';
del.textContent = '✕';
del.addEventListener('click', () => {
tasks = tasks.filter(t => t.id !== task.id);
save();
render();
});
row.append(check, text, del);
list.appendChild(row);
});
}
const remaining = tasks.filter(t => !t.done).length;
counter.textContent = `${remaining} of ${tasks.length} tasks remaining`;
}
// ============ ADD ============
function addTask() {
const text = input.value.trim();
if (!text) return;
tasks.push({
id: Date.now(),
text,
done: false
});
input.value = '';
save();
render();
}
// ============ EVENTS ============
addBtn.addEventListener('click', addTask);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') addTask();
});
// ============ INIT ============
load();
render();
How the code works
The pattern behind this app is state → render → save. Every action follows the same three steps:
tasks holds every task as an object: {id, text, done}. Nothing else stores task data — the array is the truth.
Every time state changes, render() clears the list and rebuilds it from tasks. No partial updates, no bugs from stale DOM.
save() writes the array to localStorage as JSON. On page load, load() reads it back. That's the entire persistence layer.
⚠️ Common problems
You forgot to call save() after mutating tasks, or you didn't call load() on startup. Check that localStorage.setItem('tasks', ...) is present in save().
The script runs before the DOM is ready. Either move <script> to the end of <body>, or wrap the init code in DOMContentLoaded.
You probably mutated task.done without calling render(). The DOM won't update on its own — you have to re-render.
Classic closure bug — you likely used a loop index instead of the task ID. This tutorial uses task.id specifically to avoid that issue.
🎯 What to add next
- Edit existing tasks. Double-click the text, swap in an input, save on blur.
- Filter by status. All / Active / Completed tabs that change what renders.
- Clear completed. One button that filters out
task.done === true. - Drag to reorder. Use the HTML5 drag-and-drop API to reshuffle
tasks. - Add a backend. Replace localStorage with a fetch to an API (Node + Express or Firebase).
- Add categories. Give each task a
tagsarray and filter on that.
❓ Frequently asked questions
What will I learn building a to-do app?
You'll learn DOM manipulation, event handling, state management with a JavaScript array, rendering UI from data, and persisting state with localStorage. These are the four skills behind every modern web app.
How long does this build take?
About 45 minutes if you copy-paste and run it. About 2 hours if you type every line and follow each explanation. Both are valid — the second sticks better.
Do I need to install anything?
No. This build runs entirely in your browser. You need a text editor and a browser. No Node.js, no npm, no build tools.
Why use localStorage instead of a database?
localStorage is perfect for a first project because it requires zero setup. The data persists between page reloads and lives in the user's browser. Once you're comfortable, you can add a backend later.
How do I deploy this app?
Drag the folder to Netlify Drop or push it to GitHub and enable GitHub Pages. Both are free and give you a live URL in under 5 minutes.