Todo List
A collapsible agent task plan with morphing status marks, a completion count, compact metadata, and smooth list updates.
Preview
TSXcomponents/previews/agents/todo-list.preview.tsx
"use client";
import { RotateCcw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
type TodoItem,
TodoList,
} from "@/components/agents/todo-list";
const TASKS = [
"Inspect the current data flow",
"Update the response schema",
"Add coverage for edge cases",
"Run checks and prepare the result",
];
const TICKS_PER_TASK = 4;
function itemsAtStep(step: number): TodoItem[] {
return TASKS.map((title, index) => ({
id: `task-${index}`,
title,
status:
step >= (index + 1) * TICKS_PER_TASK
? "completed"
: step >= index * TICKS_PER_TASK
? "in-progress"
: "pending",
progress:
step >= index * TICKS_PER_TASK &&
step < (index + 1) * TICKS_PER_TASK
? ((step % TICKS_PER_TASK) + 1) * 25
: undefined,
detail:
step >= index * TICKS_PER_TASK &&
step < (index + 1) * TICKS_PER_TASK
? `${((step % TICKS_PER_TASK) + 1) * 25}%`
: undefined,
}));
}
function TodoRun() {
const [step, setStep] = useState(0);
const timer = useRef<number | undefined>(undefined);
useEffect(() => {
if (step >= TASKS.length * TICKS_PER_TASK) return;
timer.current = window.setTimeout(() => setStep((value) => value + 1), 280);
return () => {
if (timer.current) window.clearTimeout(timer.current);
};
}, [step]);
return <TodoList items={itemsAtStep(step)} title="Implementation plan" />;
}
export function TodoListPreview() {
const [run, setRun] = useState(0);
return (
<div className="relative h-[330px] w-full max-w-lg">
<TodoRun key={run} />
<button
type="button"
onClick={() => setRun((value) => value + 1)}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
TSXcomponents/agents/todo-list.tsx
"use client";
// beui.dev/components/agents/todo-list
import { ChevronDown, ListTodo } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionSwapRollText } from "@/components/motion/action-swap-roll";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import {
EASE_OUT,
SPRING_LAYOUT,
SPRING_SWAP,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
export type TodoItemStatus =
| "pending"
| "in-progress"
| "completed"
| "cancelled";
export interface TodoItem {
id: string;
title: ReactNode;
status?: TodoItemStatus;
progress?: number;
detail?: ReactNode;
}
export interface TodoListProps {
items: TodoItem[];
title?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapseOnComplete?: boolean;
maxHeight?: number;
className?: string;
}
function statusLabel(status: TodoItemStatus) {
if (status === "in-progress") return "In progress";
if (status === "completed") return "Completed";
if (status === "cancelled") return "Cancelled";
return "Pending";
}
function TodoHeaderIcon({ complete }: { complete: boolean }) {
const reduce = useReducedMotion() ?? false;
return (
<span
aria-hidden="true"
className="relative grid size-6 shrink-0 place-items-center"
>
<AnimatePresence initial={false} mode="popLayout">
{complete ? (
<motion.svg
key="complete"
viewBox="0 0 24 24"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.72 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="absolute size-5.5 overflow-visible text-emerald-500"
>
<circle cx="12" cy="12" r="9" fill="currentColor" />
<motion.path
d="M7.5 12.25 10.5 15.25 16.75 8.75"
fill="none"
stroke="white"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }
}
/>
</motion.svg>
) : (
<motion.span
key="todo"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.72 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="absolute grid place-items-center text-muted-foreground"
>
<ListTodo className="size-4" />
</motion.span>
)}
</AnimatePresence>
</span>
);
}
function TodoStatusIcon({
status,
progress,
}: {
status: TodoItemStatus;
progress?: number;
}) {
const reduce = useReducedMotion() ?? false;
const normalizedProgress =
progress === undefined ? 0.68 : Math.min(100, Math.max(0, progress)) / 100;
return (
<motion.svg
aria-hidden="true"
viewBox="0 0 24 24"
initial={false}
className={cn(
"mx-0.5 size-5 shrink-0 overflow-visible text-muted-foreground",
status === "in-progress" && "text-foreground",
status === "cancelled" && "text-rose-600 dark:text-rose-400",
)}
>
<motion.circle
cx="12"
cy="12"
r="9"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray={status === "pending" ? "2 3" : undefined}
strokeLinecap="round"
initial={false}
animate={{ fillOpacity: status === "completed" ? 0.06 : 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.18, ease: EASE_OUT }}
className={cn(status === "in-progress" && "opacity-20")}
/>
<motion.circle
cx="12"
cy="12"
r="9"
pathLength="1"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
initial={false}
animate={{
pathLength: status === "in-progress" ? normalizedProgress : 0,
opacity: status === "in-progress" ? 1 : 0,
rotate:
status === "in-progress" && progress === undefined && !reduce
? 360
: -90,
}}
transition={
status === "in-progress" && progress === undefined && !reduce
? { rotate: { duration: 1.1, repeat: Infinity, ease: "linear" } }
: reduce
? { duration: 0 }
: SPRING_LAYOUT
}
style={{ transformOrigin: "12px 12px" }}
/>
<motion.path
d="M7.5 12.25 10.5 15.25 16.75 8.75"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{
pathLength: status === "completed" ? 1 : 0,
opacity: status === "completed" ? 1 : 0,
}}
transition={reduce ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }}
/>
<motion.path
d="M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
initial={false}
animate={{
pathLength: status === "cancelled" ? 1 : 0,
opacity: status === "cancelled" ? 1 : 0,
}}
transition={reduce ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }}
/>
</motion.svg>
);
}
export function TodoList({
items,
title = "To-dos",
open,
defaultOpen = true,
onOpenChange,
collapseOnComplete = true,
maxHeight = 248,
className,
}: TodoListProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
const viewportRef = useRef<HTMLDivElement>(null);
const previousComplete = useRef(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const completed = items.filter((item) => item.status === "completed").length;
const allComplete = items.length > 0 && completed === items.length;
const itemCount = items.length;
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
useEffect(() => {
if (previousComplete.current && !allComplete) {
setOpen(true);
}
if (!previousComplete.current && allComplete && collapseOnComplete) {
setOpen(false);
}
previousComplete.current = allComplete;
}, [allComplete, collapseOnComplete, setOpen]);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport || itemCount === 0) return;
const frame = requestAnimationFrame(() => {
if (viewport.scrollHeight <= viewport.clientHeight) return;
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduce ? "auto" : "smooth",
});
} else {
viewport.scrollTop = viewport.scrollHeight;
}
});
return () => cancelAnimationFrame(frame);
}, [itemCount, reduce]);
return (
<section
aria-label="Agent task list"
className={cn(
"w-full overflow-hidden rounded-2xl border border-border/70",
className,
)}
>
<button
id={triggerId}
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className="group flex h-11 w-full items-center gap-2.5 rounded-2xl px-3.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<TodoHeaderIcon complete={allComplete} />
<h3 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground/90">
{title}
</h3>
<span
className={cn(
"shrink-0 text-xs font-medium tabular-nums text-muted-foreground",
allComplete && "text-emerald-600 dark:text-emerald-400",
)}
>
<span className="sr-only">
{completed} of {items.length} tasks completed
</span>
<span aria-hidden="true" className="inline-flex">
<ActionSwapRollText value={String(completed)}>
{completed}
</ActionSwapRollText>
<span>/</span>
<span>{items.length}</span>
</span>
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="text-muted-foreground/50 transition-colors group-hover:text-muted-foreground"
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
<AgentDisclosure
id={contentId}
role="region"
aria-labelledby={triggerId}
open={currentOpen}
>
<div
ref={viewportRef}
className="scrollbar-hide overflow-y-auto px-2 pb-2"
style={{ maxHeight }}
>
{items.length ? (
<ol aria-live="polite" className="space-y-0">
<AnimatePresence initial={false} mode="popLayout">
{items.map((item) => {
const status = item.status ?? "pending";
return (
<motion.li
layout="position"
key={item.id}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.18, ease: EASE_OUT },
y: SPRING_LAYOUT,
layout: SPRING_LAYOUT,
}
}
className="flex min-h-9 items-center gap-2.5 rounded-xl px-1.5 py-1"
>
<TodoStatusIcon status={status} progress={item.progress} />
<span className="sr-only">{statusLabel(status)}: </span>
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
status === "pending" && "text-muted-foreground/65",
status === "in-progress" && "text-foreground",
status === "completed" && "text-muted-foreground/60",
status === "cancelled" && "text-muted-foreground/55",
)}
>
<span className="relative inline-block max-w-full">
{item.title}
<motion.span
aria-hidden="true"
initial={false}
animate={{
scaleX: status === "completed" ? 1 : 0,
opacity: status === "completed" ? 1 : 0,
}}
transition={
reduce
? { duration: 0 }
: { duration: 0.28, ease: EASE_OUT, delay: 0.06 }
}
className="absolute inset-x-0 top-1/2 h-px origin-left bg-current"
/>
</span>
</span>
{item.detail ? (
<span className="shrink-0 text-sm text-muted-foreground/55">
{item.detail}
</span>
) : null}
</motion.li>
);
})}
</AnimatePresence>
</ol>
) : (
<p className="px-1.5 py-2 text-sm text-muted-foreground">
No tasks yet
</p>
)}
</div>
</AgentDisclosure>
</section>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/todo-list
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/agents/todo-list.tsx
"use client";
// beui.dev/components/agents/todo-list
import { ChevronDown, ListTodo } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { ActionSwapRollText } from "@/components/motion/action-swap-roll";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import {
EASE_OUT,
SPRING_LAYOUT,
SPRING_SWAP,
} from "@/lib/ease";
import { cn } from "@/lib/utils";
export type TodoItemStatus =
| "pending"
| "in-progress"
| "completed"
| "cancelled";
export interface TodoItem {
id: string;
title: ReactNode;
status?: TodoItemStatus;
progress?: number;
detail?: ReactNode;
}
export interface TodoListProps {
items: TodoItem[];
title?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
collapseOnComplete?: boolean;
maxHeight?: number;
className?: string;
}
function statusLabel(status: TodoItemStatus) {
if (status === "in-progress") return "In progress";
if (status === "completed") return "Completed";
if (status === "cancelled") return "Cancelled";
return "Pending";
}
function TodoHeaderIcon({ complete }: { complete: boolean }) {
const reduce = useReducedMotion() ?? false;
return (
<span
aria-hidden="true"
className="relative grid size-6 shrink-0 place-items-center"
>
<AnimatePresence initial={false} mode="popLayout">
{complete ? (
<motion.svg
key="complete"
viewBox="0 0 24 24"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.72 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="absolute size-5.5 overflow-visible text-emerald-500"
>
<circle cx="12" cy="12" r="9" fill="currentColor" />
<motion.path
d="M7.5 12.25 10.5 15.25 16.75 8.75"
fill="none"
stroke="white"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }
}
/>
</motion.svg>
) : (
<motion.span
key="todo"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.72 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="absolute grid place-items-center text-muted-foreground"
>
<ListTodo className="size-4" />
</motion.span>
)}
</AnimatePresence>
</span>
);
}
function TodoStatusIcon({
status,
progress,
}: {
status: TodoItemStatus;
progress?: number;
}) {
const reduce = useReducedMotion() ?? false;
const normalizedProgress =
progress === undefined ? 0.68 : Math.min(100, Math.max(0, progress)) / 100;
return (
<motion.svg
aria-hidden="true"
viewBox="0 0 24 24"
initial={false}
className={cn(
"mx-0.5 size-5 shrink-0 overflow-visible text-muted-foreground",
status === "in-progress" && "text-foreground",
status === "cancelled" && "text-rose-600 dark:text-rose-400",
)}
>
<motion.circle
cx="12"
cy="12"
r="9"
fill="currentColor"
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray={status === "pending" ? "2 3" : undefined}
strokeLinecap="round"
initial={false}
animate={{ fillOpacity: status === "completed" ? 0.06 : 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.18, ease: EASE_OUT }}
className={cn(status === "in-progress" && "opacity-20")}
/>
<motion.circle
cx="12"
cy="12"
r="9"
pathLength="1"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
initial={false}
animate={{
pathLength: status === "in-progress" ? normalizedProgress : 0,
opacity: status === "in-progress" ? 1 : 0,
rotate:
status === "in-progress" && progress === undefined && !reduce
? 360
: -90,
}}
transition={
status === "in-progress" && progress === undefined && !reduce
? { rotate: { duration: 1.1, repeat: Infinity, ease: "linear" } }
: reduce
? { duration: 0 }
: SPRING_LAYOUT
}
style={{ transformOrigin: "12px 12px" }}
/>
<motion.path
d="M7.5 12.25 10.5 15.25 16.75 8.75"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{
pathLength: status === "completed" ? 1 : 0,
opacity: status === "completed" ? 1 : 0,
}}
transition={reduce ? { duration: 0 } : { duration: 0.24, ease: EASE_OUT }}
/>
<motion.path
d="M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
initial={false}
animate={{
pathLength: status === "cancelled" ? 1 : 0,
opacity: status === "cancelled" ? 1 : 0,
}}
transition={reduce ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT }}
/>
</motion.svg>
);
}
export function TodoList({
items,
title = "To-dos",
open,
defaultOpen = true,
onOpenChange,
collapseOnComplete = true,
maxHeight = 248,
className,
}: TodoListProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
const viewportRef = useRef<HTMLDivElement>(null);
const previousComplete = useRef(false);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const completed = items.filter((item) => item.status === "completed").length;
const allComplete = items.length > 0 && completed === items.length;
const itemCount = items.length;
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
useEffect(() => {
if (previousComplete.current && !allComplete) {
setOpen(true);
}
if (!previousComplete.current && allComplete && collapseOnComplete) {
setOpen(false);
}
previousComplete.current = allComplete;
}, [allComplete, collapseOnComplete, setOpen]);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport || itemCount === 0) return;
const frame = requestAnimationFrame(() => {
if (viewport.scrollHeight <= viewport.clientHeight) return;
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduce ? "auto" : "smooth",
});
} else {
viewport.scrollTop = viewport.scrollHeight;
}
});
return () => cancelAnimationFrame(frame);
}, [itemCount, reduce]);
return (
<section
aria-label="Agent task list"
className={cn(
"w-full overflow-hidden rounded-2xl border border-border/70",
className,
)}
>
<button
id={triggerId}
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className="group flex h-11 w-full items-center gap-2.5 rounded-2xl px-3.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<TodoHeaderIcon complete={allComplete} />
<h3 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground/90">
{title}
</h3>
<span
className={cn(
"shrink-0 text-xs font-medium tabular-nums text-muted-foreground",
allComplete && "text-emerald-600 dark:text-emerald-400",
)}
>
<span className="sr-only">
{completed} of {items.length} tasks completed
</span>
<span aria-hidden="true" className="inline-flex">
<ActionSwapRollText value={String(completed)}>
{completed}
</ActionSwapRollText>
<span>/</span>
<span>{items.length}</span>
</span>
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="text-muted-foreground/50 transition-colors group-hover:text-muted-foreground"
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
<AgentDisclosure
id={contentId}
role="region"
aria-labelledby={triggerId}
open={currentOpen}
>
<div
ref={viewportRef}
className="scrollbar-hide overflow-y-auto px-2 pb-2"
style={{ maxHeight }}
>
{items.length ? (
<ol aria-live="polite" className="space-y-0">
<AnimatePresence initial={false} mode="popLayout">
{items.map((item) => {
const status = item.status ?? "pending";
return (
<motion.li
layout="position"
key={item.id}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.18, ease: EASE_OUT },
y: SPRING_LAYOUT,
layout: SPRING_LAYOUT,
}
}
className="flex min-h-9 items-center gap-2.5 rounded-xl px-1.5 py-1"
>
<TodoStatusIcon status={status} progress={item.progress} />
<span className="sr-only">{statusLabel(status)}: </span>
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
status === "pending" && "text-muted-foreground/65",
status === "in-progress" && "text-foreground",
status === "completed" && "text-muted-foreground/60",
status === "cancelled" && "text-muted-foreground/55",
)}
>
<span className="relative inline-block max-w-full">
{item.title}
<motion.span
aria-hidden="true"
initial={false}
animate={{
scaleX: status === "completed" ? 1 : 0,
opacity: status === "completed" ? 1 : 0,
}}
transition={
reduce
? { duration: 0 }
: { duration: 0.28, ease: EASE_OUT, delay: 0.06 }
}
className="absolute inset-x-0 top-1/2 h-px origin-left bg-current"
/>
</span>
</span>
{item.detail ? (
<span className="shrink-0 text-sm text-muted-foreground/55">
{item.detail}
</span>
) : null}
</motion.li>
);
})}
</AnimatePresence>
</ol>
) : (
<p className="px-1.5 py-2 text-sm text-muted-foreground">
No tasks yet
</p>
)}
</div>
</AgentDisclosure>
</section>
);
}
TSXcomponents/agents/agent-disclosure.tsx
"use client";
import { motion, type HTMLMotionProps, useReducedMotion } from "motion/react";
import type { CSSProperties } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AgentDisclosureProps
extends Omit<HTMLMotionProps<"div">, "animate" | "initial"> {
open: boolean;
openHeight?: CSSProperties["height"];
}
/** Shared transform-only reveal for collapsible agent content. */
export function AgentDisclosure({
open,
openHeight = "auto",
className,
style,
transition,
...props
}: AgentDisclosureProps) {
const reduce = useReducedMotion() ?? false;
return (
<motion.div
{...props}
aria-hidden={!open}
inert={!open}
initial={false}
animate={
reduce
? { opacity: open ? 1 : 0 }
: {
opacity: open ? 1 : 0,
clipPath: open ? "inset(0 0 0% 0)" : "inset(0 0 100% 0)",
y: open ? 0 : -4,
}
}
transition={
transition ?? {
duration: reduce ? 0 : open ? 0.22 : 0.14,
ease: EASE_OUT,
}
}
className={cn("overflow-hidden", className)}
style={{
...style,
height: open ? openHeight : 0,
pointerEvents: open ? undefined : "none",
transformOrigin: "top",
}}
/>
);
}
TSXcomponents/motion/action-swap-roll.tsx
"use client";
import {
ActionSwapButton,
ActionSwapIcon,
ActionSwapText,
type ActionSwapButtonProps,
type ActionSwapIconProps,
type ActionSwapTextProps,
} from "./action-swap";
export type {
ActionSwapButtonSize,
ActionSwapButtonVariant,
ActionSwapItem,
} from "./action-swap";
export type ActionSwapRollButtonProps = Omit<ActionSwapButtonProps, "animation">;
export type ActionSwapRollTextProps = Omit<ActionSwapTextProps, "animation">;
export type ActionSwapRollIconProps = Omit<ActionSwapIconProps, "animation">;
export function ActionSwapRollButton(props: ActionSwapRollButtonProps) {
return <ActionSwapButton {...props} animation="roll" />;
}
export function ActionSwapRollText(props: ActionSwapRollTextProps) {
return <ActionSwapText {...props} animation="roll" />;
}
export function ActionSwapRollIcon(props: ActionSwapRollIconProps) {
return <ActionSwapIcon {...props} animation="roll" />;
}
TSXcomponents/motion/action-swap.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ActionSwapItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
ariaLabel?: string;
};
export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon";
export type ActionSwapAnimation = "blur" | "roll" | "cascade";
/** Animations with a single-element variant set (cascade animates per letter). */
type CoreAnimation = "blur" | "roll";
export interface ActionSwapButtonProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onChange"
> {
items: ActionSwapItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string, item: ActionSwapItem) => void;
variant?: ActionSwapButtonVariant;
size?: ActionSwapButtonSize;
animation?: ActionSwapAnimation;
iconOnly?: boolean;
cycle?: boolean;
}
export interface ActionSwapTextProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
export interface ActionSwapIconProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
const ROLL_TRANSITION = SPRING_SWAP;
const ROLL_EXIT_TRANSITION = { duration: 0.14, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(3px)";
// Cascade rolls the label one letter at a time, left to right. The leaving
// and landing strings overlap as independent layers (no shared cells), so
// proportional glyph widths never jitter. Exits cascade at half the enter
// stagger so the tail of the old label lingers briefly.
const CASCADE_STAGGER = 0.025;
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const TEXT_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.94,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: "90%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-90%",
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const ICON_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.25,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: 12, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -12,
filter: ROLL_BLUR,
transition: ROLL_EXIT_TRANSITION,
},
},
};
const VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
outline: "border border-border bg-transparent text-foreground hover:bg-primary/5",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<ActionSwapButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
lg: "h-12 gap-2.5 rounded-full px-5 text-base",
icon: "h-10 w-10 rounded-full",
};
export function ActionSwapText({
value,
children,
animation = "blur",
className,
}: ActionSwapTextProps) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));
});
// Cascade needs a plain string to split into letters; non-string content
// and reduced motion fall back to the closest single-element animation.
const label = typeof children === "string" ? children : null;
const cascade = animation === "cascade" && label !== null && !reduce;
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span
className={cn("relative inline-block overflow-hidden whitespace-nowrap align-bottom", className)}
style={{
width,
transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,
}}
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
{/* Letters are decorative fragments; readers get the whole label. */}
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, i) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.
key={i}
custom={i * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`${animation}-${value}`}
variants={TEXT_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
export function ActionSwapIcon({
value,
children,
animation = "blur",
className,
}: ActionSwapIconProps) {
const reduce = useReducedMotion();
// Icons are single elements — cascade maps to its closest motion, roll.
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span className={cn("relative inline-grid shrink-0 place-items-center overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${animation}-${value}`}
aria-hidden
variants={ICON_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}
export function ActionSwapButton({
items,
value,
defaultValue,
onValueChange,
variant = "secondary",
size = "md",
animation = "blur",
iconOnly = size === "icon",
cycle = true,
className,
disabled,
onClick,
...rest
}: ActionSwapButtonProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);
const currentValue = value ?? internalValue;
const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));
const activeItem = items[activeIndex] ?? items[0];
const hasIcon = items.some((item) => item.icon);
const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;
if (!activeItem) return null;
const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined);
return (
<motion.button
type="button"
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center overflow-hidden font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
aria-label={accessibleLabel}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || disabled || !cycle || !nextItem) return;
if (value === undefined) setInternalValue(nextItem.id);
onValueChange?.(nextItem.id, nextItem);
}}
{...rest}
>
{hasIcon ? (
<ActionSwapIcon value={activeItem.id} animation={animation} className="h-4 w-4">
{activeItem.icon ?? null}
</ActionSwapIcon>
) : null}
{!iconOnly ? (
<ActionSwapText value={activeItem.id} animation={animation}>
{activeItem.label}
</ActionSwapText>
) : null}
</motion.button>
);
}
API Reference
itemsTodoItem[]—title?ReactNodeTo-dosopen?boolean—defaultOpen?booleantrueonOpenChange?((open: boolean) => void)—collapseOnComplete?booleantruemaxHeight?number248className?string—Composition
Render the plan as message content when it belongs to a conversational agent run.
Message
└── MessageContent
└── TodoListNote: Agent Activity shows the chronological events occurring between tasks. Agent Loading States covers work before a durable plan is available. Tool Result presents the evidence produced by an active task.
How it works
An agent plan should explain what remains without pretending every internal thought is a task. Use a todo list for durable work items whose state matters to the person following the run.
Updated