Task list where checking draws the check path and a left-to-right strikethrough, settling completed items to muted — fully reversible mid-animation.
npx shadcn@latest add @paragon/animated-checklist"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>
);
}