Build a Todo App with Vanilla JavaScript

Coding ProjectsBuild a Todo App with Vanilla JavaScript

What if I told you you don’t need React or Vue to build a real todo app?
In this step-by-step guide you’ll build a working todo app with vanilla JavaScript, plain HTML, and simple CSS.
You’ll set up the page, wire DOM elements, add tasks, mark them done, delete items, and save everything so it survives a page refresh.
No prior library knowledge required, just your editor and a browser.
By the end you’ll have a small project you can understand, extend, and show off.

Step 1: Set Up the HTML Structure (Starter Code Included)

Tx_gsP1_UPOb5-LW-wJEPQ

Before you write any JavaScript, you need somewhere to put your tasks. That means building a simple HTML page with an input field, a button, and a container for the task list.

Create an index.html file. Inside, add a <div> container that holds everything. You’ll need three pieces: an <input> element where users type their task, a <button> to add it, and an unordered list (<ul>) where each task shows up as a list item (<li>). This is how your app will collect, display, and manage tasks.

What you’ll include:

  1. An <input> field with an ID like taskInput so JavaScript can grab what the user types.
  2. A <button> with an ID like addButton to trigger the add action.
  3. A <ul> element with an ID like taskList to hold all the tasks.
  4. Optional: A title at the top so your app has a clear name.
  5. Links to your CSS and JavaScript files at the bottom of the <body> tag.

Each element has a job. The input collects the task name, the button tells JavaScript to process it, and the list is where each task gets displayed. Later you’ll connect these to JavaScript functions using their IDs, so naming them clearly now saves confusion when you start writing event listeners and DOM code.

Step 2: Basic CSS Styling for Layout and Readability

pmVI6z6vVgOVVq0sdf5k1A

CSS turns your plain HTML into something readable and easy to use. You don’t need animations yet. Just clean spacing, readable fonts, and a layout that keeps everything centered.

Start with these foundational rules:

  • Set box-sizing: border-box on all elements so padding doesn’t break your layout.
  • Use margin: 0 auto on the main container to center it.
  • Pick a simple font stack like Arial, sans-serif or use a Google Font.
  • Add padding to the input field and button so they’re easy to tap on mobile.
  • Set a max-width on the container (like 600px) so the app doesn’t stretch too wide.
  • Use list-style: none on the <ul> to remove default bullet points.

These tweaks improve usability before you write any JavaScript. Good spacing makes the input field easier to click. Padding around the button prevents accidental misclicks. Removing default list bullets gives you full control over how tasks look. When the interface feels smooth, users can focus on functionality instead of squinting at cramped text or hunting for the add button.

Step 3: Select and Access DOM Elements with JavaScript

prG9Grm9XGOWVSbOgAICbA

JavaScript can’t interact with your HTML until it knows which elements you want to control. You’ll use methods like document.getElementById or document.querySelector to grab references to the input, button, and list.

You need to select at least these:

  • The input field where users type tasks.
  • The add button that triggers task creation.
  • The <ul> container where new tasks get appended.
  • Optionally, a reference to any error message container if you want validation feedback.

DOM access is the bridge between your HTML and your JavaScript logic. Without selecting these elements first, you can’t read input values, attach event listeners, or insert new tasks into the page. Think of it like plugging in a device. Until you connect JavaScript to the right HTML nodes, nothing happens when the user clicks or types. Save each selected element into a clearly named variable (like taskInput, addButton, taskList) so your code stays readable as you add features.

Step 4: Add Tasks Using JavaScript Event Listeners

xUpNRLzGWRa-SLHHXI3zJg

Now that JavaScript knows which elements to watch, you can wire up the “add task” behavior. Use addEventListener on the button to run a function every time the user clicks it. You can also listen for the Enter key on the input field so users can add tasks by pressing Enter.

Here’s the flow for adding a task:

  1. Attach a click event listener to the add button.
  2. Inside the listener function, read the current value of the input field using taskInput.value.
  3. Check if the input is empty (trim any whitespace first with .trim()).
  4. If the input is blank, show an alert or inline message like “Please enter a task” and stop the function.
  5. If the input is valid, create a new <li> element using document.createElement('li').
  6. Set the textContent of the new <li> to the task the user typed.
  7. Append the new <li> to the <ul> using taskList.appendChild(newTask).
  8. Clear the input field by setting taskInput.value = '' so it’s ready for the next task.

Error handling for empty inputs matters because it prevents blank tasks from cluttering your list. By calling .trim() on the input value, you catch cases where the user typed only spaces. If the trimmed string has no length, you block the task and give clear feedback. This small validation step keeps your app from breaking and teaches you a fundamental pattern: always check user input before acting on it.

Step 5: Mark Tasks as Completed

opFQu6hViyADRVU7V06Jg

Once tasks are on the page, users need a way to mark them done. The standard pattern is letting them click a task to toggle its completion state. You’ll do this by adding or removing a CSS class (like completed) that changes how the task looks.

Use classList.toggle('completed') when a task is clicked. In your CSS, define a .completed class that applies styles like:

  • text-decoration: line-through to cross out the text.
  • A lighter color (like #888) to visually de-emphasize completed tasks.
  • Optional: a different background color or opacity to make the state clear.

Toggling a class is clean and reversible. When the user clicks the task again, toggle removes the class and the task goes back to its active state. This pattern improves workflow because it’s fast, visual, and doesn’t require extra buttons or modals. The user sees immediate feedback, and you keep your JavaScript simple by letting CSS handle the visual changes.

Step 6: Delete Tasks from the List

4Zpt_YQjXA-6dfQXhM0mzg

Users will want to remove tasks they no longer need. The standard approach is adding a delete button (often an “×” icon or a small “Delete” link) next to each task. When clicked, that button removes the task from the DOM.

Here’s how to wire it up:

  1. When you create a new task <li>, also create a delete button element (a <span> or <button>).
  2. Set the button’s text to something like “×” or “Delete”.
  3. Append the delete button to the <li> so it sits next to the task text.
  4. Attach a click event listener to the delete button.
  5. Inside the listener, call this.parentElement.remove() to delete the entire <li> from the DOM.
  6. Later, when you add localStorage, you’ll also remove the task from your saved data array at this step.

Event delegation is an alternative strategy. Instead of attaching a listener to every delete button individually, you can attach one listener to the <ul> and check event.target to see if a delete button was clicked. This scales better when you have dozens or hundreds of tasks because you’re managing only one listener instead of one per task. For now, attaching individual listeners works fine and keeps the logic straightforward. But keep delegation in mind as a future optimization.

Step 7: Save and Retrieve Tasks Using Local Storage

EdcBWDUJVzGxkpILxTbIzg

Right now, if you refresh the page, all your tasks disappear. To make tasks persist across sessions, you’ll use localStorage, a simple browser API that stores data as key-value pairs in the user’s browser.

The core localStorage operations are:

  • Save: Convert your tasks array to a JSON string with JSON.stringify(tasks) and store it using localStorage.setItem('tasks', jsonString).
  • Load: On page load, retrieve the string with localStorage.getItem('tasks'), then parse it back into an array with JSON.parse(jsonString).
  • Update: Every time you add, complete, or delete a task, update the tasks array in memory and call setItem again to overwrite the stored data.
  • Delete: To clear all tasks, call localStorage.removeItem('tasks') or localStorage.clear().

JSON.stringify and JSON.parse are the bridge between JavaScript objects and the plain text format that localStorage requires. stringify turns your array of task objects (like [{ text: 'Buy milk', done: false }]) into a string that can be saved. parse reverses that, turning the stored string back into a working array when the page loads. If localStorage is empty or contains invalid JSON, JSON.parse will throw an error, so wrap it in a try/catch block or check if the value is null before parsing. This keeps your app from crashing on the first visit when no tasks have been saved yet.

Step 8: Full Working Source Code (HTML, CSS, JS)

S9X81tGvUdSP_uBLhOSxrA

Now that you’ve built each piece, here’s how they all fit together. Below are the complete files for your todo app.

You’ll see three code blocks: the HTML structure, the CSS styling, and the JavaScript logic. Together, they create a fully functional app that lets you add tasks, mark them complete, delete them, and persist everything in localStorage so your list survives page refreshes.

HTML (index.html)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Todo App</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>My Todo List</h1>
    <div class="input-group">
      <input type="text" id="taskInput" placeholder="Enter a new task...">
      <button id="addButton">Add</button>
    </div>
    <ul id="taskList"></ul>
  </div>
  <script src="main.js"></script>
</body>
</html>

CSS (style.css)

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: Arial, sans-serif;
  background: #f4f4f4;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
}

.container {
  background: #fff;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  max-width: 500px;
  width: 100%;
}

h1 {
  margin-bottom: 1rem;
  color: #333;
}

.input-group {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

#taskInput {
  flex: 1;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

#addButton {
  padding: 0.75rem 1.5rem;
  background: #28a745;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
}

#addButton:hover {
  background: #218838;
}

#taskList {
  list-style: none;
}

#taskList li {
  padding: 0.75rem;
  background: #f9f9f9;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-bottom: 0.5rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
  cursor: pointer;
  transition: 0.2s;
}

#taskList li:hover {
  background: #e9e9e9;
}

#taskList li.completed {
  text-decoration: line-through;
  color: #888;
  background: #e0e0e0;
}

.delete-btn {
  background: #dc3545;
  color: #fff;
  border: none;
  padding: 0.5rem 0.75rem;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.9rem;
}

.delete-btn:hover {
  background: #c82333;
}

JavaScript (main.js)

const taskInput = document.getElementById('taskInput');
const addButton = document.getElementById('addButton');
const taskList = document.getElementById('taskList');

let tasks = JSON.parse(localStorage.getItem('tasks')) || [];

function saveTasks() {
  localStorage.setItem('tasks', JSON.stringify(tasks));
}

function renderTasks() {
  taskList.innerHTML = '';
  tasks.forEach((task, index) => {
    const li = document.createElement('li');
    li.textContent = task.text;
    if (task.completed) {
      li.classList.add('completed');
    }

    li.addEventListener('click', () => {
      tasks[index].completed = !tasks[index].completed;
      saveTasks();
      renderTasks();
    });

    const deleteBtn = document.createElement('button');
    deleteBtn.textContent = 'Delete';
    deleteBtn.className = 'delete-btn';
    deleteBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      tasks.splice(index, 1);
      saveTasks();
      renderTasks();
    });

    li.appendChild(deleteBtn);
    taskList.appendChild(li);
  });
}

function addTask() {
  const text = taskInput.value.trim();
  if (text === '') {
    alert('Please enter a task');
    return;
  }
  tasks.push({ text, completed: false });
  saveTasks();
  renderTasks();
  taskInput.value = '';
}

addButton.addEventListener('click', addTask);
taskInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') {
    addTask();
  }
});

renderTasks();

Step 9: Troubleshooting Common Errors

HB2tLcB4WZqwqd71sV39Lg

Even simple projects hit snags. Here are the most common mistakes beginners make with this todo app and how to fix them.

Tasks don’t appear when you click Add: Check that you’re calling renderTasks() after pushing a new task. Also confirm that taskList is correctly selected and not null.

Empty tasks get added: Make sure you’re calling .trim() on the input value and checking if the result is an empty string before pushing to the array.

localStorage errors on first load: Wrap JSON.parse(localStorage.getItem('tasks')) in a try/catch or use || [] as a fallback to handle the case when no data exists yet.

Delete button deletes the wrong task: If you’re using a loop to attach listeners, make sure you’re capturing the correct index. Using renderTasks() that rebuilds the list each time avoids stale index references.

Completed state doesn’t persist: Confirm you’re calling saveTasks() inside the click listener that toggles task.completed.

Styling doesn’t apply: Double check class names in your CSS match the ones you’re adding in JavaScript (like .completed and .delete-btn). Also verify your CSS file is linked correctly in the HTML <head>.

When something breaks, open your browser’s DevTools. Check the Console tab for JavaScript errors. Use the Elements tab to inspect whether your <li> elements are actually being created. Look at the Application tab under Local Storage to see if your tasks array is being saved correctly. If localStorage shows the right JSON but your list is blank, the problem’s likely in renderTasks(). If tasks appear but don’t save, you’re probably forgetting to call saveTasks() after a change. Debugging is pattern recognition. Once you’ve fixed each of these issues once, you’ll spot them faster next time.

Step 10: Live Demo and CodePen Link Placeholder

7FlMr6lVUumJmM-p8Ufvxw

The best way to see how all the pieces work together is to try the app live. You can copy the code above into your own editor, or you can fork and edit a ready-made version on CodePen.

A CodePen demo lets you:

  • Test the app instantly in your browser without setting up files locally.
  • Edit HTML, CSS, and JavaScript side by side and see changes in real time.
  • Fork the project to create your own version and experiment with new features (like due dates, filters, or edit-in-place).
  • Share your customized version with others or bookmark it for future reference.

If you’re following along from a course or tutorial platform, look for an embedded CodePen or a “View Demo” button near the top of the article. Click it to open the full working app, then hit “Fork” in the top right corner to make your own editable copy. From there, you can add features, break things on purpose to learn how they work, and test your changes without worrying about messing up the original code.

Final Words

You just wired up the HTML, added simple CSS, selected DOM elements, hooked up event listeners to create tasks, toggled completed styles, attached delete buttons, and saved items with localStorage.

You also saw the full source and common fixes—check your selectors, handle empty inputs, and parse JSON carefully. If something feels fuzzy, read the code top to bottom and narrate what each part does.

Now you’re ready to build a todo app with vanilla javascript step by step, tweak the UI, add filters, or connect a tiny backend. Keep experimenting—you’ve got this!

FAQ

Q: What HTML structure do I need to start the todo app?

A: The HTML structure you need to start the todo app includes an input field for task text, an add button (or form), and an unordered list (ul) inside a semantic container like a section or main.

Q: Which HTML elements are essential for the todo app?

A: The essential elements for the todo app are an input for entering tasks, a button to add tasks, a list container (ul or ol) to show tasks, and optional form and template elements for accessibility and reuse.

Q: How should I style the todo app with basic CSS?

A: You should style the todo app with simple rules: margin, padding, readable font, max-width, background and text colors, flexbox for layout, button hover state, and consistent spacing for clarity.

Q: Which DOM elements must I select in JavaScript?

A: The DOM elements you must select are the task input, the add button (or form), the list container, and optional template or clear-all buttons—use document.querySelector or getElementById to grab them.

Q: How do I add tasks using event listeners?

A: You add tasks using event listeners by handling the add button click or Enter key, reading the input value, creating a new list item with controls, appending it to the list, and clearing the input.

Q: How can I mark tasks as completed?

A: You mark tasks as completed by toggling a CSS class on the task element using element.classList.toggle, updating visual styles and optionally saving the completed state to storage.

Q: How do I delete tasks and scale deletion for many items?

A: You delete tasks by removing the task node with element.remove() when its delete button is clicked, and use event delegation on the list container to handle many items efficiently with fewer listeners.

Q: How do I save and retrieve tasks using localStorage?

A: You save and retrieve tasks using localStorage by JSON.stringify-ing your tasks array to save, JSON.parse-ing it on load, and updating storage whenever tasks are added, toggled, or deleted.

Q: What should the full working source code include and where should it appear?

A: The full working source code should include three blocks—HTML, CSS, and JS—placed together near the article’s end so readers can copy, run locally, and see how structure, style, and logic integrate.

Q: What common errors will I encounter and how do I fix them?

A: Common errors include null DOM selectors, empty input adds, JSON parse/save issues, event listener mistakes, overwritten localStorage, and typos; fix them by checking selectors, validating input, and using try/catch and delegation.

Q: How can I create and share a live demo (CodePen) for the project?

A: You create and share a live demo by pasting HTML, CSS, and JS into CodePen, saving the pen, and publishing the link; include a demo link in the article so readers can fork and edit it.

Check out our other content

Check out other tags: