Open demo

CSS code

HTML
<div><span class="lbl">Full app with filters</span>
<section class="td" data-todo="errands" aria-labelledby="errands-title">
  <div class="td-head"><h2 id="errands-title">Saturday errands</h2><p>Saved in this browser</p></div>
  <form class="td-add" data-todo-form>
    <label class="sr-only" for="errands-new">New task</label>
    <input id="errands-new" type="text" placeholder="What needs doing?" autocomplete="off" maxlength="120">
    <button type="submit"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M8 3v10M3 8h10"/></svg>Add</button>
  </form>
  <ul class="td-list" data-todo-list aria-label="Saturday errands tasks">
    <li class="td-item"><input class="td-check" type="checkbox" id="errands-task-1" checked><label for="errands-task-1" data-text>Pick up the dry cleaning before noon</label><button class="td-del" type="button" data-delete aria-label="Delete “Pick up the dry cleaning before noon”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="td-item"><input class="td-check" type="checkbox" id="errands-task-2"><label for="errands-task-2" data-text>Buy milk, eggs and a sourdough loaf</label><button class="td-del" type="button" data-delete aria-label="Delete “Buy milk, eggs and a sourdough loaf”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="td-item"><input class="td-check" type="checkbox" id="errands-task-3"><label for="errands-task-3" data-text>Return three library books</label><button class="td-del" type="button" data-delete aria-label="Delete “Return three library books”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="td-item"><input class="td-check" type="checkbox" id="errands-task-4"><label for="errands-task-4" data-text>Book a haircut for Tuesday</label><button class="td-del" type="button" data-delete aria-label="Delete “Book a haircut for Tuesday”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="td-item"><input class="td-check" type="checkbox" id="errands-task-5" checked><label for="errands-task-5" data-text>Water the balcony herbs</label><button class="td-del" type="button" data-delete aria-label="Delete “Water the balcony herbs”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
  </ul>
  <p class="td-empty" data-todo-empty hidden></p>
  <div class="td-foot">
    <p class="td-count" data-todo-count>3 tasks left</p>
    <div class="td-filters" role="group" aria-label="Show tasks">
      <button type="button" data-filter="all" aria-pressed="true">All</button>
      <button type="button" data-filter="active" aria-pressed="false">Active</button>
      <button type="button" data-filter="done" aria-pressed="false">Done</button>
    </div>
    <button class="td-clear" type="button" data-todo-clear>Clear done</button>
  </div>
  <p class="sr-only" role="status" data-todo-live></p>
  <template data-todo-tpl><li class="td-item"><input class="td-check" type="checkbox"><label data-text></label><button class="td-del" type="button" data-delete><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li></template>
</section></div>

<div><span class="lbl">Compact widget</span>
<section class="td mini" data-todo="roast" aria-labelledby="roast-title">
  <div class="td-head"><h2 id="roast-title">Sunday roast shopping</h2><p class="td-count" data-todo-count>3 tasks left</p></div>
  <ul class="td-list" data-todo-list aria-label="Sunday roast shopping tasks">
    <li class="td-item"><input class="td-check" type="checkbox" id="roast-task-1"><label for="roast-task-1" data-text>Potatoes, 2 kg</label><button class="td-del" type="button" data-delete aria-label="Delete “Potatoes, 2 kg”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="td-item"><input class="td-check" type="checkbox" id="roast-task-2" checked><label for="roast-task-2" data-text>Fresh rosemary</label><button class="td-del" type="button" data-delete aria-label="Delete “Fresh rosemary”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="td-item"><input class="td-check" type="checkbox" id="roast-task-3"><label for="roast-task-3" data-text>Carrots and parsnips</label><button class="td-del" type="button" data-delete aria-label="Delete “Carrots and parsnips”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="td-item"><input class="td-check" type="checkbox" id="roast-task-4"><label for="roast-task-4" data-text>Gravy granules</label><button class="td-del" type="button" data-delete aria-label="Delete “Gravy granules”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
  </ul>
  <form class="td-add" data-todo-form>
    <label class="sr-only" for="roast-new">New item</label>
    <input id="roast-new" type="text" placeholder="Add an item" autocomplete="off" maxlength="80">
    <button type="submit">Add</button>
  </form>
  <p class="sr-only" role="status" data-todo-live></p>
  <template data-todo-tpl><li class="td-item"><input class="td-check" type="checkbox"><label data-text></label><button class="td-del" type="button" data-delete><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li></template>
</section></div>
CSS
/* Classic to-do app: a form adds rows cloned from a <template>, real checkboxes tick them off,
   filter buttons use aria-pressed, and a polite status region announces every change. */
.td{--td-bg:#fff;--td-ink:#1f1a14;--td-muted:#655c51;--td-line:#ebe4d8;--td-box:#8a7e70;--td-accent:#c2410c;--td-accent-dark:#9a3412;
  --td-tint:#f4efe7;--td-danger:#b42318;--td-danger-bg:#fdecea;--td-radius:20px;
  background:var(--td-bg);color:var(--td-ink);border:1px solid var(--td-line);border-radius:var(--td-radius);
  box-shadow:0 1px 2px rgba(31,26,20,.05),0 22px 44px -26px rgba(31,26,20,.32);padding:26px 26px 18px}
.td [hidden]{display:none!important}
.td :where(h2,p,ul){margin:0;padding:0}
.td-head{display:flex;align-items:baseline;justify-content:space-between;gap:6px 14px;flex-wrap:wrap}
.td-head h2{font-size:1.35rem;font-weight:800;letter-spacing:-.02em;line-height:1.25}
.td-head p{font-size:.85rem;color:var(--td-muted)}
.td-add{display:flex;gap:8px;margin-top:18px}
.td-add input{flex:1;min-width:0;font:inherit;font-size:1rem;line-height:1.4;padding:11px 14px;border:1.5px solid var(--td-box);border-radius:12px;
  background:#fff;color:inherit;transition:border-color .15s}
.td-add input::placeholder{color:#6f665b}
.td-add input:focus{outline:3px solid var(--td-accent);outline-offset:1px;border-color:var(--td-accent)}
.td-add button{display:inline-flex;align-items:center;gap:6px;flex:none;font:inherit;font-size:.95rem;font-weight:700;padding:0 18px;border:0;
  border-radius:12px;background:var(--td-accent);color:#fff;cursor:pointer;transition:background-color .15s}
.td-add button:hover{background:var(--td-accent-dark)}
.td button:focus-visible{outline:3px solid var(--td-accent);outline-offset:2px}
.td-list{list-style:none;margin-top:12px!important}
.td-item{display:flex;align-items:center;gap:12px;padding:8px 2px 8px 6px;border-bottom:1px solid var(--td-line)}
.td-check{appearance:none;flex:none;width:22px;height:22px;margin:0;border:2px solid var(--td-box);border-radius:7px;background:#fff center/14px no-repeat;
  cursor:pointer;transition:background-color .15s,border-color .15s}
.td-check:checked{background-color:var(--td-accent);border-color:var(--td-accent);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23fff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E")}
.td-check:focus-visible{outline:3px solid var(--td-accent);outline-offset:2px}
.td-item label{flex:1;min-width:0;font-size:1rem;line-height:1.4;padding:6px 0;cursor:pointer;overflow-wrap:anywhere;transition:color .15s}
.td-check:checked+label{color:var(--td-muted);text-decoration:line-through;text-decoration-thickness:1.5px}
.td-del{flex:none;display:grid;place-items:center;width:34px;height:34px;padding:0;border:0;border-radius:10px;background:transparent;color:var(--td-muted);
  cursor:pointer;transition:background-color .15s,color .15s}
.td-del:hover{background:var(--td-danger-bg);color:var(--td-danger)}
.td-empty{padding:26px 8px!important;text-align:center;color:var(--td-muted);font-size:.95rem}
.td-foot{display:flex;align-items:center;justify-content:space-between;gap:10px 14px;flex-wrap:wrap;margin-top:14px;font-size:.88rem}
.td-count{color:var(--td-muted);font-weight:650}
.td-filters{display:flex;gap:2px;padding:3px;background:var(--td-tint);border-radius:999px}
.td-filters button{font:inherit;font-size:.85rem;font-weight:650;padding:6px 14px;border:0;border-radius:999px;background:transparent;color:var(--td-muted);cursor:pointer}
.td-filters button:hover{color:var(--td-ink)}
.td-filters button[aria-pressed="true"]{background:#fff;color:var(--td-ink);box-shadow:0 1px 3px rgba(31,26,20,.2)}
.td-clear{font:inherit;font-size:.85rem;font-weight:650;padding:6px 4px;border:0;background:none;color:var(--td-accent-dark);cursor:pointer}
.td-clear:hover{text-decoration:underline}
.td-clear:disabled{color:#857b70;cursor:default;text-decoration:none}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
/* compact widget */
.td.mini{--td-radius:16px;max-width:360px;padding:20px 20px 16px}
.td.mini .td-head h2{font-size:1.05rem}
.td.mini .td-check{border-radius:50%;width:20px;height:20px;background-size:12px}
.td.mini .td-item{padding:4px 0;border-bottom-style:dashed}
.td.mini .td-item label{font-size:.95rem}
.td.mini .td-add{margin-top:12px}
.td.mini .td-add input{padding:9px 12px;font-size:.95rem;border-radius:10px}
.td.mini .td-add button{padding:0 14px;border-radius:10px}
@media (max-width:480px){.td{padding:20px 18px 14px}}
@media (prefers-reduced-motion:reduce){.td *{transition:none!important}}
JavaScript
/* To-do app: add, tick off, delete, filter All / Active / Done, count what is left and remember the list
   in localStorage. Every [data-todo] root works on its own; new rows are cloned from its <template>. */
document.querySelectorAll('[data-todo]').forEach((root) => {
  const form = root.querySelector('[data-todo-form]');
  const field = form.querySelector('input');
  const list = root.querySelector('[data-todo-list]');
  const tpl = root.querySelector('[data-todo-tpl]');
  const count = root.querySelector('[data-todo-count]');
  const live = root.querySelector('[data-todo-live]');
  const empty = root.querySelector('[data-todo-empty]');
  const clear = root.querySelector('[data-todo-clear]');
  const filters = root.querySelectorAll('[data-filter]');
  const key = 'uic-todo-' + root.dataset.todo;
  let filter = 'all', seq = 0, tasks = null;

  try { tasks = JSON.parse(localStorage.getItem(key)); } catch (e) { /* storage blocked: start from the markup */ }
  if (!Array.isArray(tasks)) tasks = [...list.children].map((li) => ({
    text: li.querySelector('[data-text]').textContent.trim(), done: li.querySelector('input').checked }));
  tasks.forEach((t) => { t.id = ++seq; });

  const q = (s) => '“' + s + '”';
  const plural = (n, word) => n + ' ' + word + (n === 1 ? '' : 's');
  const left = () => plural(tasks.filter((t) => !t.done).length, 'task') + ' left';
  const shows = (t) => filter === 'all' || (filter === 'done') === t.done;
  const find = (li) => tasks.find((t) => t.id === +li.dataset.id);
  const save = () => { try { localStorage.setItem(key, JSON.stringify(tasks.map(({ text, done }) => ({ text, done })))); } catch (e) { /* ignore */ } };
  const say = (msg) => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };

  const row = (t) => {
    const li = tpl.content.firstElementChild.cloneNode(true);
    const box = li.querySelector('input');
    box.id = root.dataset.todo + '-task-' + t.id;
    box.checked = t.done;
    li.dataset.id = t.id;
    li.querySelector('label').htmlFor = box.id;
    li.querySelector('[data-text]').textContent = t.text;
    li.querySelector('[data-delete]').setAttribute('aria-label', 'Delete ' + q(t.text));
    return li;
  };
  // the visible neighbour that should take focus when a row leaves the view
  const near = (li) => {
    let n = li.nextElementSibling; while (n && n.hidden) n = n.nextElementSibling;
    let p = li.previousElementSibling; while (p && p.hidden) p = p.previousElementSibling;
    return (n || p) ? (n || p).querySelector('input') : field;
  };
  const sync = () => {
    [...list.children].forEach((li) => { li.hidden = !shows(find(li)); });
    if (count) count.textContent = left();
    if (clear) clear.disabled = !tasks.some((t) => t.done);
    if (empty) {
      empty.hidden = tasks.some(shows);
      empty.textContent = filter === 'active' ? 'Nothing left to do.' : filter === 'done' ? 'Nothing ticked off yet.' : 'No tasks yet. Add one above.';
    }
    filters.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
  };

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const text = field.value.trim().replace(/\s+/g, ' ');
    if (!text) { say('Type a task first.'); field.focus(); return; }
    const t = { id: ++seq, text, done: false };
    tasks.push(t); list.append(row(t));
    if (filter === 'done') filter = 'all';
    field.value = ''; save(); sync();
    say('Task added: ' + q(text) + '. ' + left() + '.');
  });

  list.addEventListener('change', (e) => {
    const li = e.target.closest('li'), t = li && find(li);
    if (!t) return;
    t.done = e.target.checked;
    const next = shows(t) ? null : near(li);
    save(); sync();
    if (next) next.focus();
    say((t.done ? 'Done: ' : 'Not done: ') + q(t.text) + '. ' + left() + '.');
  });

  list.addEventListener('click', (e) => {
    const btn = e.target.closest('[data-delete]');
    if (!btn) return;
    const li = btn.closest('li'), t = find(li), next = near(li);
    tasks.splice(tasks.indexOf(t), 1); li.remove();
    save(); sync(); next.focus();
    say('Deleted ' + q(t.text) + '. ' + left() + '.');
  });

  filters.forEach((b) => b.addEventListener('click', () => {
    filter = b.dataset.filter; sync();
    const n = tasks.filter(shows).length;
    say(filter === 'all' ? 'Showing all ' + plural(n, 'task') : 'Showing ' + n + ' ' + filter + ' ' + (n === 1 ? 'task' : 'tasks'));
  }));

  if (clear) clear.addEventListener('click', () => {
    const gone = tasks.filter((t) => t.done).length;
    tasks = tasks.filter((t) => !t.done);
    [...list.children].forEach((li) => { if (!find(li)) li.remove(); });
    save(); sync(); field.focus();
    say('Cleared ' + plural(gone, 'finished task') + '. ' + left() + '.');
  });

  list.replaceChildren(...tasks.map(row));
  sync();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Full app with filters</span>
<section class="card td" data-todo="errands" aria-labelledby="errands-title"><div class="card-body">
  <div class="d-flex flex-wrap align-items-baseline justify-content-between column-gap-3 row-gap-1"><h2 class="td-title mb-0" id="errands-title">Saturday errands</h2><p class="td-sub mb-0">Saved in this browser</p></div>
  <form class="d-flex gap-2 mt-3 pt-1" data-todo-form>
    <label class="visually-hidden" for="errands-new">New task</label>
    <input class="form-control" id="errands-new" type="text" placeholder="What needs doing?" autocomplete="off" maxlength="120">
    <button class="btn td-add-btn flex-shrink-0" type="submit"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M8 3v10M3 8h10"/></svg>Add</button>
  </form>
  <ul class="list-group list-group-flush mt-2 pt-1" data-todo-list aria-label="Saturday errands tasks">
    <li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox" id="errands-task-1" checked><label class="td-text" for="errands-task-1" data-text>Pick up the dry cleaning before noon</label><button class="btn td-del" type="button" data-delete aria-label="Delete “Pick up the dry cleaning before noon”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox" id="errands-task-2"><label class="td-text" for="errands-task-2" data-text>Buy milk, eggs and a sourdough loaf</label><button class="btn td-del" type="button" data-delete aria-label="Delete “Buy milk, eggs and a sourdough loaf”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox" id="errands-task-3"><label class="td-text" for="errands-task-3" data-text>Return three library books</label><button class="btn td-del" type="button" data-delete aria-label="Delete “Return three library books”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox" id="errands-task-4"><label class="td-text" for="errands-task-4" data-text>Book a haircut for Tuesday</label><button class="btn td-del" type="button" data-delete aria-label="Delete “Book a haircut for Tuesday”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox" id="errands-task-5" checked><label class="td-text" for="errands-task-5" data-text>Water the balcony herbs</label><button class="btn td-del" type="button" data-delete aria-label="Delete “Water the balcony herbs”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
  </ul>
  <p class="td-empty mb-0" data-todo-empty hidden></p>
  <div class="td-foot d-flex flex-wrap align-items-center justify-content-between column-gap-3 row-gap-2 mt-3">
    <p class="td-count mb-0" data-todo-count>3 tasks left</p>
    <div class="td-filters d-flex gap-1 rounded-pill" role="group" aria-label="Show tasks">
      <button class="btn td-filter rounded-pill" type="button" data-filter="all" aria-pressed="true">All</button>
      <button class="btn td-filter rounded-pill" type="button" data-filter="active" aria-pressed="false">Active</button>
      <button class="btn td-filter rounded-pill" type="button" data-filter="done" aria-pressed="false">Done</button>
    </div>
    <button class="btn td-clear" type="button" data-todo-clear>Clear done</button>
  </div>
  <p class="visually-hidden" role="status" data-todo-live></p>
  <template data-todo-tpl><li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox"><label class="td-text" data-text></label><button class="btn td-del" type="button" data-delete><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li></template>
</div></section></div>

<div><span class="lbl">Compact widget</span>
<section class="card td mini" data-todo="roast" aria-labelledby="roast-title"><div class="card-body">
  <div class="d-flex flex-wrap align-items-baseline justify-content-between column-gap-3 row-gap-1"><h2 class="td-title mb-0" id="roast-title">Sunday roast shopping</h2><p class="td-count mb-0 small" data-todo-count>3 tasks left</p></div>
  <ul class="list-group list-group-flush mt-2 pt-1" data-todo-list aria-label="Sunday roast shopping tasks">
    <li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox" id="roast-task-1"><label class="td-text" for="roast-task-1" data-text>Potatoes, 2 kg</label><button class="btn td-del" type="button" data-delete aria-label="Delete “Potatoes, 2 kg”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox" id="roast-task-2" checked><label class="td-text" for="roast-task-2" data-text>Fresh rosemary</label><button class="btn td-del" type="button" data-delete aria-label="Delete “Fresh rosemary”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox" id="roast-task-3"><label class="td-text" for="roast-task-3" data-text>Carrots and parsnips</label><button class="btn td-del" type="button" data-delete aria-label="Delete “Carrots and parsnips”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox" id="roast-task-4"><label class="td-text" for="roast-task-4" data-text>Gravy granules</label><button class="btn td-del" type="button" data-delete aria-label="Delete “Gravy granules”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
  </ul>
  <form class="d-flex gap-2 mt-3" data-todo-form>
    <label class="visually-hidden" for="roast-new">New item</label>
    <input class="form-control" id="roast-new" type="text" placeholder="Add an item" autocomplete="off" maxlength="80">
    <button class="btn td-add-btn flex-shrink-0" type="submit">Add</button>
  </form>
  <p class="visually-hidden" role="status" data-todo-live></p>
  <template data-todo-tpl><li class="list-group-item d-flex align-items-center gap-3"><input class="form-check-input" type="checkbox"><label class="td-text" data-text></label><button class="btn td-del" type="button" data-delete><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li></template>
</div></section></div>
CSS
/* Classic to-do app on Bootstrap: .card, .form-control, .btn, .list-group and .form-check-input,
   restyled through Bootstrap's CSS variables. Filter buttons use aria-pressed; a .visually-hidden status region announces changes. */
.td{--td-ink:#1f1a14;--td-muted:#655c51;--td-line:#ebe4d8;--td-box:#8a7e70;--td-accent:#c2410c;--td-accent-dark:#9a3412;--td-tint:#f4efe7;--td-danger:#b42318;--td-danger-bg:#fdecea;
  --bs-card-border-color:var(--td-line);--bs-card-border-radius:20px;--bs-card-spacer-y:26px;--bs-card-spacer-x:26px;--bs-card-color:var(--td-ink);
  box-shadow:0 1px 2px rgba(31,26,20,.05),0 22px 44px -26px rgba(31,26,20,.32)}
.td .card-body{padding-bottom:18px}
.td-sub{font-size:.85rem;color:var(--td-muted)}
.td-title{font-size:1.35rem;font-weight:800;letter-spacing:-.02em;line-height:1.25}
.td .form-control{--bs-border-color:var(--td-box);border-width:1.5px;border-radius:12px;padding:11px 14px;font-size:1rem;line-height:1.4;color:var(--td-ink)}
.td .form-control::placeholder{color:#6f665b}
.td .form-control:focus{border-color:var(--td-accent);box-shadow:none;outline:3px solid var(--td-accent);outline-offset:1px}
.td-add-btn{--bs-btn-bg:var(--td-accent);--bs-btn-border-color:var(--td-accent);--bs-btn-color:#fff;--bs-btn-hover-bg:var(--td-accent-dark);--bs-btn-hover-border-color:var(--td-accent-dark);
  --bs-btn-hover-color:#fff;--bs-btn-active-bg:var(--td-accent-dark);--bs-btn-active-border-color:var(--td-accent-dark);--bs-btn-active-color:#fff;
  --bs-btn-font-weight:700;--bs-btn-font-size:.95rem;--bs-btn-padding-x:18px;--bs-btn-border-radius:12px;display:inline-flex;align-items:center;gap:6px}
.td .btn:focus-visible{box-shadow:none;outline:3px solid var(--td-accent);outline-offset:2px}
.td .list-group{--bs-list-group-border-color:var(--td-line);--bs-list-group-item-padding-x:0;--bs-list-group-item-padding-y:8px;--bs-list-group-color:var(--td-ink)}
.td .list-group-item{padding-left:6px;padding-right:2px}
.td .list-group-flush>.list-group-item:last-child{border-bottom-width:1px}
.td .form-check-input{flex:none;width:22px;height:22px;margin:0;border:2px solid var(--td-box);border-radius:7px;cursor:pointer;
  background-size:14px;transition:background-color .15s,border-color .15s}
.td .form-check-input:checked{background-color:var(--td-accent);border-color:var(--td-accent)}
.td .form-check-input:focus{box-shadow:none;border-color:var(--td-box)}
.td .form-check-input:checked:focus{border-color:var(--td-accent)}
.td .form-check-input:focus-visible{outline:3px solid var(--td-accent);outline-offset:2px}
.td-text{flex:1;min-width:0;font-size:1rem;line-height:1.4;padding:6px 0;cursor:pointer;overflow-wrap:anywhere;transition:color .15s}
.td .form-check-input:checked+.td-text{color:var(--td-muted);text-decoration:line-through;text-decoration-thickness:1.5px}
.td-del{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-color:var(--td-muted);--bs-btn-hover-color:var(--td-danger);--bs-btn-hover-bg:var(--td-danger-bg);
  --bs-btn-active-color:var(--td-danger);--bs-btn-active-bg:var(--td-danger-bg);--bs-btn-border-width:0;--bs-btn-border-radius:10px;
  flex:none;display:grid;place-items:center;width:34px;height:34px}
.td-empty{padding:26px 8px;text-align:center;color:var(--td-muted);font-size:.95rem}
.td-foot{font-size:.88rem}
.td-count{color:var(--td-muted);font-weight:650}
.td-filters{background:var(--td-tint);padding:3px}
.td-filter{--bs-btn-padding-x:14px;--bs-btn-padding-y:6px;--bs-btn-font-size:.85rem;--bs-btn-font-weight:650;--bs-btn-color:var(--td-muted);--bs-btn-hover-color:var(--td-ink);
  --bs-btn-active-color:var(--td-ink);--bs-btn-border-width:0;--bs-btn-line-height:normal}
.td-filter[aria-pressed="true"]{background:#fff;color:var(--td-ink);box-shadow:0 1px 3px rgba(31,26,20,.2)}
.td-clear{--bs-btn-padding-x:4px;--bs-btn-padding-y:6px;--bs-btn-font-size:.85rem;--bs-btn-font-weight:650;--bs-btn-color:var(--td-accent-dark);--bs-btn-hover-color:var(--td-accent-dark);
  --bs-btn-active-color:var(--td-accent-dark);--bs-btn-border-width:0;--bs-btn-disabled-color:#857b70;--bs-btn-disabled-opacity:1;--bs-btn-line-height:normal}
.td-clear:hover{text-decoration:underline}
/* compact widget */
.td.mini{max-width:360px;--bs-card-border-radius:16px;--bs-card-spacer-y:20px;--bs-card-spacer-x:20px}
.td.mini .card-body{padding-bottom:16px}
.td.mini .td-title{font-size:1.05rem}
.td.mini .list-group{--bs-list-group-item-padding-y:4px}
.td.mini .list-group-item{padding-left:0;padding-right:0;border-bottom-style:dashed}
.td.mini .form-check-input{width:20px;height:20px;border-radius:50%;background-size:12px}
.td.mini .td-text{font-size:.95rem}
.td.mini .form-control{padding:9px 12px;font-size:.95rem;border-radius:10px}
.td.mini .td-add-btn{--bs-btn-padding-x:14px;--bs-btn-border-radius:10px}
@media (max-width:480px){.td{--bs-card-spacer-y:20px;--bs-card-spacer-x:18px}.td .card-body{padding-bottom:14px}}
@media (prefers-reduced-motion:reduce){.td *{transition:none!important}}
JavaScript
/* To-do app: add, tick off, delete, filter All / Active / Done, count what is left and remember the list
   in localStorage. Every [data-todo] root works on its own; new rows are cloned from its <template>. */
document.querySelectorAll('[data-todo]').forEach((root) => {
  const form = root.querySelector('[data-todo-form]');
  const field = form.querySelector('input');
  const list = root.querySelector('[data-todo-list]');
  const tpl = root.querySelector('[data-todo-tpl]');
  const count = root.querySelector('[data-todo-count]');
  const live = root.querySelector('[data-todo-live]');
  const empty = root.querySelector('[data-todo-empty]');
  const clear = root.querySelector('[data-todo-clear]');
  const filters = root.querySelectorAll('[data-filter]');
  const key = 'uic-todo-' + root.dataset.todo;
  let filter = 'all', seq = 0, tasks = null;

  try { tasks = JSON.parse(localStorage.getItem(key)); } catch (e) { /* storage blocked: start from the markup */ }
  if (!Array.isArray(tasks)) tasks = [...list.children].map((li) => ({
    text: li.querySelector('[data-text]').textContent.trim(), done: li.querySelector('input').checked }));
  tasks.forEach((t) => { t.id = ++seq; });

  const q = (s) => '“' + s + '”';
  const plural = (n, word) => n + ' ' + word + (n === 1 ? '' : 's');
  const left = () => plural(tasks.filter((t) => !t.done).length, 'task') + ' left';
  const shows = (t) => filter === 'all' || (filter === 'done') === t.done;
  const find = (li) => tasks.find((t) => t.id === +li.dataset.id);
  const save = () => { try { localStorage.setItem(key, JSON.stringify(tasks.map(({ text, done }) => ({ text, done })))); } catch (e) { /* ignore */ } };
  const say = (msg) => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };

  const row = (t) => {
    const li = tpl.content.firstElementChild.cloneNode(true);
    const box = li.querySelector('input');
    box.id = root.dataset.todo + '-task-' + t.id;
    box.checked = t.done;
    li.dataset.id = t.id;
    li.querySelector('label').htmlFor = box.id;
    li.querySelector('[data-text]').textContent = t.text;
    li.querySelector('[data-delete]').setAttribute('aria-label', 'Delete ' + q(t.text));
    return li;
  };
  // the visible neighbour that should take focus when a row leaves the view
  const near = (li) => {
    let n = li.nextElementSibling; while (n && n.hidden) n = n.nextElementSibling;
    let p = li.previousElementSibling; while (p && p.hidden) p = p.previousElementSibling;
    return (n || p) ? (n || p).querySelector('input') : field;
  };
  const sync = () => {
    [...list.children].forEach((li) => { li.hidden = !shows(find(li)); });
    if (count) count.textContent = left();
    if (clear) clear.disabled = !tasks.some((t) => t.done);
    if (empty) {
      empty.hidden = tasks.some(shows);
      empty.textContent = filter === 'active' ? 'Nothing left to do.' : filter === 'done' ? 'Nothing ticked off yet.' : 'No tasks yet. Add one above.';
    }
    filters.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
  };

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const text = field.value.trim().replace(/\s+/g, ' ');
    if (!text) { say('Type a task first.'); field.focus(); return; }
    const t = { id: ++seq, text, done: false };
    tasks.push(t); list.append(row(t));
    if (filter === 'done') filter = 'all';
    field.value = ''; save(); sync();
    say('Task added: ' + q(text) + '. ' + left() + '.');
  });

  list.addEventListener('change', (e) => {
    const li = e.target.closest('li'), t = li && find(li);
    if (!t) return;
    t.done = e.target.checked;
    const next = shows(t) ? null : near(li);
    save(); sync();
    if (next) next.focus();
    say((t.done ? 'Done: ' : 'Not done: ') + q(t.text) + '. ' + left() + '.');
  });

  list.addEventListener('click', (e) => {
    const btn = e.target.closest('[data-delete]');
    if (!btn) return;
    const li = btn.closest('li'), t = find(li), next = near(li);
    tasks.splice(tasks.indexOf(t), 1); li.remove();
    save(); sync(); next.focus();
    say('Deleted ' + q(t.text) + '. ' + left() + '.');
  });

  filters.forEach((b) => b.addEventListener('click', () => {
    filter = b.dataset.filter; sync();
    const n = tasks.filter(shows).length;
    say(filter === 'all' ? 'Showing all ' + plural(n, 'task') : 'Showing ' + n + ' ' + filter + ' ' + (n === 1 ? 'task' : 'tasks'));
  }));

  if (clear) clear.addEventListener('click', () => {
    const gone = tasks.filter((t) => t.done).length;
    tasks = tasks.filter((t) => !t.done);
    [...list.children].forEach((li) => { if (!find(li)) li.remove(); });
    save(); sync(); field.focus();
    say('Cleared ' + plural(gone, 'finished task') + '. ' + left() + '.');
  });

  list.replaceChildren(...tasks.map(row));
  sync();
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#655c51] uppercase">Full app with filters</span>
<section class="rounded-[20px] border border-[#ebe4d8] bg-white px-[26px] pt-[26px] pb-[18px] text-[#1f1a14] shadow-[0_1px_2px_rgba(31,26,20,.05),0_22px_44px_-26px_rgba(31,26,20,.32)] max-[480px]:px-[18px] max-[480px]:pt-5 max-[480px]:pb-3.5" data-todo="errands" aria-labelledby="errands-title">
  <div class="flex flex-wrap items-baseline justify-between gap-x-3.5 gap-y-1.5"><h2 class="text-[1.35rem] leading-[1.25] font-extrabold tracking-[-.02em]" id="errands-title">Saturday errands</h2><p class="text-[.85rem] text-[#655c51]">Saved in this browser</p></div>
  <form class="mt-[18px] flex gap-2" data-todo-form>
    <label class="sr-only" for="errands-new">New task</label>
    <input class="min-w-0 flex-1 border-[1.5px] border-[#8a7e70] bg-white leading-[1.4] text-[#1f1a14] placeholder:text-[#6f665b] transition-colors duration-150 focus:border-[#c2410c] focus:outline-3 focus:outline-offset-1 focus:outline-[#c2410c] motion-reduce:transition-none rounded-xl px-3.5 py-[11px] text-base" id="errands-new" type="text" placeholder="What needs doing?" autocomplete="off" maxlength="120">
    <button class="inline-flex flex-none cursor-pointer items-center gap-1.5 bg-[#c2410c] font-bold text-white transition-colors duration-150 hover:bg-[#9a3412] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none rounded-xl px-[18px] text-[.95rem]" type="submit"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M8 3v10M3 8h10"/></svg>Add</button>
  </form>
  <ul class="mt-3" data-todo-list aria-label="Saturday errands tasks">
    <li class="flex items-center gap-3 border-b border-[#ebe4d8] py-2 pr-0.5 pl-1.5"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[22px] rounded-[7px] bg-[length:14px]" type="checkbox" id="errands-task-1" checked><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-base" for="errands-task-1" data-text>Pick up the dry cleaning before noon</label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete aria-label="Delete “Pick up the dry cleaning before noon”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="flex items-center gap-3 border-b border-[#ebe4d8] py-2 pr-0.5 pl-1.5"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[22px] rounded-[7px] bg-[length:14px]" type="checkbox" id="errands-task-2"><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-base" for="errands-task-2" data-text>Buy milk, eggs and a sourdough loaf</label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete aria-label="Delete “Buy milk, eggs and a sourdough loaf”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="flex items-center gap-3 border-b border-[#ebe4d8] py-2 pr-0.5 pl-1.5"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[22px] rounded-[7px] bg-[length:14px]" type="checkbox" id="errands-task-3"><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-base" for="errands-task-3" data-text>Return three library books</label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete aria-label="Delete “Return three library books”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="flex items-center gap-3 border-b border-[#ebe4d8] py-2 pr-0.5 pl-1.5"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[22px] rounded-[7px] bg-[length:14px]" type="checkbox" id="errands-task-4"><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-base" for="errands-task-4" data-text>Book a haircut for Tuesday</label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete aria-label="Delete “Book a haircut for Tuesday”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="flex items-center gap-3 border-b border-[#ebe4d8] py-2 pr-0.5 pl-1.5"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[22px] rounded-[7px] bg-[length:14px]" type="checkbox" id="errands-task-5" checked><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-base" for="errands-task-5" data-text>Water the balcony herbs</label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete aria-label="Delete “Water the balcony herbs”"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
  </ul>
  <p class="px-2 py-[26px] text-center text-[.95rem] text-[#655c51]" data-todo-empty hidden></p>
  <div class="mt-3.5 flex flex-wrap items-center justify-between gap-x-3.5 gap-y-2.5 text-[.88rem]">
    <p class="font-[650] text-[#655c51]" data-todo-count>3 tasks left</p>
    <div class="flex gap-0.5 rounded-full bg-[#f4efe7] p-[3px]" role="group" aria-label="Show tasks">
      <button class="cursor-pointer rounded-full px-3.5 py-1.5 text-[.85rem] font-[650] text-[#655c51] hover:text-[#1f1a14] aria-pressed:bg-white aria-pressed:text-[#1f1a14] aria-pressed:shadow-[0_1px_3px_rgba(31,26,20,.2)] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c]" type="button" data-filter="all" aria-pressed="true">All</button>
      <button class="cursor-pointer rounded-full px-3.5 py-1.5 text-[.85rem] font-[650] text-[#655c51] hover:text-[#1f1a14] aria-pressed:bg-white aria-pressed:text-[#1f1a14] aria-pressed:shadow-[0_1px_3px_rgba(31,26,20,.2)] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c]" type="button" data-filter="active" aria-pressed="false">Active</button>
      <button class="cursor-pointer rounded-full px-3.5 py-1.5 text-[.85rem] font-[650] text-[#655c51] hover:text-[#1f1a14] aria-pressed:bg-white aria-pressed:text-[#1f1a14] aria-pressed:shadow-[0_1px_3px_rgba(31,26,20,.2)] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c]" type="button" data-filter="done" aria-pressed="false">Done</button>
    </div>
    <button class="cursor-pointer px-1 py-1.5 text-[.85rem] font-[650] text-[#9a3412] hover:underline disabled:cursor-default disabled:text-[#857b70] disabled:no-underline focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c]" type="button" data-todo-clear>Clear done</button>
  </div>
  <p class="sr-only" role="status" data-todo-live></p>
  <template data-todo-tpl><li class="flex items-center gap-3 border-b border-[#ebe4d8] py-2 pr-0.5 pl-1.5"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[22px] rounded-[7px] bg-[length:14px]" type="checkbox"><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-base" data-text></label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li></template>
</section></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#655c51] uppercase">Compact widget</span>
<section class="max-w-[360px] rounded-2xl border border-[#ebe4d8] bg-white px-5 pt-5 pb-4 text-[#1f1a14] shadow-[0_1px_2px_rgba(31,26,20,.05),0_22px_44px_-26px_rgba(31,26,20,.32)] max-[480px]:px-[18px] max-[480px]:pb-3.5" data-todo="roast" aria-labelledby="roast-title">
  <div class="flex flex-wrap items-baseline justify-between gap-x-3.5 gap-y-1.5"><h2 class="text-[1.05rem] leading-[1.25] font-extrabold tracking-[-.02em]" id="roast-title">Sunday roast shopping</h2><p class="text-[.85rem] font-[650] text-[#655c51]" data-todo-count>3 tasks left</p></div>
  <ul class="mt-3" data-todo-list aria-label="Sunday roast shopping tasks">
    <li class="flex items-center gap-3 border-b border-dashed border-[#ebe4d8] py-1"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-5 rounded-full bg-[length:12px]" type="checkbox" id="roast-task-1"><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-[.95rem]" for="roast-task-1" data-text>Potatoes, 2 kg</label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete aria-label="Delete “Potatoes, 2 kg”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="flex items-center gap-3 border-b border-dashed border-[#ebe4d8] py-1"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-5 rounded-full bg-[length:12px]" type="checkbox" id="roast-task-2" checked><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-[.95rem]" for="roast-task-2" data-text>Fresh rosemary</label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete aria-label="Delete “Fresh rosemary”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="flex items-center gap-3 border-b border-dashed border-[#ebe4d8] py-1"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-5 rounded-full bg-[length:12px]" type="checkbox" id="roast-task-3"><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-[.95rem]" for="roast-task-3" data-text>Carrots and parsnips</label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete aria-label="Delete “Carrots and parsnips”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
    <li class="flex items-center gap-3 border-b border-dashed border-[#ebe4d8] py-1"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-5 rounded-full bg-[length:12px]" type="checkbox" id="roast-task-4"><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-[.95rem]" for="roast-task-4" data-text>Gravy granules</label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete aria-label="Delete “Gravy granules”"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li>
  </ul>
  <form class="mt-3 flex gap-2" data-todo-form>
    <label class="sr-only" for="roast-new">New item</label>
    <input class="min-w-0 flex-1 border-[1.5px] border-[#8a7e70] bg-white leading-[1.4] text-[#1f1a14] placeholder:text-[#6f665b] transition-colors duration-150 focus:border-[#c2410c] focus:outline-3 focus:outline-offset-1 focus:outline-[#c2410c] motion-reduce:transition-none rounded-[10px] px-3 py-[9px] text-[.95rem]" id="roast-new" type="text" placeholder="Add an item" autocomplete="off" maxlength="80">
    <button class="inline-flex flex-none cursor-pointer items-center gap-1.5 bg-[#c2410c] font-bold text-white transition-colors duration-150 hover:bg-[#9a3412] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none rounded-[10px] px-3.5 text-[.95rem]" type="submit">Add</button>
  </form>
  <p class="sr-only" role="status" data-todo-live></p>
  <template data-todo-tpl><li class="flex items-center gap-3 border-b border-dashed border-[#ebe4d8] py-1"><input class="peer flex-none cursor-pointer appearance-none border-2 border-[#8a7e70] bg-white bg-center bg-no-repeat transition-colors duration-150 checked:border-[#c2410c] checked:bg-[#c2410c] checked:bg-tick focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-5 rounded-full bg-[length:12px]" type="checkbox"><label class="min-w-0 flex-1 cursor-pointer py-1.5 leading-[1.4] [overflow-wrap:anywhere] transition-colors duration-150 peer-checked:text-[#655c51] peer-checked:line-through peer-checked:decoration-[1.5px] motion-reduce:transition-none text-[.95rem]" data-text></label><button class="grid flex-none cursor-pointer place-items-center rounded-[10px] text-[#655c51] transition-colors duration-150 hover:bg-[#fdecea] hover:text-[#b42318] focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-[#c2410c] motion-reduce:transition-none size-[34px]" type="button" data-delete><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></li></template>
</section></div>
CSS
/* the white tick drawn inside a checked box */
@utility bg-tick {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23fff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E");
}
JavaScript
/* To-do app: add, tick off, delete, filter All / Active / Done, count what is left and remember the list
   in localStorage. Every [data-todo] root works on its own; new rows are cloned from its <template>. */
document.querySelectorAll('[data-todo]').forEach((root) => {
  const form = root.querySelector('[data-todo-form]');
  const field = form.querySelector('input');
  const list = root.querySelector('[data-todo-list]');
  const tpl = root.querySelector('[data-todo-tpl]');
  const count = root.querySelector('[data-todo-count]');
  const live = root.querySelector('[data-todo-live]');
  const empty = root.querySelector('[data-todo-empty]');
  const clear = root.querySelector('[data-todo-clear]');
  const filters = root.querySelectorAll('[data-filter]');
  const key = 'uic-todo-' + root.dataset.todo;
  let filter = 'all', seq = 0, tasks = null;

  try { tasks = JSON.parse(localStorage.getItem(key)); } catch (e) { /* storage blocked: start from the markup */ }
  if (!Array.isArray(tasks)) tasks = [...list.children].map((li) => ({
    text: li.querySelector('[data-text]').textContent.trim(), done: li.querySelector('input').checked }));
  tasks.forEach((t) => { t.id = ++seq; });

  const q = (s) => '“' + s + '”';
  const plural = (n, word) => n + ' ' + word + (n === 1 ? '' : 's');
  const left = () => plural(tasks.filter((t) => !t.done).length, 'task') + ' left';
  const shows = (t) => filter === 'all' || (filter === 'done') === t.done;
  const find = (li) => tasks.find((t) => t.id === +li.dataset.id);
  const save = () => { try { localStorage.setItem(key, JSON.stringify(tasks.map(({ text, done }) => ({ text, done })))); } catch (e) { /* ignore */ } };
  const say = (msg) => { live.textContent = ''; setTimeout(() => { live.textContent = msg; }, 60); };

  const row = (t) => {
    const li = tpl.content.firstElementChild.cloneNode(true);
    const box = li.querySelector('input');
    box.id = root.dataset.todo + '-task-' + t.id;
    box.checked = t.done;
    li.dataset.id = t.id;
    li.querySelector('label').htmlFor = box.id;
    li.querySelector('[data-text]').textContent = t.text;
    li.querySelector('[data-delete]').setAttribute('aria-label', 'Delete ' + q(t.text));
    return li;
  };
  // the visible neighbour that should take focus when a row leaves the view
  const near = (li) => {
    let n = li.nextElementSibling; while (n && n.hidden) n = n.nextElementSibling;
    let p = li.previousElementSibling; while (p && p.hidden) p = p.previousElementSibling;
    return (n || p) ? (n || p).querySelector('input') : field;
  };
  const sync = () => {
    [...list.children].forEach((li) => { li.hidden = !shows(find(li)); });
    if (count) count.textContent = left();
    if (clear) clear.disabled = !tasks.some((t) => t.done);
    if (empty) {
      empty.hidden = tasks.some(shows);
      empty.textContent = filter === 'active' ? 'Nothing left to do.' : filter === 'done' ? 'Nothing ticked off yet.' : 'No tasks yet. Add one above.';
    }
    filters.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
  };

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const text = field.value.trim().replace(/\s+/g, ' ');
    if (!text) { say('Type a task first.'); field.focus(); return; }
    const t = { id: ++seq, text, done: false };
    tasks.push(t); list.append(row(t));
    if (filter === 'done') filter = 'all';
    field.value = ''; save(); sync();
    say('Task added: ' + q(text) + '. ' + left() + '.');
  });

  list.addEventListener('change', (e) => {
    const li = e.target.closest('li'), t = li && find(li);
    if (!t) return;
    t.done = e.target.checked;
    const next = shows(t) ? null : near(li);
    save(); sync();
    if (next) next.focus();
    say((t.done ? 'Done: ' : 'Not done: ') + q(t.text) + '. ' + left() + '.');
  });

  list.addEventListener('click', (e) => {
    const btn = e.target.closest('[data-delete]');
    if (!btn) return;
    const li = btn.closest('li'), t = find(li), next = near(li);
    tasks.splice(tasks.indexOf(t), 1); li.remove();
    save(); sync(); next.focus();
    say('Deleted ' + q(t.text) + '. ' + left() + '.');
  });

  filters.forEach((b) => b.addEventListener('click', () => {
    filter = b.dataset.filter; sync();
    const n = tasks.filter(shows).length;
    say(filter === 'all' ? 'Showing all ' + plural(n, 'task') : 'Showing ' + n + ' ' + filter + ' ' + (n === 1 ? 'task' : 'tasks'));
  }));

  if (clear) clear.addEventListener('click', () => {
    const gone = tasks.filter((t) => t.done).length;
    tasks = tasks.filter((t) => !t.done);
    [...list.children].forEach((li) => { if (!find(li)) li.remove(); });
    save(); sync(); field.focus();
    say('Cleared ' + plural(gone, 'finished task') + '. ' + left() + '.');
  });

  list.replaceChildren(...tasks.map(row));
  sync();
});

About this set

The to-do app most people search for when they want a working to-do list in HTML, CSS and JavaScript. Full app with filters is a Saturday errands list: an add field with an Add button, five tasks with real checkboxes and a delete button each, a count of tasks left, All, Active and Done filter buttons, and a Clear done button. Compact widget is a shopping list for a Sunday roast with round checkboxes, the count in the header and the add field under the list, for a sidebar or a dashboard card. About a hundred lines of vanilla JavaScript drive both. New rows are cloned from a template element, so the markup of a row lives in the HTML rather than in the script, and each list saves its tasks to localStorage under its own key and falls back to the tasks in the markup when storage is blocked. Every change is announced through a polite status region, such as Task added or 3 tasks left. After a delete, focus moves to the next row, the previous row or the add field, and a row that the current filter hides hands focus to its neighbour.

What’s included

  • Add, tick off, delete, filter All, Active and Done, and clear finished tasks
  • Remaining count and an empty-state message for each filter
  • Tasks saved to localStorage, one key per list
  • Rows cloned from a template element, about a hundred lines of vanilla JavaScript
  • Full app and compact widget variants share one script

Accessibility

Each task is a real checkbox with a label, and each delete button is named after its task, such as Delete “Buy milk, eggs and a sourdough loaf”. Filter buttons expose their state with aria-pressed, and a polite status region announces added, ticked and deleted tasks with the number left. Focus moves to the neighbouring row after a delete, every control has a visible focus outline, and colour transitions stop under prefers-reduced-motion.

Customise it

Change --td-accent, --td-accent-dark, --td-box and --td-radius on .td for colour and shape. The Bootstrap version maps the same variables onto --bs-card-*, --bs-btn-* and the .form-check-input colours; in Tailwind swap the #c2410c and #9a3412 colour utilities.