Task List
A to-do list: add with Enter, check off with a strike, filter All / Active / Done, clear completed, and undo a delete.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/task-list.json'use client'
import { useEffect, useId, useState } from 'react'
import './task-list.css'
export interface Task { id: string; text: string; done?: boolean }
export interface TaskListProps {
defaultTasks?: Task[]
onChange?: (tasks: Task[]) => void
filter?: 'all' | 'active' | 'done'
sinkDone?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** To-do list with filters and undoable delete. */
export function TaskList({ defaultTasks = [], onChange, filter: f0 = 'all', sinkDone = true, fill = 'dark', className }: TaskListProps) {
const [tasks, setTasks] = useState(defaultTasks)
const [draft, setDraft] = useState('')
const [filter, setFilter] = useState(f0)
const [undo, setUndo] = useState<{ t: Task; i: number } | null>(null)
const uid = useId()
useEffect(() => { if (!undo) return; const t = setTimeout(() => setUndo(null), 5000); return () => clearTimeout(t) }, [undo])
const commit = (n: Task[]) => { setTasks(n); onChange?.(n) }
const add = () => { const t = draft.trim(); if (!t) return; commit([{ id: `${uid}${Date.now()}`, text: t }, ...tasks]); setDraft(''); if (filter === 'done') setFilter('all') }
const left = tasks.filter((t) => !t.done).length
let shown = tasks.filter((t) => (filter === 'all' ? true : filter === 'done' ? t.done : !t.done))
if (sinkDone) shown = [...shown.filter((t) => !t.done), ...shown.filter((t) => t.done)]
return (
<div className={['sd-tkl', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-tkl__add">
<span aria-hidden>+</span>
<input className="sd-tkl__in" aria-label="New task" placeholder="Add a task" enterKeyHint="done" value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') add() }} />
</div>
<ul className="sd-tkl__list" aria-label="Tasks">
{shown.map((t) => (
<li key={t.id} className="sd-tkl__it" data-done={t.done || undefined}>
<input id={t.id} type="checkbox" className="sd-tkl__cb" checked={!!t.done} onChange={() => commit(tasks.map((x) => (x.id === t.id ? { ...x, done: !x.done } : x)))} />
<label htmlFor={t.id} className="sd-tkl__t">{t.text}</label>
<button type="button" className="sd-tkl__x" aria-label={`Delete ${t.text}`} onClick={() => { const i = tasks.indexOf(t); setUndo({ t, i }); commit(tasks.filter((x) => x.id !== t.id)) }}>
<svg viewBox="0 0 14 14" aria-hidden><path d="m3 3 8 8M11 3l-8 8" /></svg>
</button>
</li>
))}
{!shown.length && <li className="sd-tkl__empty">{filter === 'done' ? 'Nothing finished yet' : filter === 'active' ? 'All done. Nice.' : 'No tasks'}</li>}
</ul>
{undo && <div className="sd-tkl__undo" role="status"><span>Deleted “{undo.t.text.slice(0, 22)}”</span><button type="button" onClick={() => { const n = tasks.slice(); n.splice(undo.i, 0, undo.t); commit(n); setUndo(null) }}>Undo</button></div>}
<div className="sd-tkl__foot">
<span aria-live="polite">{left} left</span>
<div role="radiogroup" aria-label="Filter" style={{ display: 'flex' }}>
{(['all', 'active', 'done'] as const).map((x) => <button key={x} type="button" role="radio" aria-checked={filter === x} className="sd-tkl__f" onClick={() => setFilter(x)}>{x[0].toUpperCase() + x.slice(1)}</button>)}
</div>
{tasks.some((t) => t.done) && <button type="button" className="sd-tkl__clr" onClick={() => commit(tasks.filter((t) => !t.done))}>Clear done</button>}
</div>
</div>
)
}.sd-tkl { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-tkl[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-tkl :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-tkl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-tkl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-tkl { position: relative; width: min(100%, 320px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-tkl__add { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.sd-tkl__add span { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 99px; box-shadow: inset 0 0 0 1.5px var(--line); color: var(--mute); font-size: 15px; }
.sd-tkl__in { flex: 1; min-width: 0; border: 0; background: none; color: var(--fg); font: inherit; font-size: 14px; outline: 0; }
.sd-tkl__in::placeholder { color: var(--mute); }
.sd-tkl__list { list-style: none; margin: 0; padding: 4px 0; height: 236px; overflow-y: auto; }
.sd-tkl__it { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; animation: sd-tkl-in 240ms cubic-bezier(0.3, 1.3, 0.4, 1); }
.sd-tkl__it:hover .sd-tkl__x, .sd-tkl__x:focus-visible { opacity: 1; }
.sd-tkl__cb { appearance: none; flex: none; width: 20px; height: 20px; margin: 0; border-radius: 99px; box-shadow: inset 0 0 0 1.5px var(--mute); cursor: pointer; display: grid; place-items: center; transition: background-color 150ms, box-shadow 150ms; }
.sd-tkl__cb::after { content: ''; width: 9px; height: 5px; border: solid var(--on); border-width: 0 0 2px 2px; transform: translateY(-1px) rotate(-45deg) scale(0); transition: transform 200ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-tkl__cb:checked { background: var(--strong); box-shadow: none; }
.sd-tkl__cb:checked::after { transform: translateY(-1px) rotate(-45deg) scale(1); }
.sd-tkl__t { flex: 0 1 auto; min-width: 0; font-size: 14px; cursor: pointer; position: relative; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-tkl__t::after { content: ''; position: absolute; left: 0; top: 52%; height: 1.5px; width: 0; background: var(--mute); transition: width 260ms ease-out; }
.sd-tkl__it[data-done] .sd-tkl__t { color: var(--mute); }
.sd-tkl__it[data-done] .sd-tkl__t::after { width: 100%; }
.sd-tkl__x { margin-left: auto; flex: none; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 7px; color: var(--mute) !important; opacity: 0; transition: opacity 120ms; }
@media (hover: none) { .sd-tkl__x { opacity: 0.6; } }
.sd-tkl__x:hover { background: var(--soft); color: var(--fg) !important; }
.sd-tkl__x svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.sd-tkl__empty { padding: 50px 0; text-align: center; color: var(--mute); font-size: 13px; }
.sd-tkl__foot { display: flex; align-items: center; gap: 6px; padding: 8px 10px 8px 14px; border-top: 1px solid var(--line); font-size: 12px; color: var(--mute); }
.sd-tkl__foot > span { margin-right: auto; font-variant-numeric: tabular-nums; }
.sd-tkl__f { padding: 4px 8px; border-radius: 7px; color: var(--mute) !important; }
.sd-tkl__f[aria-checked='true'] { background: var(--soft); color: var(--fg) !important; }
.sd-tkl__clr { padding: 4px 6px; color: var(--mute) !important; }
.sd-tkl__clr:hover { color: var(--fg) !important; }
.sd-tkl__undo { position: absolute; left: 10px; right: 10px; bottom: 46px; display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; border-radius: 12px; background: var(--strong); color: var(--on); font-size: 13px; animation: sd-tkl-in 220ms ease-out; }
.sd-tkl__undo button { font-weight: 600; text-decoration: underline; text-underline-offset: 2px; }
@keyframes sd-tkl-in { from { opacity: 0; transform: translateY(-6px); } }
@media (prefers-reduced-motion: reduce) { .sd-tkl *, .sd-tkl *::before, .sd-tkl *::after { animation: none !important; transition: none !important; } }Overview
Type and press Enter to add to the top. Checking a task strikes it through and moves it down; the counter shows what is left.
Deleting shows an Undo bar for five seconds. Filters and “Clear completed” sit in the footer.
When to use
- Personal to-dos, checklists inside a project, shopping lists.
When not to use
- Team workflows with owners and stages: use a board.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/task-list.json$
npx shadcn@latest add https://sundaeui.dev/r/task-list.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/task-list.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/task-list.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy task-list.tsx and task-list.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TaskList } from '@/components/sundae/task-list'
<TaskList />Props
| Prop | Type | Default |
|---|---|---|
defaultTasksStarting tasks. | { id: string; text: string; done?: boolean }[] | — |
onChangeCalled with the list. | (tasks) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each task is a native checkbox with its text as label; delete buttons name the task.
- Count, filter results and undo are announced politely.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-tkl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Enter
- Add the task
- Space
- Toggle the focused task
FAQ
- Is Task List free?
- Yes. Task List is free for personal and commercial projects, with full source on this page.
- How do I install Task List?
- Run npx shadcn@latest add https://sundaeui.dev/r/task-list.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Task List support keyboard and screen readers?
- Each task is a native checkbox with its text as label; delete buttons name the task. Count, filter results and undo are announced politely.
- Does Task List respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Setup Checklist
Components · Understated- Pro
Focus Timer
Components · Refined - Pro
Swipe Actions
Components · Refined - Pro
Sortable List
Components · Refined - Pro
Bulk Actions Bar
Components · Refined Grid / List View Switcher
Components · Refined