Tool Approval
A card for an agent action waiting on approval, with Approve and Deny that collapse into a single status pill once resolved.
Run a shell command
rm -rf dist/ && npm run build
"use client";
import { useState } from "react";
import { ToolApproval, type ToolApprovalStatus } from "@/components/motion/tool-approval";
export function ToolApprovalPreview() {
const [status, setStatus] = useState<ToolApprovalStatus>("pending");
return (
<div className="w-full max-w-sm">
<ToolApproval
title="Run a shell command"
description="rm -rf dist/ && npm run build"
status={status}
onApprove={() => setStatus("approved")}
onDeny={() => setStatus("denied")}
/>
</div>
);
}
"use client";
// easeui.dev/components/agents/tool-approval
import { Check, Terminal, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ToolApprovalStatus = "pending" | "approved" | "denied";
export interface ToolApprovalProps {
/** What the agent wants to do. */
title: ReactNode;
/** The exact command, file path, or other detail worth reading before approving. */
description?: ReactNode;
/** Default "pending". Acting on the request is up to the caller: set this once onApprove or onDeny fires. */
status?: ToolApprovalStatus;
onApprove?: () => void;
onDeny?: () => void;
approveLabel?: string;
denyLabel?: string;
className?: string;
}
const RESOLVED_ICON: Record<"approved" | "denied", ReactNode> = {
approved: <Check aria-hidden="true" className="h-3 w-3" strokeWidth={3} />,
denied: <X aria-hidden="true" className="h-3 w-3" strokeWidth={3} />,
};
const RESOLVED_LABEL: Record<"approved" | "denied", string> = {
approved: "Approved",
denied: "Denied",
};
const RESOLVED_CLASS: Record<"approved" | "denied", string> = {
approved: "bg-success/15 text-success",
denied: "bg-destructive/15 text-destructive",
};
/**
* A card for an agent action waiting on approval, with Approve and Deny.
* Resolving it collapses the buttons into a single status pill.
*/
export function ToolApproval({
title,
description,
status = "pending",
onApprove,
onDeny,
approveLabel = "Approve",
denyLabel = "Deny",
className,
}: ToolApprovalProps) {
const reduce = useReducedMotion();
const pending = status === "pending";
return (
<motion.div
layout={!reduce}
transition={{ duration: 0.2, ease: EASE_OUT }}
className={cn("flex flex-col gap-3 rounded-2xl bg-card p-4 shadow-[0_0_0_1px_var(--border)]", className)}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Terminal aria-hidden="true" className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{title}</p>
{description ? (
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{description}</p>
) : null}
</div>
</div>
<AnimatePresence mode="popLayout" initial={false}>
{pending ? (
<motion.div
key="actions"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="flex justify-end gap-2"
>
<button
type="button"
onClick={onDeny}
className="inline-flex h-8 touch-manipulation items-center rounded-full px-3.5 text-sm font-medium text-muted-foreground outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/40"
>
{denyLabel}
</button>
<button
type="button"
onClick={onApprove}
className="inline-flex h-8 touch-manipulation items-center rounded-full bg-foreground px-3.5 text-sm font-medium text-background outline-none transition-colors duration-150 hover:bg-foreground/90 focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{approveLabel}
</button>
</motion.div>
) : (
<motion.div
key="resolved"
initial={reduce ? false : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
className={cn(
"inline-flex w-fit items-center gap-1.5 self-end rounded-full px-2.5 py-1 text-xs font-medium",
RESOLVED_CLASS[status],
)}
>
{RESOLVED_ICON[status]}
{RESOLVED_LABEL[status]}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
Installation
$ bunx --bun shadcn add @easeui/tool-approval
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/tool-approval.tsx "use client"; // easeui.dev/components/agents/tool-approval import { Check, Terminal, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type ToolApprovalStatus = "pending" | "approved" | "denied"; export interface ToolApprovalProps { /** What the agent wants to do. */ title: ReactNode; /** The exact command, file path, or other detail worth reading before approving. */ description?: ReactNode; /** Default "pending". Acting on the request is up to the caller: set this once onApprove or onDeny fires. */ status?: ToolApprovalStatus; onApprove?: () => void; onDeny?: () => void; approveLabel?: string; denyLabel?: string; className?: string; } const RESOLVED_ICON: Record<"approved" | "denied", ReactNode> = { approved: <Check aria-hidden="true" className="h-3 w-3" strokeWidth={3} />, denied: <X aria-hidden="true" className="h-3 w-3" strokeWidth={3} />, }; const RESOLVED_LABEL: Record<"approved" | "denied", string> = { approved: "Approved", denied: "Denied", }; const RESOLVED_CLASS: Record<"approved" | "denied", string> = { approved: "bg-success/15 text-success", denied: "bg-destructive/15 text-destructive", }; /** * A card for an agent action waiting on approval, with Approve and Deny. * Resolving it collapses the buttons into a single status pill. */ export function ToolApproval({ title, description, status = "pending", onApprove, onDeny, approveLabel = "Approve", denyLabel = "Deny", className, }: ToolApprovalProps) { const reduce = useReducedMotion(); const pending = status === "pending"; return ( <motion.div layout={!reduce} transition={{ duration: 0.2, ease: EASE_OUT }} className={cn("flex flex-col gap-3 rounded-2xl bg-card p-4 shadow-[0_0_0_1px_var(--border)]", className)} > <div className="flex items-start gap-3"> <span className="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"> <Terminal aria-hidden="true" className="h-3.5 w-3.5" /> </span> <div className="min-w-0 flex-1"> <p className="text-sm font-medium text-foreground">{title}</p> {description ? ( <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{description}</p> ) : null} </div> </div> <AnimatePresence mode="popLayout" initial={false}> {pending ? ( <motion.div key="actions" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={reduce ? undefined : { opacity: 0 }} transition={{ duration: 0.15, ease: EASE_OUT }} className="flex justify-end gap-2" > <button type="button" onClick={onDeny} className="inline-flex h-8 touch-manipulation items-center rounded-full px-3.5 text-sm font-medium text-muted-foreground outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/40" > {denyLabel} </button> <button type="button" onClick={onApprove} className="inline-flex h-8 touch-manipulation items-center rounded-full bg-foreground px-3.5 text-sm font-medium text-background outline-none transition-colors duration-150 hover:bg-foreground/90 focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background" > {approveLabel} </button> </motion.div> ) : ( <motion.div key="resolved" initial={reduce ? false : { opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: EASE_OUT }} className={cn( "inline-flex w-fit items-center gap-1.5 self-end rounded-full px-2.5 py-1 text-xs font-medium", RESOLVED_CLASS[status], )} > {RESOLVED_ICON[status]} {RESOLVED_LABEL[status]} </motion.div> )} </AnimatePresence> </motion.div> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ 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.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | - | What the agent wants to do. |
| description? | any | - | The exact command, file path, or other detail worth reading before approving. |
| status? | "pending" | "approved" | "denied" | pending | Default "pending". Acting on the request is up to the caller: set this once onApprove or onDeny fires. |
| onApprove? | (() => void) | - | - |
| onDeny? | (() => void) | - | - |
| approveLabel? | string | Approve | - |
| denyLabel? | string | Deny | - |
| className? | string | - | - |
Updated