Tutorials 5 min read

How to create a TODO with Tailwind CSS: JavaScript and Alpine.js

Build a TODO app with Tailwind CSS that saves tasks to localStorage, two ways: vanilla JavaScript or Alpine.js with x-data and x-for.

It’s Monday! Let’s build a simple TODO app: add tasks, check them off, remove them, and keep everything saved in localStorage. We’ll build it twice, first with vanilla JavaScript, then with Alpine.js, so you can pick whichever fits your stack.

What is a TODO?

A TODO is a list of things that need to be done. It’s a great way to keep track of tasks and prioritize them, and it works for anything from personal projects to work deadlines and daily habits.

Use cases

  • Work tasks: track deadlines and what’s on your plate this week.
  • Personal projects: break bigger goals into small, checkable steps.
  • Daily habits: mark routines as done and keep the streak alive.
  • Project planning: a lightweight alternative to heavier management tools.

The markup

  • id="todo-component": the component that contains the input to add a new todo and the list of todos.
  • id="addTodoButton": adds a new todo when clicked.
  • id="newTodoInput": the input that holds the new todo.
  • id="todoList": the list that displays the todos.

Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.

html
<div id="todo-component">
  <!-- Input to add new todo -->
  <div>
    <button id="addTodoButton">Add</button>
    <input id="newTodoInput" />
  </div>
  <!-- List of todos -->
  <ul id="todoList"></ul>
</div>

The script

  • const todos = JSON.parse(localStorage.getItem("todos")) || [];: loads any saved todos, or starts with an empty array.
  • renderTodos(): rebuilds the list markup from the array, with a checkbox, the text, and a delete button for each todo.
  • checkbox.addEventListener("change", ...): updates the todo’s completed flag, saves to localStorage, and re-renders.
  • The delete buttons splice their todo out of the array, save, and re-render.
  • addTodo(): trims the input, pushes the new todo with completed: false, clears the input, saves, and re-renders.
  • Both the Add button and the Enter key call addTodo().

The full script

js
document.addEventListener("DOMContentLoaded", () => {
  const todos = JSON.parse(localStorage.getItem("todos")) || [];
  const todoList = document.getElementById("todoList");
  const newTodoInput = document.getElementById("newTodoInput");
  const addTodoButton = document.getElementById("addTodoButton");

  const renderTodos = () => {
    todoList.innerHTML = todos
      .map(
        (todo, i) => `
        <li class="flex items-center w-full py-2 space-x-2">
          <div class="flex items-center justify-between w-full">
            <div>
              <input type="checkbox" ${
                todo.completed ? "checked" : ""
              } class="w-4 h-4 text-orange-600 rounded form-checkbox border-zinc-300 focus:ring-orange-600"/>
              <span class="text-lg text-zinc-500 ${
                todo.completed ? "line-through" : ""
              }">${todo.text}</span>
            </div>
            <button class="ml-auto text-foreground hover:text-red-700 focus:outline-none">
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
                <path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"></path>
              </svg>
            </button>
          </div>
        </li>
      `
      )
      .join("");

    document
      .querySelectorAll('input[type="checkbox"]')
      .forEach((checkbox, i) => {
        checkbox.addEventListener("change", () => {
          todos[i].completed = checkbox.checked;
          localStorage.setItem("todos", JSON.stringify(todos));
          renderTodos();
        });
      });

    document.querySelectorAll("button.text-foreground").forEach((button, i) => {
      button.addEventListener("click", () => {
        todos.splice(i, 1);
        localStorage.setItem("todos", JSON.stringify(todos));
        renderTodos();
      });
    });
  };

  const addTodo = () => {
    const newTodoText = newTodoInput.value.trim();
    if (newTodoText) {
      todos.push({
        text: newTodoText,
        completed: false,
      });
      newTodoInput.value = "";
      localStorage.setItem("todos", JSON.stringify(todos));
      renderTodos();
    }
  };

  addTodoButton.addEventListener("click", addTodo);
  newTodoInput.addEventListener(
    "keydown",
    (e) => e.key === "Enter" && addTodo()
  );

  renderTodos();
});

The Alpine.js version

Same component, no separate script: the state lives in x-data.

  • x-data="{ todos: [], newTodo: '', addTodo() { ... }, removeTodo(index) { ... }, initializeTodos() { ... } }": holds the list, the input value, and the methods that push, remove, and load todos.
  • x-init="initializeTodos": loads saved todos from localStorage when the component starts.
  • x-model="newTodo" with @keydown.enter="addTodo" (and @click="addTodo" on the button): adds a new todo from the input.
  • x-for="(todo, index) in todos" with :key="index": renders the list, with the key uniquely identifying each todo.
  • x-model="todo.completed" on the checkbox and :class="{ 'line-through': todo.completed }" on the text: check off a todo and strike it through.
  • @click="removeTodo(index)": removes a todo.

Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.

html
<div
  x-data="{
        todos: [],
        newTodo: '',
        addTodo() {
            if (this.newTodo.trim() !== '') {
                this.todos.push({ text: this.newTodo, completed: false });
                this.newTodo = '';
                localStorage.setItem('todos', JSON.stringify(this.todos));
            }
        },
        removeTodo(index) {
            this.todos.splice(index, 1);
            localStorage.setItem('todos', JSON.stringify(this.todos));
        },
        initializeTodos() {
            const storedTodos = localStorage.getItem('todos');
            if (storedTodos) {
                this.todos = JSON.parse(storedTodos);
            }
        }
    }"
  x-init="initializeTodos"
>
  <!-- Input to add new todo -->
  <div>
    <button @click="addTodo">Add</button>
    <input type="text" x-model="newTodo" @keydown.enter="addTodo" />
  </div>

  <!-- List of todos -->
  <ul>
    <template x-for="(todo, index) in todos" :key="index">
      <li>
        <div>
          <div>
            <input type="checkbox" x-model="todo.completed" />
            <span
              x-text="todo.text"
              :class="{ 'line-through': todo.completed }"
            ></span>
          </div>
          <button @click="removeTodo(index)">
            <svg>
              <!-- SVG Path -->
            </svg>
          </button>
        </div>
      </li>
    </template>
  </ul>
</div>

You can try the Alpine version separately: live demo and source code.

Which one should you use?

The Alpine version keeps state, templating, and persistence right in the markup, which is hard to beat if Alpine is already on the page. The vanilla version needs a bit more code for rendering, but it runs anywhere with zero dependencies.

Conclusion

This is a simple TODO app that can be used to keep track of tasks and prioritize them, from personal projects to work. Remember that before using this code you will have to make it accessible to your users with the necessary HTML and styling.

Hope you enjoyed this tutorial and have a great day!

/Michael Andreuzza