Todo Tower

beta

To-do cards stacked like bricks: tick one and it flicks away as the cards above drop; drag one and the stack sways.

  • Call the landlord
  • Pay the invoice
  • Export the icons
  • Book the flights
  • Renew the domain
  • Reply to Nadia

Installation

Terminal
pnpm dlx @dowel-ui/cli add todo-tower

npm packages installed: class-variance-authority, motion.

Accessibility

A labelled list. Each card's check is a real button named "Done: <task>", so Tab and Enter/Space complete tasks; completion is announced politely and focus moves to the card that drops into the gap. Dragging and swaying the stack is decoration with no keyboard equivalent because it does nothing. Under reduced motion there is no sway, flick or drop.

Props

TodoTower

PropTypeDefault
checkLabel

The check button's name for a to-do.

(label: string) => string(text) => `Done: ${text}`
completedLabel

Announced when a to-do is completed.

(label: string) => string(text) => `Completed: ${text}`
defaultItems

Initial to-dos when uncontrolled, top of the stack first.

TodoItem[][]
fill"light" | "dark""light"
gravity

How hard the stack pulls back together, 0–100.

number50
items

Controlled to-dos, top of the stack first.

TodoItem[]
label

The list's accessible name.

string"To-dos"
loop

Re-drop the whole stack once the last card is done (uncontrolled only).

booleanfalse
onComplete

Called with the to-do that was completed.

(item: TodoItem) => void
onItemsChange

Called with the remaining to-dos after one is completed.

(items: TodoItem[]) => void
slip

How loosely the cards above a held one sway, 0–100.

number45
strokeboolean

Plus every attribute of <div> except defaultValue, onChange.

Quality

10/10 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focuspasses
  • No fixed widthspasses

Source

This is exactly what dowel add todo-tower writes into your project, with imports rewritten to your own path alias.

ui/todo-tower.tsx
"use client";

// Ported from bencho Todo tower (MIT, © 2026 Lorenzo Cabra). See THIRD_PARTY_NOTICES.md.
import { cva, type VariantProps } from "class-variance-authority";
import {
  MotionConfig,
  animate,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
  useTransform,
  useVelocity,
  type MotionValue,
} from "motion/react";
import {
  useEffect,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type CSSProperties,
  type PointerEvent,
} from "react";

import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";

/*
 * A to-do list stacked like bricks on the floor. Ticking a card flicks it off
 * the side and the cards above drop into the gap; grabbing a card drags it,
 * and the cards stacked on top of it sway after it on springs.
 *
 * Deviation from the source: bencho runs a full rigid-body simulation in which
 * cards collide and topple off each other. This keeps the parts of that which
 * carry meaning — the flick, the drop, the sway of a dragged stack — without a
 * physics engine: each card above the one held follows it on a spring that
 * gets looser the higher it sits, and tilts with its own velocity. That is
 * what `motion` is for here (springs carrying a drag's velocity); the flick
 * and the drop are CSS. Dragging is decoration, so it has no keyboard
 * equivalent: every card's "Done" is a real button, and that is the action.
 *
 * Added: completion is announced politely, and focus moves to the card that
 * drops into the gap. Under reduced motion there is no sway, flick or drop.
 */

const PREFIX = "dowel-todo-tower";

const STYLES = `
@keyframes ${PREFIX}-drop{from{transform:translateY(-120%);opacity:0}}
[data-slot=todo-tower-card]{animation:${PREFIX}-drop calc(420ms * var(--motion-scale,1)) cubic-bezier(.5,0,.6,1.25) both;animation-delay:calc(var(--todo-tower-order,0) * 60ms * var(--motion-scale,1))}
`;

/** Height of one brick, and the gap-free pitch of the stack, in px. */
const PITCH = 46;
/** Milliseconds between ticking a card and removing it: the flick. */
const EXIT = 350;

const todoTowerVariants = cva("relative w-[18.75rem] max-w-full touch-none select-none", {
  variants: {
    /** Card surface — the source's Fill switch. */
    fill: {
      light:
        "[--todo-tower-card:var(--color-card)] [--todo-tower-ink:var(--color-card-foreground)]",
      dark: "[--todo-tower-card:var(--color-foreground)] [--todo-tower-ink:var(--color-background)]",
    },
    /** A hairline ring on each card — the source's Stroke switch. */
    stroke: {
      true: "[&_[data-slot=todo-tower-card]]:shadow-[inset_0_0_0_1px_var(--color-border)]",
      false: "",
    },
  },
  defaultVariants: { fill: "light", stroke: false },
});

export interface TodoItem {
  id: string;
  label: string;
}

/** A stable, id-derived horizontal nudge of ±6–15px, so the pile looks hand-stacked. */
function nudge(id: string): number {
  let hash = 0;
  for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
  const magnitude = 6 + (Math.abs(hash) % 10);
  return hash % 2 === 0 ? magnitude : -magnitude;
}

function reducedMotion(): boolean {
  return (
    typeof window.matchMedia === "function" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
}

interface Leader {
  index: number;
  x: MotionValue<number>;
  y: MotionValue<number>;
}

interface RowProps {
  item: TodoItem;
  index: number;
  count: number;
  leader: Leader | null;
  leaderIndex: number | null;
  out: boolean;
  still: boolean;
  gravity: number;
  slip: number;
  checkLabel: (label: string) => string;
  onCheck: (id: string) => void;
  onGrab: (index: number, event: PointerEvent<HTMLLIElement>) => void;
}

function Row({
  item,
  index,
  count,
  leader,
  leaderIndex,
  out,
  still,
  gravity,
  slip,
  checkLabel,
  onCheck,
  onGrab,
}: RowProps) {
  const follows = leader !== null && leaderIndex !== null && index <= leaderIndex;
  const lag = follows ? leaderIndex - index : 0;
  const targetX = useMotionValue(0);
  const targetY = useMotionValue(0);
  // The held card tracks the pointer almost rigidly; each card above it is
  // looser than the one below, so a tall stack sways like one.
  const stiffness = lag === 0 ? 2400 : (200 + gravity * 6) / (1 + lag * 0.6);
  const damping = lag === 0 ? 80 : 8 + (100 - slip) * 0.25;
  const x = useSpring(targetX, { stiffness, damping });
  const y = useSpring(targetY, { stiffness, damping });
  const velocity = useVelocity(x);
  const rotate = useTransform(velocity, (v) =>
    still ? 0 : Math.max(-35, Math.min(35, v * 0.02 * (1 + lag * 0.4))),
  );

  useMotionValueEvent(leader?.x ?? targetX, "change", (value) => {
    if (follows && leader) targetX.set(value);
  });
  useMotionValueEvent(leader?.y ?? targetY, "change", (value) => {
    if (follows && leader) targetY.set(value);
  });

  useEffect(() => {
    if (follows) return;
    targetX.set(0);
    targetY.set(0);
  }, [follows, targetX, targetY]);

  return (
    <li
      data-slot="todo-tower-row"
      data-held={leaderIndex === index || undefined}
      className={cn(
        "absolute bottom-0 left-1/2 -translate-x-1/2 translate-x-[calc(-50%+var(--todo-tower-x))] translate-y-[var(--todo-tower-y)]",
        "transition-[translate] duration-[var(--duration-slow)] ease-[cubic-bezier(0.5,0,0.6,1.3)]",
        "data-[held]:z-3",
      )}
      style={
        {
          "--todo-tower-x": `${String(nudge(item.id))}px`,
          "--todo-tower-y": `${String(-(count - 1 - index) * PITCH)}px`,
        } as CSSProperties
      }
      onPointerDown={(event) => {
        onGrab(index, event);
      }}
    >
      <motion.div data-slot="todo-tower-body" style={{ x, y, rotate }}>
        <div
          data-slot="todo-tower-card"
          data-out={out || undefined}
          className={cn(
            "flex h-[2.875rem] w-[12.875rem] cursor-grab items-center gap-2.5 rounded-[0.6875rem] px-3.5",
            "bg-[var(--todo-tower-card)] text-[var(--todo-tower-ink)]",
            "in-data-[held]:-translate-y-0.5 in-data-[held]:cursor-grabbing",
            "transition-[translate,rotate,opacity] duration-[var(--duration-slow),var(--duration-slow),var(--duration-normal)] ease-[var(--ease-in-quint)]",
            "data-[out]:translate-x-[118%] data-[out]:rotate-5 data-[out]:opacity-0 data-[out]:delay-[var(--duration-instant)]",
          )}
          style={{ "--todo-tower-order": count - 1 - index } as CSSProperties}
        >
          <button
            type="button"
            aria-label={checkLabel(item.label)}
            aria-pressed={out}
            data-slot="todo-tower-check"
            className={cn(
              "grid size-[1.1875rem] shrink-0 place-items-center rounded-md",
              "shadow-[inset_0_0_0_1.5px_color-mix(in_oklab,currentColor_22%,transparent)] hover:shadow-[inset_0_0_0_1.5px_color-mix(in_oklab,currentColor_38%,transparent)]",
              "aria-pressed:bg-[var(--todo-tower-ink)] aria-pressed:text-[var(--todo-tower-card)] aria-pressed:shadow-none",
              focusRing,
            )}
            onPointerDown={(event) => {
              // The check is a button, not a handle.
              event.stopPropagation();
            }}
            onClick={() => {
              onCheck(item.id);
            }}
          >
            <svg
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="3.4"
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden="true"
              className={cn("size-3", !out && "invisible")}
            >
              <path d="M20 6 9 17l-5-5" />
            </svg>
          </button>
          <span
            data-slot="todo-tower-text"
            className={cn(
              "truncate text-[0.9375rem] leading-none tracking-[-0.005em] opacity-80",
              out && "line-through opacity-45",
            )}
          >
            {item.label}
          </span>
        </div>
      </motion.div>
    </li>
  );
}

export interface TodoTowerProps
  extends
    Omit<ComponentPropsWithRef<"div">, "defaultValue" | "onChange">,
    VariantProps<typeof todoTowerVariants> {
  /** Controlled to-dos, top of the stack first. */
  items?: TodoItem[];
  /** Initial to-dos when uncontrolled, top of the stack first. */
  defaultItems?: TodoItem[];
  /** Called with the remaining to-dos after one is completed. */
  onItemsChange?: (items: TodoItem[]) => void;
  /** Called with the to-do that was completed. */
  onComplete?: (item: TodoItem) => void;
  /** Re-drop the whole stack once the last card is done (uncontrolled only). */
  loop?: boolean;
  /** How hard the stack pulls back together, 0–100. */
  gravity?: number;
  /** How loosely the cards above a held one sway, 0–100. */
  slip?: number;
  /** The list's accessible name. */
  label?: string;
  /** The check button's name for a to-do. */
  checkLabel?: (label: string) => string;
  /** Announced when a to-do is completed. */
  completedLabel?: (label: string) => string;
}

/** A stack of to-do cards: tick one and it flicks away; grab one and the stack sways. */
export function TodoTower({
  className,
  fill,
  stroke,
  items: itemsProp,
  defaultItems = [],
  onItemsChange,
  onComplete,
  loop = false,
  gravity = 50,
  slip = 45,
  label = "To-dos",
  checkLabel = (text) => `Done: ${text}`,
  completedLabel = (text) => `Completed: ${text}`,
  style,
  ...props
}: TodoTowerProps) {
  const [inner, setInner] = useState(defaultItems);
  const items = itemsProp ?? inner;
  const [round, setRound] = useState(0);
  const [leaving, setLeaving] = useState<string[]>([]);
  const [leaderIndex, setLeaderIndex] = useState<number | null>(null);
  const [announced, setAnnounced] = useState("");
  const reduced = useReducedMotion() ?? false;
  const leaderX = useMotionValue(0);
  const leaderY = useMotionValue(0);
  const grab = useRef<{ id: number; x: number; y: number } | null>(null);
  const listRef = useRef<HTMLUListElement | null>(null);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const focusIndex = useRef<number | null>(null);

  useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer);
    },
    [],
  );

  // After a card leaves, focus the one that dropped into its place.
  useEffect(() => {
    const index = focusIndex.current;
    if (index === null) return;
    focusIndex.current = null;
    const checks = listRef.current?.querySelectorAll<HTMLElement>(
      "[data-slot=todo-tower-check]",
    );
    const target = checks?.[Math.min(index, checks.length - 1)];
    if (target) target.focus();
    else listRef.current?.focus();
  });

  function remove(id: string) {
    const index = items.findIndex((item) => item.id === id);
    const item = items[index];
    setLeaving((current) => current.filter((value) => value !== id));
    if (!item) return;
    const next = items.filter((value) => value.id !== id);
    focusIndex.current = index;
    onComplete?.(item);
    onItemsChange?.(next);
    setAnnounced(completedLabel(item.label));
    if (itemsProp !== undefined) return;
    if (next.length === 0 && loop) {
      setInner(defaultItems);
      setRound((value) => value + 1);
    } else {
      setInner(next);
    }
  }

  function check(id: string) {
    if (leaving.includes(id)) return;
    if (reducedMotion()) {
      remove(id);
      return;
    }
    setLeaving((current) => [...current, id]);
    timers.current.push(
      setTimeout(() => {
        remove(id);
      }, EXIT),
    );
  }

  function handleGrab(index: number, event: PointerEvent<HTMLLIElement>) {
    if (event.button !== 0 || reduced) return;
    event.currentTarget.setPointerCapture(event.pointerId);
    grab.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
    leaderX.jump(0);
    leaderY.jump(0);
    setLeaderIndex(index);
  }

  function handleMove(event: PointerEvent<HTMLDivElement>) {
    const current = grab.current;
    if (!current || current.id !== event.pointerId) return;
    leaderX.set(event.clientX - current.x);
    leaderY.set(event.clientY - current.y);
  }

  function handleRelease(event: PointerEvent<HTMLDivElement>) {
    const current = grab.current;
    if (!current || current.id !== event.pointerId) return;
    grab.current = null;
    // Thrown: the stack springs home carrying the throw's velocity.
    const options = { type: "spring", stiffness: 180 + gravity * 3, damping: 18 } as const;
    void Promise.all([
      animate(leaderX, 0, { ...options, velocity: leaderX.getVelocity() }),
      animate(leaderY, 0, { ...options, velocity: leaderY.getVelocity() }),
    ]).then(() => {
      if (!grab.current) setLeaderIndex(null);
    });
  }

  const leader: Leader | null =
    leaderIndex === null ? null : { index: leaderIndex, x: leaderX, y: leaderY };

  return (
    <MotionConfig reducedMotion="user">
      <div
        data-slot="todo-tower"
        className={cn(todoTowerVariants({ fill, stroke }), className)}
        style={{ height: Math.max(1, items.length) * PITCH + 20, ...style }}
        onPointerMove={handleMove}
        onPointerUp={handleRelease}
        onPointerCancel={handleRelease}
        {...props}
      >
        <style href={PREFIX} precedence="dowel">
          {STYLES}
        </style>
        <ul
          key={round}
          ref={listRef}
          aria-label={label}
          tabIndex={-1}
          className="absolute inset-0 outline-none"
        >
          {items.map((item, index) => (
            <Row
              key={item.id}
              item={item}
              index={index}
              count={items.length}
              leader={leader}
              leaderIndex={leaderIndex}
              out={leaving.includes(item.id)}
              still={reduced}
              gravity={gravity}
              slip={slip}
              checkLabel={checkLabel}
              onCheck={check}
              onGrab={handleGrab}
            />
          ))}
        </ul>
        <span role="status" aria-live="polite" className="sr-only">
          {announced}
        </span>
      </div>
    </MotionConfig>
  );
}

export { todoTowerVariants };