I wanted a todo list. I did not want an account, a sync server, a mobile app, or a “Pro” tier. I just wanted a list that survives a refresh and lives on my own site.

So I built one. It is at /tools/todo. The whole thing is one Astro page and about 150 lines of vanilla JavaScript.

Why another todo app

Every todo app I tried wanted something from me. An email. A login. A “team workspace” I did not need. One of them asked for notification permission before it had even rendered the input box.

Most of these apps are good. They are just not what I wanted. I wanted the smallest possible thing: type, press Enter, see it on the list, check it off when done. That is the whole feature set I actually use, even in the fancy apps.

So the design constraint was easy. If I would not use the feature in week two, do not ship it in week one.

localStorage is the entire backend

The trick is that browsers already ship a perfectly good database for this kind of thing. localStorage gives you a synchronous key-value store, scoped to the origin, that survives refreshes and restarts. For a personal todo list, that is enough.

There is no server. There is no API. There is no auth. The page loads, reads one key out of localStorage, and renders a list.

The data model

The shape is boring on purpose. One array of objects, stored under the key gatty-todos:

[
  {
    "id": "uuid",
    "title": "Buy bread",
    "completed": false,
    "removed": false,
    "createdAt": 1762720000000
  }
]

removed is the trash flag (more on that in a minute). createdAt is an epoch millisecond, used to render “added 5 mins ago” next to each row. Everything else I considered (priority, tags, due dates) I deliberately left out. If I find myself wanting one of those later, I will add it then. Not before.

Saving is one line in spirit, six lines in practice:

function save(todos) {
  try {
    localStorage.setItem('gatty-todos', JSON.stringify(todos));
  } catch (err) {
    console.warn('localStorage write failed', err);
  }
}

The try/catch is there because Safari in private mode will throw on writes. I do not care enough to surface a UI for it, but I do care enough not to crash the page.

Features that made the cut

The full feature list:

  • Press Enter (or click Add) to add an item.
  • Double click a row to edit it inline. Enter saves, Escape cancels.
  • Drag to reorder.
  • Four filters: All, Active, Completed, Trash. The first three each show a count badge that hides at zero.
  • Active filter writes to the URL hash, so /tools/todo#active is a shareable view.
  • “Clear completed” sweeps finished items into trash. “Clear trash” empties the bin for good (with a confirm).
  • JSON export and import for backup or moving the list to another browser.
  • Press / from anywhere on the page to focus the input.
  • A live local datetime in the header, formatted in the visitor’s own timezone abbreviation (IST, EST, GMT, whatever the browser knows).
  • A small confetti burst when you mark a row done. From both edges of the row, because of one row left no longer felt celebratory enough.

The hash filter was the one feature I almost cut and am glad I kept. It makes the back button work the way you expect, and it means I can bookmark “active only” if I want to.

Features I deliberately did not ship

This list is still longer than the feature list, which feels right:

  • No sync across devices.
  • No accounts.
  • No due dates.
  • No reminders.
  • No notifications.
  • No “share with a teammate”.
  • No analytics.
  • No dark mode toggle inside the app. It inherits the site theme.

Each one would have made the app worse for me, because each one is a thing I would have to maintain and you would have to learn. The cost of a feature is not just the lines of code. It is also the line of explanation in the empty state.

The trade offs you accept

Going server-free is a real choice with real consequences. The honest list:

  • If you clear your browser data, the list is gone.
  • A different browser or a different device gets a different list.
  • localStorage is capped at around 5 MB per origin. You will never hit it with text todos, but it is a real limit.
  • There is no audit trail. If you delete an item, it is gone.

These are not bugs. They are the deal you make when you skip a backend. The app is faster, smaller, and more private than any synced alternative, and the price is that your data lives in exactly one browser profile.

For a personal todo list, that price is fine. For a shared work tracker, it would be wrong.

Where it lives

I made a small /tools section on the site for browser-only experiments like this. The plan is to put any little single-page utility there, as long as it follows the same rule: it must work without a network request after the page loads.

I read ricocc/uiineed-todo-list carefully before writing my own version. It is a clean, well-built open source todo app, and a good reference for the kind of small details that are easy to miss (focus rings, edit-mode escape behavior, the empty state). My version is simpler and tied to my site’s design system, but the shape of the interactions owes a lot to reading that code.

A small touch: rotating quotes

There is a small italic quote under the input. It changes every 20 minutes.

The quotes live in /public/data/quotes.md, one per block, with a -- Author line below each. To add a new one I just edit that file and refresh. The script ignores # comment lines, so I can keep the file organized by theme without breaking the parser.

The rotation is deterministic: Math.floor(Date.now() / TWENTY_MIN) % quotes.length. Every visitor in the same 20-minute window sees the same quote, and nobody sees the same one for very long. About 72 a day.

There is also a tiny orange dot next to the author name. Click it and you get a different random quote immediately. The auto-rotation snaps back in sync with the clock so it does not overwrite your manual pick until the next bucket boundary.

It seemed nice to have something kind to read while staring at a list of things you have not done yet.

Four things I had to back up on

1. Trash that survived a refresh

The reference I read kept its recycle bin in a Vue data() field, not in localStorage. Delete an item, refresh, the item is gone for good. Not what “trash” usually means.

I made the trash a property of the todo, not a separate list. Each row gets a removed boolean. Delete sets it true. The Trash filter shows rows where removed === true. Restore flips the flag back. “Clear trash” splices them out for real.

const visibleTodos = () => {
  if (filter === 'trash') return todos.filter(t => t.removed);
  const live = todos.filter(t => !t.removed);
  if (filter === 'active') return live.filter(t => !t.completed);
  if (filter === 'completed') return live.filter(t => t.completed);
  return live;
};

One flag, one storage key, trash survives reloads the same way active items do.

2. The “no white card” feedback

First version: one big white card wrapping input, list, and footer. Looked like a Notion box.

Feedback: “the card white is not needed, create individual cards where background is required.”

I tried glass (transparent + backdrop-filter), too hard to read. Then no cards at all, too bare. Then “small islands”: each todo gets its own card with a soft shadow, the input is its own pill, the footer is plain text. That was right.

Three tries. CSS in my head was wrong each time. You have to render and look.

3. Confetti from both sides of the row

Marking a todo done should feel like a small celebration. First version: 18 particles at the checkbox, drifted out, faded. Felt weak.

Second pass: 60 particles, real gravity, longer arc, mix of tall and wide rectangles, saturated colors. Better, but all from the left side of the row, where the checkbox is. The right half never saw the party.

Fix: capture the row’s geometry before re-rendering wipes it, then fire two bursts.

const row = toggleBtn.closest('.t-row');
const rect = row.getBoundingClientRect();
toggleTodo(id); // re-renders, row is gone after this
confettiAt({ left: rect.left,  top: rect.top, width: 0, height: rect.height });
confettiAt({ left: rect.right, top: rect.top, width: 0, height: rect.height });

The order is the only non-obvious part. Same handler measures and mutates, so read first.

I also pulled confetti off the “add” handler. Adding a todo is the start of a task, not a celebration.

4. The HTML hidden attribute that wasn’t hiding

I added count pills to the Active and Completed filter buttons. They were supposed to disappear at zero via the HTML hidden attribute, toggled in JS.

Counts were right. The pill said 0 → 5 → 0 as I added and removed items. But the empty 0 pill never went away.

The bug was CSS:

.sidebar-btn .badge { display: inline-block; }

The hidden attribute is equivalent to display: none, but any explicit display: rule wins over it. One-line fix:

.sidebar-btn .badge[hidden] { display: none; }

Useful reminder: the hidden attribute is not magic. It is just a default style, and CSS can override it.

What surprised me

The mechanics were cheap. Reading and writing localStorage, rendering a list, toggling a class: an evening of work.

The polish was expensive. Transitions, focus rings, empty-state copy, the input keeping focus after Enter so you can keep typing. None of it is hard. It is just a lot of small decisions, and small decisions add up.

It is here: /tools/todo. It will not ask you for anything.

Try the live tool: /tools/todo