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

Edit with AIPro

Describe a change to Todo List and get the updated code. The live preview above stays as the original.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/todo-list.tsx

    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 />;
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.