Guided Project: To Do List

Build a practical To Do List application step by step using HTML, CSS, and JavaScript.

Project Overview

What Are We Building?

You will build a browser-based To Do List application where a user can add tasks, mark them as completed, delete them, filter them, and keep them saved between page reloads.

User
  ↓
Add task
  ↓
JavaScript stores task
  ↓
UI renders task
  ↓
User completes / deletes task
  ↓
State changes
  ↓
UI updates
  ↓
localStorage saves data

Final Features

  • Add a task.
  • Prevent empty tasks.
  • Mark tasks complete.
  • Delete tasks.
  • Filter all, active, and completed tasks.
  • Show task statistics.
  • Save tasks with localStorage.
  • Restore tasks after refresh.
  • Use responsive CSS.

Tech Stack

Stack: HTML, CSS, and JavaScript

HTML
  ↓
Structure

CSS
  ↓
Layout + Appearance

JavaScript
  ↓
Logic + State + Interaction

HTML Responsibilities

  • Create the form.
  • Create input and buttons.
  • Create the task list container.
  • Provide semantic structure and accessible labels.

CSS Responsibilities

  • Control layout.
  • Style the form and task list.
  • Represent visual states.
  • Make the application responsive.

JavaScript Responsibilities

  • Manage task data.
  • Handle events.
  • Render the task list.
  • Filter and update tasks.
  • Save and restore data.

Learning Goals

By the End of This Project

You should be able to explain and use:

HTML
→ Forms
→ Semantic structure
→ IDs and classes
→ Data attributes

CSS
→ Layout
→ Classes
→ States
→ Responsive design

JavaScript
→ Variables
→ Arrays
→ Objects
→ Functions
→ DOM selection
→ DOM manipulation
→ Events
→ Array methods
→ localStorage
→ JSON
→ State management

Project Requirements

Functional Requirements

  1. The user can enter a task.
  2. Submitting the form adds the task.
  3. Empty tasks are rejected.
  4. Each task can be completed.
  5. Each task can be deleted.
  6. The task count updates.
  7. Tasks persist after refresh.
  8. Tasks can be filtered.

Technical Requirements

  • Use semantic HTML.
  • Use external CSS.
  • Use external JavaScript.
  • Do not use a frontend framework.
  • Use DOM APIs.
  • Use localStorage.
  • Keep application state in JavaScript.

UI Plan

Basic Layout

┌──────────────────────────────────┐
│            TO DO LIST            │
├──────────────────────────────────┤
│ [ Enter a task... ] [ Add Task ] │
├──────────────────────────────────┤
│ [ All ] [ Active ] [ Completed ] │
├──────────────────────────────────┤
│ ☐ Learn HTML              Delete │
│ ☑ Learn CSS               Delete │
│ ☐ Learn JavaScript        Delete │
├──────────────────────────────────┤
│ 2 active · 1 completed           │
└──────────────────────────────────┘

State Model

const state = {
  tasks: [],
  filter: "all"
};

Task Model

{
  id: "unique-id",
  text: "Learn JavaScript",
  completed: false
}

Project Setup

Folder Structure

todo-list/
├── index.html
├── coding.css
└── app.js

Starter 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 List</title>

  <link rel="stylesheet" href="coding.css">
</head>

<body>

  <main>
    <h1>To Do List</h1>
  </main>

  <script
    type="module"
    src="./app.js"
  ></script>

</body>

</html>

Checkpoint

Open the page in the browser. You should see the heading and no console errors.

Step 1: Build the HTML

Goal

Build the complete semantic structure before writing the application logic.

HTML Structure

<main class="todo-app">

  <header class="todo-header">
    <h1>To Do List</h1>
    <p>Stay organized.</p>
  </header>

  <form id="task-form">

    <label for="task-input">
      New task
    </label>

    <input
      id="task-input"
      name="task"
      type="text"
      placeholder="Enter a task..."
      autocomplete="off"
      required
    >

    <button type="submit">
      Add Task
    </button>

  </form>

  <nav aria-label="Task filters">
    <button
      type="button"
      data-filter="all"
    >
      All
    </button>

    <button
      type="button"
      data-filter="active"
    >
      Active
    </button>

    <button
      type="button"
      data-filter="completed"
    >
      Completed
    </button>
  </nav>

  <ul
    id="task-list"
    aria-live="polite"
  ></ul>

  <p id="task-stats"></p>

</main>

Why This Structure?

  • form handles task submission.
  • label makes the input accessible.
  • data-filter stores filter information.
  • ul represents a list of tasks.
  • aria-live can announce dynamic list changes.

Checkpoint

At this point, the page should be structurally complete but not interactive.

Step 2: Style the UI

Goal

Create a clean interface before adding JavaScript behavior.

Base Layout

.todo-app {
  width: min(100% - 32px, 700px);
  margin: 40px auto;
}

#task-form {
  display: grid;
  gap: 10px;
}

#task-input {
  width: 100%;
  padding: 10px 12px;
}

#task-list {
  display: grid;
  gap: 10px;
  margin: 20px 0;
  padding: 0;
  list-style: none;
}

Task Item

.task {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;

  padding: 12px;

  border: 1px solid #555555;
}

.task__actions {
  display: flex;
  gap: 8px;
}

.task.is-complete {
  opacity: 0.6;
}

.task.is-complete .task__text {
  text-decoration: line-through;
}

Filter State

.filter-button.is-active {
  font-weight: 700;
}

Responsive Layout

@media (max-width: 600px) {
  .task {
    align-items: flex-start;
    flex-direction: column;
  }

  .task__actions {
    width: 100%;
  }
}

Checkpoint

You should now have a usable visual layout even though the buttons do nothing yet.

Step 3: Add JavaScript

Select the HTML Elements

const form =
  document.querySelector("#task-form");

const input =
  document.querySelector("#task-input");

const list =
  document.querySelector("#task-list");

const stats =
  document.querySelector("#task-stats");

Create the State

const state = {
  tasks: [],
  filter: "all"
};

Create a Task

function createTask(text) {
  return {
    id: crypto.randomUUID(),
    text,
    completed: false
  };
}

Checkpoint

Open the console and test:

const task =
  createTask("Learn JavaScript");

console.log(task);

Step 4: Handle Events

Handle Form Submission

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const text =
    input.value.trim();

  if (!text) {
    return;
  }

  state.tasks.push(
    createTask(text)
  );

  input.value = "";

  render();
});

Why preventDefault?

A normal form submission can reload or navigate the page. The application handles submission in JavaScript, so the default browser behavior is stopped.

Checkpoint

Submitting the form should add an object to state.tasks.

Step 5: Render Tasks

Create One Task Element

function createTaskElement(task) {
  const item =
    document.createElement("li");

  item.className = "task";

  if (task.completed) {
    item.classList.add("is-complete");
  }

  const text =
    document.createElement("span");

  text.className = "task__text";
  text.textContent = task.text;

  item.append(text);

  return item;
}

Render the List

function renderTasks() {
  list.innerHTML = "";

  for (const task of state.tasks) {
    list.append(
      createTaskElement(task)
    );
  }
}

Render Everything

function render() {
  renderTasks();
  renderStats();
}

Checkpoint

Adding a task should now create a visible task in the list.

Step 6: Complete Tasks

Add a Complete Button

const completeButton =
  document.createElement("button");

completeButton.type = "button";
completeButton.dataset.action = "toggle";
completeButton.dataset.id = task.id;

completeButton.textContent =
  task.completed
    ? "Undo"
    : "Complete";

Handle the Action

function toggleTask(id) {
  state.tasks = state.tasks.map((task) => {
    if (task.id !== id) {
      return task;
    }

    return {
      ...task,
      completed: !task.completed
    };
  });
}

Event Delegation

list.addEventListener("click", (event) => {
  const button =
    event.target.closest(
      "button[data-action]"
    );

  if (!button) {
    return;
  }

  const id = button.dataset.id;
  const action = button.dataset.action;

  if (action === "toggle") {
    toggleTask(id);
    render();
  }
});

Checkpoint

Clicking Complete should add the visual is-complete state. Clicking Undo should restore the active state.

Step 7: Delete Tasks

Add Delete Action

const deleteButton =
  document.createElement("button");

deleteButton.type = "button";
deleteButton.dataset.action = "delete";
deleteButton.dataset.id = task.id;
deleteButton.textContent = "Delete";

Delete Function

function deleteTask(id) {
  state.tasks = state.tasks.filter(
    (task) => task.id !== id
  );
}

Connect the Action

if (action === "delete") {
  deleteTask(id);
  render();
}

Checkpoint

Every task should now have working Complete and Delete controls.

Step 8: localStorage

Why Storage?

Without storage, all tasks disappear when the page is refreshed.

Save Tasks

function saveTasks() {
  localStorage.setItem(
    "todo-tasks",
    JSON.stringify(state.tasks)
  );
}

Load Tasks

function loadTasks() {
  const saved =
    localStorage.getItem("todo-tasks");

  if (!saved) {
    return;
  }

  state.tasks =
    JSON.parse(saved);
}

Save After Changes

function updateAndRender() {
  saveTasks();
  render();
}

Start the Application

loadTasks();
render();

Checkpoint

Add a few tasks, refresh the page, and verify that the tasks are still present.

Step 9: Filters

Filter State

state.filter = "all";

Possible values:

all
active
completed

Filter Function

function getVisibleTasks() {
  if (state.filter === "active") {
    return state.tasks.filter(
      (task) => !task.completed
    );
  }

  if (state.filter === "completed") {
    return state.tasks.filter(
      (task) => task.completed
    );
  }

  return state.tasks;
}

Use the Filter in Rendering

function renderTasks() {
  list.innerHTML = "";

  const tasks =
    getVisibleTasks();

  for (const task of tasks) {
    list.append(
      createTaskElement(task)
    );
  }
}

Filter Buttons

const filterButtons =
  document.querySelectorAll(
    "[data-filter]"
  );

filterButtons.forEach((button) => {
  button.addEventListener("click", () => {
    state.filter =
      button.dataset.filter;

    render();
  });
});

Checkpoint

All should display every task, Active should hide completed tasks, and Completed should show only completed tasks.

Step 10: Statistics

Calculate Counts

function getStats() {
  const total =
    state.tasks.length;

  const completed =
    state.tasks.filter(
      (task) => task.completed
    ).length;

  const active =
    total - completed;

  return {
    total,
    completed,
    active
  };
}

Render Statistics

function renderStats() {
  const {
    total,
    active,
    completed
  } = getStats();

  stats.textContent =
    `${active} active · ` +
    `${completed} completed · ` +
    `${total} total`;
}

Checkpoint

The statistics should update automatically whenever a task is added, completed, or deleted.

Complete Solution

Final 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 List</title>

  <link rel="stylesheet" href="coding.css">
</head>

<body>

  <main class="todo-app">

    <header>
      <h1>To Do List</h1>
      <p>Stay organized and keep moving.</p>
    </header>

    <form id="task-form">

      <label for="task-input">
        New task
      </label>

      <input
        id="task-input"
        name="task"
        type="text"
        placeholder="Enter a task..."
        autocomplete="off"
        required
      >

      <button type="submit">
        Add Task
      </button>

    </form>

    <nav aria-label="Task filters">

      <button
        class="filter-button is-active"
        type="button"
        data-filter="all"
      >
        All
      </button>

      <button
        class="filter-button"
        type="button"
        data-filter="active"
      >
        Active
      </button>

      <button
        class="filter-button"
        type="button"
        data-filter="completed"
      >
        Completed
      </button>

    </nav>

    <ul
      id="task-list"
      aria-live="polite"
    ></ul>

    <p id="task-stats"></p>

  </main>

  <script
    type="module"
    src="./app.js"
  ></script>

</body>

</html>

Final CSS

.todo-app {
  width: min(100% - 32px, 700px);
  margin: 40px auto;
}

.todo-app header {
  margin-bottom: 24px;
}

#task-form {
  display: grid;
  gap: 10px;
}

#task-input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #555555;
  background: #333333;
  color: #eeeeee;
  font: inherit;
}

#task-input:focus-visible {
  outline: 2px solid #ffb92d;
  outline-offset: 2px;
}

#task-form button {
  width: fit-content;
}

nav[aria-label="Task filters"] {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 25px 0;
}

.filter-button {
  cursor: pointer;
}

.filter-button.is-active {
  background: #aaff00;
  color: #000000;
}

#task-list {
  display: grid;
  gap: 10px;

  margin: 20px 0;
  padding: 0;

  list-style: none;
}

.task {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;

  padding: 12px;

  border: 1px solid #555555;
}

.task__text {
  overflow-wrap: anywhere;
}

.task__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.task.is-complete {
  opacity: 0.6;
}

.task.is-complete .task__text {
  text-decoration: line-through;
}

#task-stats {
  color: #aaaaaa;
}

@media (max-width: 600px) {
  .task {
    align-items: flex-start;
    flex-direction: column;
  }

  .task__actions {
    width: 100%;
  }
}

Final JavaScript

const form =
  document.querySelector("#task-form");

const input =
  document.querySelector("#task-input");

const list =
  document.querySelector("#task-list");

const stats =
  document.querySelector("#task-stats");

const filterButtons =
  document.querySelectorAll(
    "[data-filter]"
  );

const state = {
  tasks: [],
  filter: "all"
};

function createTask(text) {
  return {
    id: crypto.randomUUID(),
    text,
    completed: false
  };
}

function saveTasks() {
  localStorage.setItem(
    "todo-tasks",
    JSON.stringify(state.tasks)
  );
}

function loadTasks() {
  const saved =
    localStorage.getItem("todo-tasks");

  if (!saved) {
    return;
  }

  try {
    state.tasks =
      JSON.parse(saved);
  } catch (error) {
    console.error(
      "Could not load saved tasks:",
      error
    );

    state.tasks = [];
  }
}

function getVisibleTasks() {
  if (state.filter === "active") {
    return state.tasks.filter(
      (task) => !task.completed
    );
  }

  if (state.filter === "completed") {
    return state.tasks.filter(
      (task) => task.completed
    );
  }

  return state.tasks;
}

function getStats() {
  const total =
    state.tasks.length;

  const completed =
    state.tasks.filter(
      (task) => task.completed
    ).length;

  const active =
    total - completed;

  return {
    total,
    active,
    completed
  };
}

function createTaskElement(task) {
  const item =
    document.createElement("li");

  item.className = "task";

  if (task.completed) {
    item.classList.add("is-complete");
  }

  const text =
    document.createElement("span");

  text.className = "task__text";
  text.textContent = task.text;

  const actions =
    document.createElement("div");

  actions.className = "task__actions";

  const completeButton =
    document.createElement("button");

  completeButton.type = "button";
  completeButton.dataset.action = "toggle";
  completeButton.dataset.id = task.id;

  completeButton.textContent =
    task.completed
      ? "Undo"
      : "Complete";

  const deleteButton =
    document.createElement("button");

  deleteButton.type = "button";
  deleteButton.dataset.action = "delete";
  deleteButton.dataset.id = task.id;

  deleteButton.textContent = "Delete";

  actions.append(
    completeButton,
    deleteButton
  );

  item.append(
    text,
    actions
  );

  return item;
}

function renderTasks() {
  list.innerHTML = "";

  const tasks =
    getVisibleTasks();

  for (const task of tasks) {
    list.append(
      createTaskElement(task)
    );
  }
}

function renderStats() {
  const {
    total,
    active,
    completed
  } = getStats();

  stats.textContent =
    `${active} active · ` +
    `${completed} completed · ` +
    `${total} total`;
}

function renderFilters() {
  filterButtons.forEach((button) => {
    button.classList.toggle(
      "is-active",
      button.dataset.filter ===
        state.filter
    );
  });
}

function render() {
  renderTasks();
  renderStats();
  renderFilters();
}

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const text =
    input.value.trim();

  if (!text) {
    return;
  }

  state.tasks.push(
    createTask(text)
  );

  input.value = "";

  saveTasks();
  render();

  input.focus();
});

list.addEventListener("click", (event) => {
  const button =
    event.target.closest(
      "button[data-action]"
    );

  if (!button) {
    return;
  }

  const id =
    button.dataset.id;

  const action =
    button.dataset.action;

  if (action === "toggle") {
    state.tasks =
      state.tasks.map((task) => {
        if (task.id !== id) {
          return task;
        }

        return {
          ...task,
          completed:
            !task.completed
        };
      });
  }

  if (action === "delete") {
    state.tasks =
      state.tasks.filter(
        (task) => task.id !== id
      );
  }

  saveTasks();
  render();
});

filterButtons.forEach((button) => {
  button.addEventListener(
    "click",
    () => {
      state.filter =
        button.dataset.filter;

      render();
    }
  );
});

loadTasks();
render();

Testing Checklist

Basic Functionality

  • Add a normal task.
  • Add several tasks.
  • Complete a task.
  • Undo a completed task.
  • Delete a task.

Validation

  • Submit an empty input.
  • Submit spaces only.
  • Submit a long task name.
  • Use special characters.

Filters

  • All shows every task.
  • Active shows only incomplete tasks.
  • Completed shows only completed tasks.
  • The active filter has a visible state.

Persistence

  • Add tasks.
  • Refresh the page.
  • Verify tasks remain.
  • Complete a task and refresh.
  • Delete a task and refresh.

Responsive Testing

Desktop
Tablet
Mobile

Keyboard navigation
Focus states
Small screen overflow

Challenge Extensions

Level 1

  • Add an Edit button.
  • Add a Clear Completed button.
  • Show an empty-state message.

Level 2

  • Add task due dates.
  • Add task priority.
  • Add categories.
  • Sort tasks.

Level 3

  • Add drag-and-drop ordering.
  • Add search.
  • Add dark/light theme persistence.
  • Add keyboard shortcuts.

Level 4

  • Replace localStorage with an API.
  • Add authentication.
  • Store tasks in a database through a backend.
  • Sync tasks across devices.

Common Mistakes

Putting Everything in One Function

Keep task creation, rendering, storage, and event handling in separate functions.

Duplicating DOM Logic

Create one reusable createTaskElement() function instead of constructing task markup in several places.

Mutating the DOM Without Updating State

The JavaScript state should remain the source of truth. After changing the state, render the UI from that state.

Using innerHTML for User Input

// Avoid
item.innerHTML = userInput;

// Prefer
item.textContent = userInput;

Forgetting localStorage Is Text

localStorage.setItem(
  "tasks",
  JSON.stringify(tasks)
);

const tasks =
  JSON.parse(
    localStorage.getItem("tasks")
  );

Not Testing Edge Cases

Test empty input, duplicate text, many tasks, refreshes, completed tasks, filters, and malformed stored data.

What You Learned

HTML

Semantic structure
Forms
Labels
Buttons
Lists
Data attributes
Accessibility

CSS

Layout
Flexbox
Spacing
States
Responsive design
Focus styles

JavaScript

Variables
Objects
Arrays
Functions
DOM
Events
Array methods
State
localStorage
JSON

Most Important Concept

User action
    ↓
JavaScript event
    ↓
State changes
    ↓
Render
    ↓
CSS reflects state
    ↓
User sees updated UI

Next Project

After the To Do List

Once you can build this project without following the solution, move to a project that introduces more application state and data relationships.

To Do List
    ↓
Task / Project Management App
    ↓
Dashboard or E-Commerce App
    ↓
API + Authentication + Backend

Mastery Test

Rebuild the To Do List from an empty folder without looking at the complete solution. Then add at least three extensions from the challenge section.