Feedback

Animated Checklist

Task list where checking draws the check path and a left-to-right strikethrough, settling completed items to muted — fully reversible mid-animation.

Install

npx shadcn@latest add @paragon/animated-checklist

animated-checklist.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface AnimatedChecklistProps extends React.ComponentProps<"ul"> {}

/** The list shell — spacing only; each item owns its own state. */
export function AnimatedChecklist({
  className,
  ...props
}: AnimatedChecklistProps) {
  return (
    <ul
      data-slot="animated-checklist"
      className={cn("flex flex-col gap-0.5", className)}
      {...props}
    />
  );
}

export interface AnimatedChecklistItemProps
  extends React.ComponentProps<"button"> {
  /** Controlled checked state. Leave undefined for uncontrolled. */
  checked?: boolean;
  /** Initial state when uncontrolled. */
  defaultChecked?: boolean;
  /** Fires with the next state on every toggle. */
  onCheckedChange?: (checked: boolean) => void;
  /** Disables the draw animations; state still swaps. */
  static?: boolean;
}

/**
 * A checklist row where checking animates in layers: the check path draws
 * (normalized via pathLength, stroke-dashoffset transition), the label's
 * strikethrough draws left-to-right (background-size on a 1px gradient),
 * and the completed item settles to muted. Everything is a CSS transition,
 * so rapid toggling retargets from the current frame — unchecking reverses
 * cleanly instead of restarting. Reduced motion keeps the color settle and
 * drops the draws.
 */
export function AnimatedChecklistItem({
  checked: controlledChecked,
  defaultChecked = false,
  onCheckedChange,
  static: isStatic = false,
  className,
  children,
  onClick,
  ...props
}: AnimatedChecklistItemProps) {
  const [uncontrolledChecked, setUncontrolledChecked] =
    React.useState(defaultChecked);
  const isControlled = controlledChecked !== undefined;
  const checked = isControlled ? controlledChecked : uncontrolledChecked;

  return (
    <li className="flex">
      <button
        type="button"
        role="checkbox"
        aria-checked={checked}
        data-slot="animated-checklist-item"
        data-checked={checked}
        onClick={(event) => {
          onClick?.(event);
          if (event.defaultPrevented) return;
          const next = !checked;
          if (!isControlled) setUncontrolledChecked(next);
          onCheckedChange?.(next);
        }}
        className={cn(
          "group flex w-full items-start gap-2.5 rounded-lg px-2 py-2 text-left text-sm transition-colors duration-150 ease-out hover:bg-accent",
          className,
        )}
        {...props}
      >
        <span
          aria-hidden
          className={cn(
            "mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[5px] border",
            checked
              ? "border-primary bg-primary text-primary-foreground"
              : "border-input bg-transparent text-transparent",
            !isStatic &&
              "transition-[background-color,border-color] duration-(--duration-quick) ease-out",
          )}
        >
          <svg viewBox="0 0 12 12" fill="none" className="size-3">
            <path
              d="M2.5 6.4 4.9 8.7 9.5 3.6"
              pathLength={1}
              stroke="currentColor"
              strokeWidth={1.8}
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeDasharray={1}
              className={cn(
                !isStatic && [
                  "transition-[stroke-dashoffset] motion-reduce:transition-none",
                  checked
                    ? "delay-75 duration-200 ease-out"
                    : "delay-0 duration-100 ease-(--ease-exit)",
                ],
              )}
              style={{ strokeDashoffset: checked ? 0 : 1 }}
            />
          </svg>
        </span>
        <span
          className={cn(
            "min-w-0 flex-1 bg-no-repeat [background-image:linear-gradient(currentColor,currentColor)] [background-position:0_58%]",
            checked
              ? "text-muted-foreground [background-size:100%_1px]"
              : "text-foreground [background-size:0%_1px]",
            !isStatic && [
              "transition-[background-size,color] motion-reduce:transition-[color]",
              checked
                ? "duration-(--duration-base) ease-out"
                : "duration-(--duration-quick) ease-(--ease-exit)",
            ],
          )}
        >
          {children}
        </span>
      </button>
    </li>
  );
}