Flaming Todos
Installation
npx shadcn@latest add https://uibrik.com/r/flaming-todos.jsonInstall the packages
npm install threeSet up squircle corners
Needs
@squircle/core,@squircle/tailwindcssand a few lines of CSS. See squircle setup.Copy the source into your project
components/uibrik/flaming-todos/flaming-todos.tsx"use client"; import { useEffect, useRef, useState } from "react"; import { BurnScene } from "./burn"; type Status = "entering" | "idle" | "burning" | "fading" | "gone"; type Item = { id: number; label: string; status: Status; open: boolean; // row height (layout animation) shown: boolean; // checkbox visibility }; export const SAMPLE_TODOS = [ "Design the interface", "Build the components", "Polish the details", "Ship it to production", ]; const easeInOut = (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; export type FlamingTodosProps = { /** To-dos listed at the start. */ initialItems?: string[]; placeholder?: string; /** Longest to-do, in characters. */ maxLength?: number; /** Seconds the flames take to climb through a to-do. */ burnDuration?: number; /** Ash particles per burn. */ ash?: number; /** Seconds a new to-do takes to fly from the input into the list. */ flightDuration?: number; /** Width of the list in px. */ width?: number; onAdd?: (label: string) => void; /** Called when a to-do is checked off (as it starts burning). */ onComplete?: (label: string) => void; className?: string; }; /** A to-do list where finished items burn away in WebGL flames. */ export function FlamingTodos({ initialItems = SAMPLE_TODOS, placeholder = "Add a to-do", maxLength = 32, burnDuration = 1.3, ash = 300, flightDuration = 0.6, width = 416, onAdd, onComplete, className, }: FlamingTodosProps) { const FLIGHT_MS = flightDuration * 1000; const initial = initialItems; const [items, setItems] = useState<Item[]>(() => initial.map((label, id) => ({ id, label, status: "idle", open: true, shown: true, })), ); const [text, setText] = useState(""); const [placeholderOn, setPlaceholderOn] = useState(true); const canvasRef = useRef<HTMLCanvasElement>(null); const inputRef = useRef<HTMLInputElement>(null); const sceneRef = useRef<BurnScene | null>(null); const textRefs = useRef(new Map<number, HTMLParagraphElement | null>()); const nextId = useRef(initial.length); const cleanups = useRef(new Set<() => void>()); useEffect(() => { sceneRef.current = new BurnScene(canvasRef.current!); sceneRef.current.duration = burnDuration; sceneRef.current.particles = ash; const pending = cleanups.current; return () => { sceneRef.current?.destroy(); sceneRef.current = null; pending.forEach((fn) => fn()); }; // created once; tuning is pushed below // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!sceneRef.current) return; sceneRef.current.duration = burnDuration; sceneRef.current.particles = ash; }, [burnDuration, ash]); const patch = (id: number, next: Partial<Item>) => setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...next } : it))); const ignite = (id: number) => { const el = textRefs.current.get(id); const item = items.find((it) => it.id === id); if (!el || !item || item.status !== "idle" || !sceneRef.current) return; patch(id, { status: "burning" }); onComplete?.(item.label); // checkbox fades out mid-burn; the row closes up as soon as the flames finish setTimeout(() => patch(id, { status: "fading" }), burnDuration * 730); sceneRef.current.burn(el, () => { patch(id, { status: "gone" }); setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 450); }); }; // the typed text flies from the input to its new row while the row opens up const flyText = (value: string, id: number, input: HTMLInputElement) => { const target = textRefs.current.get(id); if (!target) return; const ir = input.getBoundingClientRect(); const padLeft = parseFloat(getComputedStyle(input).paddingLeft) || 0; const cs = getComputedStyle(target); const clone = document.createElement("div"); clone.textContent = value; Object.assign(clone.style, { position: "fixed", left: "0", top: "0", zIndex: "60", pointerEvents: "none", whiteSpace: "nowrap", font: cs.font, letterSpacing: cs.letterSpacing, lineHeight: cs.lineHeight, color: cs.color, willChange: "transform", }); document.body.append(clone); const x0 = ir.left + padLeft; const y0 = ir.top + ir.height / 2 - clone.offsetHeight / 2; // park it on the input text straight away: until the first frame ticks it // would otherwise sit at the top-left corner of the page clone.style.transform = `translate(${x0}px, ${y0}px)`; // flyText only runs from the add handler, never during render // eslint-disable-next-line react-hooks/purity const start = performance.now(); let raf = 0; const finish = () => { cancelAnimationFrame(raf); clone.remove(); cleanups.current.delete(finish); }; cleanups.current.add(finish); const tick = (now: number) => { const t = Math.min((now - start) / FLIGHT_MS, 1); const e = easeInOut(t); const tr = target.getBoundingClientRect(); // follows the row as it opens clone.style.transform = `translate(${x0 + (tr.left - x0) * e}px, ${ y0 + (tr.top - y0) * e }px)`; if (t < 1) { raf = requestAnimationFrame(tick); } else { // reveal the real text first, and keep the clone up for a couple of // frames so React's re-render never leaves a frame with neither patch(id, { status: "idle" }); raf = requestAnimationFrame(() => { raf = requestAnimationFrame(finish); }); } }; raf = requestAnimationFrame(tick); }; const add = () => { const value = text.trim(); const input = inputRef.current; if (!value || !input) return; const id = nextId.current++; onAdd?.(value); setText(""); // keep the placeholder away while the text is flying out, then fade it in setPlaceholderOn(false); setTimeout(() => setPlaceholderOn(true), 450); setItems((prev) => [ { id, label: value, status: "entering", open: false, shown: false }, ...prev, ]); // wait for the closed row to mount, then open it and launch the text requestAnimationFrame(() => requestAnimationFrame(() => { patch(id, { open: true }); flyText(value, id, input); setTimeout(() => patch(id, { shown: true }), 300); }), ); }; return ( <> <canvas ref={canvasRef} className="fixed inset-0 w-full h-full pointer-events-none z-50" /> <div className={`flex flex-col max-w-[calc(100vw-2rem)] ${className ?? ""}`} style={{ width }}> <div className="flex items-center gap-3 pb-10"> <input ref={inputRef} value={text} maxLength={maxLength} placeholder={placeholder} aria-label="New to-do" onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && add()} className={`flex-1 min-w-0 h-11 px-4 squircle-mask squircle-2xl squircle-smooth-xl bg-neutral-100 dark:bg-neutral-800 text-xl font-medium tracking-tight text-zinc-700 dark:text-zinc-200 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 outline-none ${ placeholderOn ? "placeholder:opacity-100 placeholder:transition-opacity placeholder:duration-300" : "placeholder:opacity-0" }`} /> <button type="button" aria-label="Add to-do" disabled={!text.trim()} onClick={add} className="size-11 shrink-0 squircle-mask squircle-2xl squircle-smooth-xl bg-zinc-700 dark:bg-zinc-200 flex items-center justify-center cursor-pointer transition-opacity duration-150 disabled:opacity-40 disabled:cursor-default" > <svg viewBox="0 0 24 24" className="size-5 text-white dark:text-zinc-900" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" > <path d="M5 12h14M12 5v14" /> </svg> </button> </div> {items.map((item) => { const s = item.status; const checked = s === "burning" || s === "fading" || s === "gone"; const hidden = !item.shown || s === "fading" || s === "gone"; return ( <div key={item.id} aria-hidden={s === "gone"} className={`grid transition-[grid-template-rows] duration-350 ease-in-out ${ item.open && s !== "gone" ? "grid-rows-[1fr]" : "grid-rows-[0fr]" }`} > <div className="min-h-0 overflow-hidden"> <div className="flex items-center gap-6 pb-1.5"> <button type="button" role="checkbox" aria-checked={checked} aria-label={item.label} disabled={s !== "idle"} onClick={() => ignite(item.id)} className={`size-6 shrink-0 squircle-mask squircle-lg squircle-smooth-xl cursor-pointer flex items-center justify-center transition-[background-color,opacity] duration-250 ${ checked ? "bg-zinc-700 dark:bg-zinc-200" : "bg-neutral-100 dark:bg-neutral-800" } ${hidden ? "opacity-0" : ""}`} > <svg viewBox="0 0 24 24" className={`size-3.5 text-white dark:text-zinc-900 transition-opacity duration-150 ${ checked ? "opacity-100" : "opacity-0" }`} fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" > <path d="M5 12h14" /> </svg> </button> <p ref={(el) => { textRefs.current.set(item.id, el); }} className={`text-xl font-medium tracking-tight text-zinc-700 dark:text-zinc-200 whitespace-nowrap ${ s === "idle" ? "" : "opacity-0" }`} > {item.label} </p> </div> </div> </div> ); })} </div> </> ); }
Adds the files to
components/uibrik/flaming-todos and installs three. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/flaming-todos.Props
FlamingTodos- placeholder
- Type string
- "Add a to-do"
- burnDuration
- Type number
- 1.3
- How long the flames take to climb through a to-do.
- ash
- Type number
- 300
- Ash particles per burn.
- flightDuration
- Type number
- 0.6
- How long a new to-do takes to fly into the list.
- width
- Type number
- 416
- Width of the list.
- maxLength
- Type number
- 32
- Longest to-do, in characters.
- initialItems
- Type string[]
- SAMPLE_TODOS
- To-dos listed at the start.
- onAdd
- Type (label: string) => void
- –
- Called when a to-do is added.
- onComplete
- Type (label: string) => void
- –
- Called when a to-do is checked off.
- className
- Type string
- –
- Classes for the list.