About this animation
A tool invocation card with animated approval and execution states, with configurable call details, labels, palette, and fonts.
How it works
The decision stays attached to an immutable scope. Approving twice, declining during submission, or resetting the presentation cannot emit another decision. Success never comes from an animation timer. Denial is a normal outcome, and recovery is an explicit host request.
- The host provides one
calland all interface strings. The component never executes a tool, makes network requests, or uses an AI SDK. - An instance-local ledger records each ID, approval ID, revision, and argument snapshot synchronously before calling
onDecision. It survivesreset()and switching back to a previously seen scope while the component remains mounted. - The local submitting state blocks both decisions until the host updates the call. Host state always wins. New scope resets transient presentation without reusing an old approval.
- Status content exits in 90 ms, pauses for 20 ms, then enters in 130 ms. Terminal content waits another 140 ms. Only one status subtree exists. Measured body height follows a 220 ms curve.
- Only host
succeededdraws the check and lights the border. One UI-thread progress drives the rail, seal, and light; physical press feedback uses a separate interruptible spring with explicit mass. - Long argument sets collapse behind an accessible disclosure. A controlled host may open a separate details surface, as the demo does, keeping focus on the disclosure trigger when it closes.
Install
Copy this folder without demo/ into your project. No theme or catalog imports are required.
npx expo install react-native-reanimated @shopify/react-native-skia expo-blur expo-linear-gradient react-native-workletsUse the versions in piece.json for Expo 58. Configure Reanimated/worklets according to your Expo project. Skia web requires CanvasKit initialization before importing/rendering the component; the catalog host handles that through its existing lazy Skia loader. Icons: Lucide (ISC), copied from the local kit.
Usage
import {
ToolApprovalCard,
type ToolCall,
type ToolApprovalLabels,
} from './tool-approval-card';
const labels: ToolApprovalLabels = {
requested: 'Waiting for approval',
submitting: 'Submitting decision',
running: 'Creating event',
succeeded: 'Event created',
failed: 'Couldn’t create event',
denied: 'Action declined',
approve: 'Approve',
decline: 'Decline',
retry: 'Try again',
showDetails: 'View details',
hideDetails: 'Hide details',
};
const call: ToolCall = {
id: 'meeting-1',
approvalId: 'approval-1',
revision: 1,
title: 'Create a meeting',
description: 'Design review · Today, 2:00 PM',
parameters: [
{ label: 'Calendar:', value: 'Team' },
{ label: 'Guests:', value: '3' },
],
state: 'requested',
};
<ToolApprovalCard
call={call}
labels={labels}
approvalPrompt="Add this event to your calendar?"
accessibilityLabel="Calendar action approval"
onDecision={(decision) => sendDecisionToHost(decision)}
onRetry={(callId) => requestRecoveryFromHost(callId)}
/>;sendDecisionToHost and requestRecoveryFromHost represent your adapter. An adjustable presentation can use colorScheme="light", motionScale={0.5}, custom fontFamily, titleFontFamily, monoFontFamily, a complete palette, and style={{ flex: 1, maxWidth: 480 }}.
States and transitions
| State | Allowed request | Host transition / visible behavior |
|---|---|---|
requested | Approve or decline once | Local submitting, same arguments retained. |
| Submitting (local) | None | Host requested with new scope, running, denied, failed, or succeeded. |
running | None | Host succeeds/fails; static labeled indicator. |
succeeded | None | Check draw and border confirmation, result copy if supplied. |
failed | Retry once per failed episode | Host decides new approval or execution; arguments/error retained. |
denied | None | Neutral icon/text; no automatic execution CTA. |
The adapter must compare call ID, approval ID, and revision against its current immutable snapshot before applying any asynchronous result. Ignore stale results, including those from another call ID. Advance revision whenever arguments change and never reuse a revision for a different scope. The component treats changed arguments as a fresh UI scope but cannot determine whether an arbitrary incoming call is stale. Do not throw from callbacks: handle adapter errors by supplying failed state. Repeated errors after running can request recovery again. Unmount cancels UI animation/timers and never decides or retries.
Demo and content
Nimbus is a fictional kit brand recolored for this palette. All calendar content and host execution are simulated; no real calendar connection exists. Autoplay requests approval at 650 ms, host running at 800 ms, succeeds at 1500 ms, resets at 2900 ms, declines at 3350 ms, resets at 4200 ms, and repeats every 5000 ms. Real touch stops autoplay; already requested host operations remain independent and are filtered by IDs. Replay creates a fresh call. Show failure exposes an error; Try again returns to a new approval, never silent execution.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
call | ToolCall | Required | Stable IDs, finite revision, title, description, parameters, remote state and optional result/error strings. |
onDecision | (ToolDecision) => void | Required | { callId, approvalId, revision, approved }; no tool execution. |
onRetry | (callId: string) => void | None | Only accepted from failed; duplicates blocked until host leaves failed. |
onDetailsChange | (expanded: boolean) => void | None | Disclosure request; host can present full parameters externally. |
labels | ToolApprovalLabels | Required | All status, action, and announcement copy. See table below. |
approvalPrompt | string | None | Optional question visible only in requested state. |
detailsExpanded | boolean | Uncontrolled, false | Controlled value takes precedence over ref methods. |
colorScheme | light / dark / auto | auto | System-aware complete palette. |
palette | ToolApprovalPalette | Night/Paper | All nine colors: background, surface, text, muted, border, accent, onAccent, success, error. |
fontFamily | string | System | Body and controls. |
titleFontFamily | string | Body font | Tool title. |
monoFontFamily | string | Body font | Parameter values. |
style | StyleProp<ViewStyle> | None | Root fills host slot; no safe area insets. |
testID | string | tool-approval-card | Suffixes: -approve, -decline, -retry, -details, -parameters, -status, -error. |
accessibilityLabel | string | Required | Invocation name; fallback for an empty title. |
motionScale | number | 1 | Finite amplitude, clamped 0–1.5; zero removes movement. |
ToolCall includes id, approvalId, revision, title, description, parameters: {label,value}[], state, and optional resultTitle, resultDescription, errorDescription. Optional empty strings do not reserve space. Empty parameters remove the argument block. Empty IDs/nonfinite revision disable business decisions. Parameter strings are rendered as text, never evaluated. Structured output belongs in supplied result copy; private reasoning is never simulated.
| Label | Demo value / use |
|---|---|
requested | Waiting for approval — requested status |
submitting | Submitting decision — awaiting host acknowledgment |
running | Creating event — host execution |
succeeded | Event created — confirmed success |
failed | Couldn’t create event — confirmed failure |
denied | Action declined — normal rejection |
approve | Approve — positive decision |
decline | Decline — negative decision |
retry | Try again — request recovery |
showDetails | View details — expand/request full scope |
hideDetails | Hide details — collapse |
Compatibility
Targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1/worklets 0.13.0, Skia 2.14.0, and Expo Go-compatible native dependencies. Supports iOS, Android, and web; initialize CanvasKit before mounting on web.
Accessibility
All controls have button roles, labels, 48 pt minimum targets, and stable identifiers. Pending choices are removed from touch and accessibility immediately. Status announcements occur once per transition; web uses a polite live region and native uses the accessibility announcement API. Disclosure does not force focus into the conversation. Hosts providing an external details surface own focus trapping/restoration and may retain a trigger ref.
The root measures its host with onLayout, fills that slot, caps the surface at 480 pt, and never adds safe areas. Text scaling remains enabled. Long values wrap, and large text stacks decisions. In constrained slots, use controlled disclosure to present complete arguments in an accessible host-owned details surface; provide more vertical room when expanded. The demo uses such a surface, omits decorative context in short/large-text slots, and consolidates demo controls behind one lower button in the smallest slots. It has no ScrollView. No rolling numbers or decorative counters are present.
Reduced motion removes rail travel, border light, translation, and press springs, using sequential 90/20/90 ms fades. Loading is static and labeled. Android uses an opaque control-band fallback; iOS/web use rounded 45-intensity blur over the local gradient. Every blur and intermediate control wrapper has its own radius/clipping.




