Todo List
Shows the plan an agent is working through: each task carries a pending, in-progress, completed or cancelled mark that morphs between states, optional progress and detail, a done count in the header, and a panel that can fold away when all is finished.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/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>
);
}
"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",
}}
/>
);
}
"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" />;
}
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useState } from "react";
import type { ReactNode } from "react";
import { EASE_OUT, 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();
// 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 -my-[0.08em] inline-block max-w-full whitespace-nowrap py-[0.08em] align-bottom",
className,
)}
style={{
clipPath: "inset(0 -999px)",
WebkitClipPath: "inset(0 -999px)",
}}
>
<span
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: 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.08em] 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"}
// Truncation lives on the layer that holds the text — the layer
// moves as a whole, so clipping it never eats the roll.
className="absolute left-0 top-[0.08em] inline-block max-w-full truncate 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>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
itemsTodoItem[]—title?ReactNodeTo-dosopen?boolean—defaultOpen?booleantrueonOpenChange?((open: boolean) => void)—collapseOnComplete?booleantruemaxHeight?number248className?string—Composition
When the plan comes from a chat-based run, render it inside that turn's message content.
Message
└── MessageContent
└── TodoListNote: Agent Activity logs what happens between one task and the next. Agent Loading States fills the gap before any plan exists. Tool Result shows the output a task produced.
How it works
Use a plan to show the person what is left to do, not to narrate every passing thought. Only work items whose status someone watching the run actually cares about belong here.
Updated