When to use
- The approvals page of an agent product: agents ask to send email, run a migration or spend money, and a person decides each one from a queue.
- Operators who triage many requests in a row and want to stay on the keyboard: J and K to move, A held to approve, D to deny, E to edit.
- Requests that change something you can show as text: a file, a query, a config, an email draft.
When not to use
- One request inside a chat reply: use Approval Card on its own, inline with the message.
- A yes/no question with nothing to show: use a confirm dialog.
Installation
pnpm dlx shadcn add @eased-pro/approval-inboxnpx shadcn add @eased-pro/approval-inboxyarn dlx shadcn add @eased-pro/approval-inboxbunx --bun shadcn add @eased-pro/approval-inboxNeeds the @eased-pro registry and your Pro token in components.json. Create a token and copy the setup from your account .
Install the dependencies
pnpm add motion diff @base-ui/reactnpm install motion diff @base-ui/reactyarn add motion diff @base-ui/reactbun add motion diff @base-ui/reactAdd the Eased pieces it builds on
Copy each item’s files the same way, or install them with the CLI:
Add the files
- components/eased/approval-inbox/approval-inbox.tsx
Source is included with Eased Pro. Members download the files with the CLI command above, or through the MCP server.
Keep the folder layout
The files import each other by relative path (
../lib/motion), so keep each one at the path shown, undercomponents/eased/.
Usage
import { ApprovalInbox, type ApprovalRequest } from "@/components/eased/approval-inbox/approval-inbox";export function ApprovalsPage({ requests, decide, edit }: { requests: ApprovalRequest[]; decide: Decide; edit: Edit }) { return ( <ApprovalInbox className="h-dvh" requests={requests} onApprove={(r) => decide(r.id, "approved")} // set state "running", then "done" onDeny={(r) => decide(r.id, "denied")} // set state "denied" onEdit={(r, files) => edit(r.id, files)} // store the new files and set edited /> );}const request: ApprovalRequest = { id: "apr_9m4t", title: "Raise the GPU spend cap to $2,600 a month", tool: "update_budget", agent: "Infra agent", kind: "payment", reason: "Fine-tuning jobs hit the $2,000 cap on 14 October and were paused twice.", files: [{ path: "infra/budgets.yaml", before, after }], requestedAt,};The inbox never decides on its own: it calls onApprove, onDeny and onEdit, and you set each request's state (and decidedAt) as your backend confirms. Keep decided requests in the array; they show their outcome in the queue for a moment, fold away and move to the Decided list.
API reference
ApprovalInbox
| Prop | Type | Default | Description |
|---|---|---|---|
requests | ApprovalRequest[] | Every request, waiting and decided, oldest first. Append new ones as agents ask. | |
onApprove | (request) => void | The hold on Approve (or A) completed. | |
onDeny | (request) => void | Deny (or D). | |
onEdit | (request, files) => void | Save in the editor. Leave it out to hide Edit. | |
emptyAction | ReactNode | Under the empty state's check. | |
now | number | time of mount | Epoch ms for relative times ("6 min ago"). |
holdMs | number | 800 | How long Approve must be held. |
locale | string | "en-US" | |
className | string | "" | Appended to the root. Give it a height (h-dvh, flex-1); the queue and the Decided list scroll inside it. |
ApprovalRequest
| Field | Type | Description |
|---|---|---|
id / title | string | The request, and what it will do as a short imperative. |
tool / agent | string | The tool called (send_email) and the agent asking. |
reason | ReactNode | Why, in a sentence, beside the tool's name. |
files | ApprovalFile[] | { path, before, after } or { path, hunks }, as Approval Card takes them. Only files with after text are editable. |
kind | "email" | "database" | "payment" | "message" | "code" | "tool" | The leading glyph. Default "tool". |
requestedAt | number | Epoch ms. |
state | ApprovalState | awaiting-approval (default), running, done, denied, expired or error. |
decidedAt | number | Epoch ms, for the Decided list. |
edited | boolean | Shown as "Edited" in the queue and "Approved with edits" once decided. |
Keyboard
| Key | Action |
|---|---|
Tab | Moves to the open request, then its Deny, Edit and Approve. The queue is one tab stop. |
J / K, ↓ / ↑, Home / End | Move between waiting requests and open them. |
A (held) | Approves the open request when the hold completes; let go early to cancel. |
D | Denies the open request. |
E | Edits the open request's change. In the editor, Escape cancels and ⌘ Enter (Ctrl Enter) saves. |
Accessibility
- The queue is a list; the open request is an Approval Card (a
groupnamed by its title) that takes focus as you move, so its title is read on arrival. Folded requests are buttons named "Open: …" for pointer and screen reader users. - Focus follows the decision: when a request is decided, the next one opens and takes focus; when the last one goes, focus moves to the "All caught up" heading. Nothing takes focus on mount.
- Each card announces its own outcome ("Approved: …"); the inbox's status region then announces the count ("3 waiting", "All caught up").
- The shortcut legend is plain text with keycaps that press as the keys do, so it is read as written.
Motion
On mount the header, queue and Decided list rise 4px in turn (stagger.line, once). From then on everything is state-driven. Moving opens the next card on Auto Height (smooth, no overshoot) while the last one folds to its header. A decision folds the card to its outcome (the check draws, the title gains "Approved:" through Text Swap); 0.9s later the row collapses on the smooth spring while the pill's count rolls down, and the request rises into the Decided list as the rest glide down. A request arriving unfolds at the bottom as the count rolls up. When the queue is clear the pill's label gives way and it shrinks to a check, and a larger check draws in where the queue was (bouncy, from 0.96). Reduced motion: fades only, nothing rolls, slides or springs.
Notes for AI
Pick this for an approvals page where a person clears many agent requests; pass the real before/after text so each card's diff is the thing approved, and drive state from your backend (running while an approved call applies, then done). Keep request ids stable and keep decided requests in the array so the queue can fold them away.
Do not add confirm dialogs (the hold is the confirmation), a second count beside the pill, or your own keyboard handlers for J, K, A, D and E. Do not wrap it in a card: it is the page.
Layout
Two columns from 64rem: the queue and the Decided list (18rem). Below 64rem the Decided list folds away and the queue fills the screen. The shortcut legend shows from 48rem on devices with a fine pointer. Below 30rem each row drops its agent and time and keeps the title and change count. Nothing scrolls sideways at 390px; long diff lines scroll inside the diff.
Related components
- AI Agent Approval Card: one request on its own, the card each row opens into.
- AI Agent Run Console: watching the runs these requests come from.
- Multi-model Compare