Todo List
Tasks with an animated check, strike-through, filters and smooth add/remove.
2 tasks left
- Record the launch video
- Post on Product Hunt
- Reply to every comment
components/ui/todo-list.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Check, Plus, X } from "lucide-react";
import { useRef, useState, type FormEvent } from "react";
type Todo = { id: number; text: string; done: boolean };
type Filter = "all" | "active" | "done";
const seed: Todo[] = [
{ id: 1, text: "Record the launch video", done: true },
{ id: 2, text: "Post on Product Hunt", done: false },
{ id: 3, text: "Reply to every comment", done: false },
];
/** Todo list with an animated check, strike-through, filters and smooth add/remove. */
export function TodoList({ initial = seed, onChange }: { initial?: Todo[]; onChange?: (todos: Todo[]) => void }) {
const [todos, setTodos] = useState(initial);
const [filter, setFilter] = useState<Filter>("all");
const [text, setText] = useState("");
const nextId = useRef(100);
function save(next: Todo[]) {
setTodos(next);
onChange?.(next);
}
function add(e: FormEvent) {
e.preventDefault();
if (!text.trim()) return;
save([...todos, { id: nextId.current++, text: text.trim(), done: false }]);
setText("");
}
const shown = todos.filter((t) => filter === "all" || (filter === "done") === t.done);
const left = todos.filter((t) => !t.done).length;
return (
<div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm">
<form onSubmit={add} className="flex gap-2">
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Add a task…" aria-label="New task" className="h-10 min-w-0 flex-1 rounded-xl border border-zinc-200 px-3.5 text-sm outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-600/10" />
<button aria-label="Add task" className="grid size-10 place-items-center rounded-xl bg-zinc-900 text-white hover:bg-zinc-800">
<Plus className="size-4" />
</button>
</form>
<div className="mt-3 flex items-center justify-between text-xs">
<div role="radiogroup" aria-label="Filter" className="flex gap-1">
{(["all", "active", "done"] as const).map((f) => (
<button key={f} role="radio" aria-checked={filter === f} onClick={() => setFilter(f)} className="relative rounded-md px-2 py-1 capitalize">
{filter === f && <motion.span layoutId="todo-filter" className="absolute inset-0 rounded-md bg-zinc-100" />}
<span className={`relative ${filter === f ? "font-medium text-zinc-900" : "text-zinc-500"}`}>{f}</span>
</button>
))}
</div>
<span className="text-zinc-400">
{left} {left === 1 ? "task" : "tasks"} left
</span>
</div>
<ul className="mt-2">
<AnimatePresence initial={false}>
{shown.map((t) => (
<motion.li key={t.id} layout initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0, x: 30 }} className="overflow-hidden">
<div className="group flex items-center gap-3 rounded-lg px-1 py-2 hover:bg-zinc-50">
<button
role="checkbox"
aria-checked={t.done}
aria-label={t.text}
onClick={() => save(todos.map((x) => (x.id === t.id ? { ...x, done: !x.done } : x)))}
className={`grid size-5 shrink-0 place-items-center rounded-full border-2 transition-colors ${t.done ? "border-emerald-500 bg-emerald-500" : "border-zinc-300 hover:border-zinc-400"}`}
>
<AnimatePresence>
{t.done && (
<motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} transition={{ type: "spring", stiffness: 500, damping: 22 }}>
<Check className="size-3 text-white" strokeWidth={3.5} />
</motion.span>
)}
</AnimatePresence>
</button>
<span className="relative flex-1 text-sm">
<span className={`transition-colors ${t.done ? "text-zinc-400" : "text-zinc-800"}`}>{t.text}</span>
<motion.span aria-hidden className="absolute left-0 top-1/2 h-px bg-zinc-400" initial={false} animate={{ width: t.done ? "100%" : "0%" }} transition={{ duration: 0.25 }} />
</span>
<button aria-label={`Delete ${t.text}`} onClick={() => save(todos.filter((x) => x.id !== t.id))} className="rounded-md p-1 text-zinc-300 opacity-0 transition hover:bg-zinc-200 hover:text-zinc-700 focus:opacity-100 group-hover:opacity-100">
<X className="size-3.5" />
</button>
</div>
</motion.li>
))}
</AnimatePresence>
</ul>
{shown.length === 0 && <p className="py-6 text-center text-sm text-zinc-400">{filter === "done" ? "Nothing finished yet." : "All clear 🎉"}</p>}
</div>
);
}
export default function Demo() {
return <TodoList />;
}
Edit with AIPro
Describe a change to Todo List and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/todo-list.tsxcomponents/ui/todo-list.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { Check, Plus, X } from "lucide-react"; import { useRef, useState, type FormEvent } from "react"; type Todo = { id: number; text: string; done: boolean }; type Filter = "all" | "active" | "done"; const seed: Todo[] = [ { id: 1, text: "Record the launch video", done: true }, { id: 2, text: "Post on Product Hunt", done: false }, { id: 3, text: "Reply to every comment", done: false }, ]; /** Todo list with an animated check, strike-through, filters and smooth add/remove. */ export function TodoList({ initial = seed, onChange }: { initial?: Todo[]; onChange?: (todos: Todo[]) => void }) { const [todos, setTodos] = useState(initial); const [filter, setFilter] = useState<Filter>("all"); const [text, setText] = useState(""); const nextId = useRef(100); function save(next: Todo[]) { setTodos(next); onChange?.(next); } function add(e: FormEvent) { e.preventDefault(); if (!text.trim()) return; save([...todos, { id: nextId.current++, text: text.trim(), done: false }]); setText(""); } const shown = todos.filter((t) => filter === "all" || (filter === "done") === t.done); const left = todos.filter((t) => !t.done).length; return ( <div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"> <form onSubmit={add} className="flex gap-2"> <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Add a task…" aria-label="New task" className="h-10 min-w-0 flex-1 rounded-xl border border-zinc-200 px-3.5 text-sm outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-600/10" /> <button aria-label="Add task" className="grid size-10 place-items-center rounded-xl bg-zinc-900 text-white hover:bg-zinc-800"> <Plus className="size-4" /> </button> </form> <div className="mt-3 flex items-center justify-between text-xs"> <div role="radiogroup" aria-label="Filter" className="flex gap-1"> {(["all", "active", "done"] as const).map((f) => ( <button key={f} role="radio" aria-checked={filter === f} onClick={() => setFilter(f)} className="relative rounded-md px-2 py-1 capitalize"> {filter === f && <motion.span layoutId="todo-filter" className="absolute inset-0 rounded-md bg-zinc-100" />} <span className={`relative ${filter === f ? "font-medium text-zinc-900" : "text-zinc-500"}`}>{f}</span> </button> ))} </div> <span className="text-zinc-400"> {left} {left === 1 ? "task" : "tasks"} left </span> </div> <ul className="mt-2"> <AnimatePresence initial={false}> {shown.map((t) => ( <motion.li key={t.id} layout initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0, x: 30 }} className="overflow-hidden"> <div className="group flex items-center gap-3 rounded-lg px-1 py-2 hover:bg-zinc-50"> <button role="checkbox" aria-checked={t.done} aria-label={t.text} onClick={() => save(todos.map((x) => (x.id === t.id ? { ...x, done: !x.done } : x)))} className={`grid size-5 shrink-0 place-items-center rounded-full border-2 transition-colors ${t.done ? "border-emerald-500 bg-emerald-500" : "border-zinc-300 hover:border-zinc-400"}`} > <AnimatePresence> {t.done && ( <motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} transition={{ type: "spring", stiffness: 500, damping: 22 }}> <Check className="size-3 text-white" strokeWidth={3.5} /> </motion.span> )} </AnimatePresence> </button> <span className="relative flex-1 text-sm"> <span className={`transition-colors ${t.done ? "text-zinc-400" : "text-zinc-800"}`}>{t.text}</span> <motion.span aria-hidden className="absolute left-0 top-1/2 h-px bg-zinc-400" initial={false} animate={{ width: t.done ? "100%" : "0%" }} transition={{ duration: 0.25 }} /> </span> <button aria-label={`Delete ${t.text}`} onClick={() => save(todos.filter((x) => x.id !== t.id))} className="rounded-md p-1 text-zinc-300 opacity-0 transition hover:bg-zinc-200 hover:text-zinc-700 focus:opacity-100 group-hover:opacity-100"> <X className="size-3.5" /> </button> </div> </motion.li> ))} </AnimatePresence> </ul> {shown.length === 0 && <p className="py-6 text-center text-sm text-zinc-400">{filter === "done" ? "Nothing finished yet." : "All clear 🎉"}</p>} </div> ); } export default function Demo() { return <TodoList />; }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.