Tool Call UI
Generic tool call row with inputs, outputs, status and expand/collapse.
toolcallfunction
Input
{
"query": "best AI chat UI patterns"
}Props
Also takes onCancel.
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
tool-call.tsx
"use client";
import * as React from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type ToolStatus = "running" | "success" | "error";
export type ToolCallProps = {
name: string;
input?: string;
output?: string;
status?: ToolStatus;
duration?: string;
icon?: React.ReactNode;
onCancel?: () => void;
defaultOpen?: boolean;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
function TerminalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<path d="m4 17 6-6-6-6M12 19h8" />
</svg>
);
}
function ChevronIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function CheckIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
function XIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
function StopIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" width="10" height="10" {...props}>
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* Status pill */
/* ------------------------------------------------------------------ */
function StatusPill({ status, onCancel }: { status: ToolStatus; onCancel?: () => void }) {
if (status === "running") {
return (
<button
type="button"
onClick={onCancel}
disabled={!onCancel}
title="Cancel"
/* min-h-6: py-0.5 on 11px text left a 20.5px-tall tap target, under the
24x24 minimum. The pill's look is unchanged at this size. */
className="inline-flex min-h-6 items-center gap-1.5 rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] text-zinc-600 transition-colors hover:bg-zinc-200 disabled:hover:bg-zinc-100 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:disabled:hover:bg-zinc-800"
>
<span className="h-2.5 w-2.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
Running
{onCancel && (
<span className="text-zinc-400">
<StopIcon />
</span>
)}
</button>
);
}
if (status === "success") {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400">
<CheckIcon />
Completed
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-[11px] text-red-700 dark:bg-red-900/40 dark:text-red-400">
<XIcon />
Failed
</span>
);
}
/* ------------------------------------------------------------------ */
/* ToolCall */
/* ------------------------------------------------------------------ */
export function ToolCall({
name,
input,
output,
status = "running",
duration,
icon,
onCancel,
defaultOpen = status !== "running",
className = "",
}: ToolCallProps) {
const [open, setOpen] = React.useState(defaultOpen);
const hasDetails = (input?.length ?? 0) > 0 || (output?.length ?? 0) > 0;
return (
<div
className={`overflow-hidden rounded-xl border border-zinc-200 bg-white transition-colors dark:border-zinc-800 dark:bg-zinc-900 ${
status === "error"
? "border-red-200 dark:border-red-900/60"
: status === "running"
? "border-zinc-300 dark:border-zinc-700"
: ""
} ${className}`}
>
{/* Header.
The disclosure button covers the icon, name and duration only. It
used to wrap the whole row, which put StatusPill's Cancel button
inside it — nested buttons are invalid HTML, and React refuses to
hydrate them. The pill and the chevron are siblings of the button
now, so both controls stay independently clickable. */}
<div className="flex w-full items-center gap-2.5 px-3.5 py-2.5">
<button
type="button"
onClick={() => hasDetails && setOpen((v) => !v)}
aria-expanded={open}
disabled={!hasDetails}
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
>
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
status === "error"
? "bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400"
: status === "running"
? "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
}`}
>
{icon ?? <TerminalIcon />}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
{name}
</span>
{duration && <span className="shrink-0 text-xs tabular-nums text-zinc-500 dark:text-zinc-400">{duration}</span>}
</button>
<span className="shrink-0">
<StatusPill status={status} onCancel={onCancel} />
</span>
{hasDetails && (
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={open ? "Hide details" : "Show details"}
className={`shrink-0 text-zinc-400 transition-transform ${open ? "rotate-180" : ""}`}
>
<ChevronIcon />
</button>
)}
</div>
{/* Details */}
{open && hasDetails && (
<div className="space-y-2 border-t border-zinc-100 px-3.5 py-3 dark:border-zinc-800">
{input && (
<div>
<span className="text-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Input
</span>
<pre className="mt-1 overflow-x-auto rounded-lg bg-zinc-50 p-2.5 font-mono text-xs leading-5 text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300">
{input}
</pre>
</div>
)}
{output && (
<div>
<span className="text-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Output
</span>
<pre className="mt-1 overflow-x-auto rounded-lg bg-zinc-50 p-2.5 font-mono text-xs leading-5 text-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300">
{output}
</pre>
</div>
)}
</div>
)}
</div>
);
}
Usage Guidelines
- Show a tool call immediately when the tool starts — before the result arrives. Silence reads as a hang.
- Keep the payload collapsed by default for completed calls; expand for inspection. Never hide failures.
- Always show elapsed time on long-running tools; users estimate progress from it.
- Prefer truncated tool names with a full label on hover — tool names can get very long.
- Color only the status, not the whole row — too many red/green rows become noise.
Common UX Mistakes
- Hiding tool calls from the user entirely — agents that 'magically' get data destroy trust.
- Rendering raw tool output in huge pre blocks. Collapse by default and cap the height.
- Forgetting cancel for long-running tools, forcing the user to kill the whole session.
- Swapping status colors mid-animation — users attach meaning to position before color.
Related Components
Search Tool Call
Web search tool call with query, result count and source previews.
Code Execution
Code execution tool call — the snippet, run state, stdout and error output.
Prompt Input
The core input for AI chat products — with attachments, model selector, tools, voice and loading states.
Prompt Input + Attachments
Prompt input with file attachment chips, upload progress and removal.