AI & Chat

Tool Call Card

A collapsed ran-a-tool chip that expands to input/output panes, with blur-swapping status icons and a hairline shimmer while running.

Install

npx shadcn@latest add @paragon/tool-call-card

tool-call-card.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  Check,
  ChevronDown,
  CircleAlert,
  CircleDashed,
  LoaderCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";

export type ToolCallStatus = "pending" | "running" | "done" | "error";

const STATUS_LABEL: Record<ToolCallStatus, string> = {
  pending: "Queued",
  running: "Running",
  done: "Done",
  error: "Failed",
};

export interface ToolCallCardProps extends React.ComponentProps<"div"> {
  /** Tool name, rendered in mono. */
  name: string;
  status?: ToolCallStatus;
  /** Serialized tool input (JSON, query, etc.). */
  input?: string;
  /** Serialized tool output. Shown as awaiting until provided. */
  output?: string;
  defaultExpanded?: boolean;
  /** Controlled expansion. */
  expanded?: boolean;
  onExpandedChange?: (expanded: boolean) => void;
  /** Disables motion (icon swaps snap, shimmer hidden). */
  static?: boolean;
}

/**
 * A collapsed "ran a tool" chip that expands via grid-template-rows to show
 * input/output panes. The status icon blur-swaps through
 * pending → running → done, and while running a hairline shimmer sweeps the
 * bottom edge of the header — transform-only, paused offscreen, removed
 * under prefers-reduced-motion.
 */
export function ToolCallCard({
  name,
  status = "pending",
  input,
  output,
  defaultExpanded = false,
  expanded,
  onExpandedChange,
  static: isStatic = false,
  className,
  ...props
}: ToolCallCardProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const panelId = `tool-call-panel-${id}`;
  const reducedMotion = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const [offscreen, setOffscreen] = React.useState(false);
  const [internalOpen, setInternalOpen] = React.useState(defaultExpanded);
  const open = expanded ?? internalOpen;

  React.useEffect(() => {
    const node = rootRef.current;
    if (!node || typeof IntersectionObserver === "undefined") return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry) setOffscreen(!entry.isIntersecting);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  const toggle = () => {
    const next = !open;
    setInternalOpen(next);
    onExpandedChange?.(next);
  };

  const blur = isStatic || reducedMotion ? "blur(0px)" : "blur(4px)";

  const icon =
    status === "pending" ? (
      <CircleDashed className="size-3.5 text-muted-foreground" />
    ) : status === "running" ? (
      <LoaderCircle className="size-3.5 animate-spin text-muted-foreground motion-reduce:animate-none" />
    ) : status === "done" ? (
      <Check className="size-3.5 text-emerald-600 dark:text-emerald-500" />
    ) : (
      <CircleAlert className="size-3.5 text-destructive" />
    );

  return (
    <div
      ref={rootRef}
      data-slot="tool-call-card"
      className={cn(
        "w-full overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <button
        type="button"
        aria-expanded={open}
        aria-controls={panelId}
        onClick={toggle}
        className="relative flex h-10 w-full items-center gap-2 px-3 text-left outline-none transition-[background-color] duration-150 ease-out hover:bg-accent/50 focus-visible:bg-accent/50"
      >
        <span className="flex size-4 shrink-0 items-center justify-center">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={status}
              className="flex"
              initial={{ opacity: 0, scale: 0.25, filter: blur }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, scale: 0.25, filter: blur }}
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            >
              {icon}
            </motion.span>
          </AnimatePresence>
        </span>
        <span className="truncate font-mono text-[13px]">{name}</span>
        <span
          aria-live="polite"
          className="ml-auto shrink-0 text-xs text-muted-foreground"
        >
          {STATUS_LABEL[status]}
        </span>
        <ChevronDown
          aria-hidden
          className={cn(
            "size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ease-out",
            open && "rotate-180",
          )}
        />
        {status === "running" && !isStatic && (
          <span
            aria-hidden
            className="pointer-events-none absolute inset-x-3 bottom-0 h-px overflow-hidden"
          >
            <style href="paragon-tool-call-card" precedence="paragon">{`
              @keyframes pg-tool-sweep {
                from { transform: translateX(-100%); }
                to { transform: translateX(400%); }
              }
              @media (prefers-reduced-motion: reduce) {
                [data-tool-sweep] { animation: none !important; opacity: 0; }
              }
            `}</style>
            <span
              data-tool-sweep=""
              className="absolute inset-y-0 w-1/3 bg-gradient-to-r from-transparent via-foreground/40 to-transparent"
              style={{
                animation: `pg-tool-sweep 1.4s linear infinite`,
                animationPlayState: offscreen ? "paused" : "running",
              }}
            />
          </span>
        )}
      </button>
      <div
        className="grid transition-[grid-template-rows] duration-250 ease-out"
        style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
      >
        <div id={panelId} className="min-h-0 overflow-hidden">
          <div className="space-y-2.5 border-t px-3 py-2.5">
            <div>
              <p className="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
                Input
              </p>
              <pre className="mt-1 overflow-x-auto rounded-md bg-muted/60 px-2.5 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap">
                {input ?? "—"}
              </pre>
            </div>
            <div>
              <p className="text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
                Output
              </p>
              {output ? (
                <pre className="mt-1 overflow-x-auto rounded-md bg-muted/60 px-2.5 py-2 font-mono text-xs leading-relaxed whitespace-pre-wrap">
                  {output}
                </pre>
              ) : (
                <p className="mt-1 rounded-md bg-muted/60 px-2.5 py-2 text-xs text-muted-foreground italic">
                  {status === "error"
                    ? "The tool call failed."
                    : "Awaiting result…"}
                </p>
              )}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}