Approval Flow React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React approval workflow: the sign-off chain of a request, showing who approved and who is next, with comments, reminders and history.
Read the chain top to bottom: a green check per approved step, a ring with a clock on the step that waits, numbers on the ones after it, and every approver with a status ring, a decision badge, the time and their comment. When it is your turn a tinted bar asks for your decision: write an optional comment (type @ to mention a colleague, arrows and Enter pick one), then press Approve, Request changes or Reject. Reject opens a dialog (a bottom sheet on phones) that focuses the reason field and will not go on without a reason; Cmd or Ctrl + Enter there confirms, Escape cancels and returns focus. Approve never fires from the keyboard shortcut: decisions are explicit clicks. While a decision saves the pressed button spins and the others wait; an error from the server shows under the buttons. Once it saves, the bar folds away and keyboard focus moves to the flow instead of getting lost. Late approvers get a Send a reminder button, amber once the step is overdue. The last approval pops the final marker with a small bounce. The History row folds the timeline open and closed. In the compact variant the summary (stacked avatars with rings, 2 of 3 approvals, the status) is a button that opens the whole flow in a popover, a sheet on phones. Wider than 512px a horizontal flow lays its steps in a row; narrower it stacks. Everything is announced to screen readers and reduced motion turns the springs into instant changes.
Approval Flow is a Pro React component in the React data display components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add approval-flow, together with Mention Input, Timeline, Avatar, Badge, Button and 11 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add approval-flowApproval Flow is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import ApprovalFlow from @/components/ui/approval-flow.
import { useState } from "react"
import { ApprovalFlow, type ApprovalStep } from "@/components/ui/approval-flow"
const initialSteps: ApprovalStep[] = [
{
id: "sales",
name: "Sales manager",
approvers: [{ id: "u-ryan", name: "Ryan Martin", role: "Sales", decision: "approved", decidedAt: "2026-09-22T14:05:00" }],
},
{
id: "finance",
name: "Finance director",
description: "required over $10,000",
dueAt: "2026-09-24T12:00:00",
approvers: [{ id: "u-jessica", name: "Jessica Dunn", role: "Accounting" }],
},
{ id: "ceo", name: "CEO", approvers: [{ id: "u-liam", name: "Liam Brooks", role: "Owner" }] },
]
export function DiscountApproval() {
const [steps, setSteps] = useState(initialSteps)
return (
<ApprovalFlow
title="15% discount · Keystone Construction"
requester={{ name: "Michael Hayes" }}
submittedAt="2026-09-22T11:20:00"
amount={103400}
currency="USD"
currentUserId="u-jessica"
steps={steps}
labels={{ approve: "Approve", reject: "Decline", requestChanges: "Ask for changes" }}
onDecision={async ({ stepId, decision, comment }) => {
await fetch("/api/approvals", { method: "POST", body: JSON.stringify({ stepId, decision, comment }) })
setSteps((prev) =>
prev.map((step) =>
step.id !== stepId
? step
: {
...step,
approvers: step.approvers.map((a) =>
a.id === "u-jessica" ? { ...a, decision, comment, decidedAt: new Date() } : a,
),
},
),
)
}}
/>
)
}Props
steps*ApprovalStep[]The chain in order: { id, name, description?, rule?: "any" | "all", approvers, dueAt?, skipped? }. Each approver is { id, name, avatar?, role?, decision?, decidedAt?, comment? (mention markup), delegatedFrom? }. The status of each step and of the request is derived from the decisions.
titledefault""ReactNodeThe request, e.g. "15% discount · Keystone Construction". Without it the section is named by labels.title for screen readers. The demo shows a sample while the field is empty.
requester{ name: string; avatar?: string }Who asked, shown with a small avatar in the meta line under the title.
submittedAtTimeInputWhen it was submitted: a relative time in the meta line, the full date on hover.
amountdefault0numberThe money the request is about, in major units, formatted with formatPrice in the meta line. 0 hides it (the demo then shows its sample amount).
currencydefault"USD"stringISO 4217 code for the amount.
statusdefault"""draft" | "pending" | "approved" | "rejected" | "changes-requested" | "cancelled" | ""Overrides the status derived from the steps. Draft and cancelled only exist this way; any status other than pending hides the action bar and the active step ring.
currentUserIddefault""stringThe person looking. When they are a pending approver of the active step the action bar shows and their row gets a You badge; anyone else sees who the request waits for. The demo's "View as" switch sets it while this field is empty.
onDecision(input: ApprovalDecisionInput) => (void | Promise<unknown>)Runs on Approve, Reject and Request changes with { stepId, decision: "approved" | "rejected" | "changes-requested", comment (markup), mentions (ids) }. A promise keeps the pressed button busy; a rejection shows its message under the buttons (or in the reject dialog).
requireRejectCommentdefaulttruebooleanReject opens a dialog (a sheet on phones) that needs a reason. false rejects at once with the comment as it is.
allowRequestChangesdefaulttruebooleanShows the third action, Request changes, which sends the request back without rejecting it.
onRemind(approver: ApprovalApprover, step: ApprovalStep) => (void | Promise<unknown>)Adds a Send a reminder button to the pending approvers of the active step (amber once the step is overdue). A promise spins it; success turns it into Reminder sent and announces it.
peopleMentionItem[]The colleagues @ can mention in the comment box (MentionInput). Without it the comment box is a plain Textarea.
historyApprovalHistoryEntry[]The events: { id, at, actor: { name, avatar? }, type: submitted | approved | rejected | changes-requested | comment | reminder | resubmitted | cancelled, comment?, stepId? }, shown as a feed timeline under a History (n) toggle.
showHistorydefaulttruebooleanShows the History toggle when there is history.
historyDefaultOpendefaultfalsebooleanStarts with the history open.
defaultOpendefaultfalsebooleanStarts with the overlay open: the reject dialog in the full variant (when the viewer can decide), the details popover in compact. Control it with open and onOpenChange.
openbooleanControlled overlay state (reject dialog or compact popover); pair it with onOpenChange.
onOpenChange(open: boolean) => voidRuns when the reject dialog or the compact popover opens or closes.
orientationdefault"vertical""vertical" | "horizontal"horizontal lays the steps in a row with an avatar group under each, for page and invoice headers. Below 512px of width it stacks vertically anyway.
variantdefault"full""full" | "compact"compact renders only the summary (stacked avatars with status rings, 2 of 3 approvals, the status badge) as a button that opens the full flow in a popover, a bottom sheet on phones. For table rows and cards.
sizedefault"md""sm" | "md"sm tightens the panel, the markers (24px), the avatars and the text for side panels.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The active step's ring, the your-turn bar and the Approve button. Decisions keep their status colors: green approved, red rejected, blue changes requested, amber waiting.
colordefault""stringAny CSS color; replaces the tone for the active ring, the bar and the Approve button.
radiusdefault"md""none" | "sm" | "md" | "lg"Corner of the panel: 0, 12, 16 or 24px.
surfacedefaulttruebooleanThe raised panel look. false drops the background, shadow and padding for a flow inside a card of your own.
maxAvatarsdefault4numberCompact: avatars before the +N chip.
nowDate | numberThe moment relative times and overdue checks count from. Defaults to the live clock (useNow); demos pin 24.09.2026 16:40.
localedefault""stringLocale for money, dates and relative times. Empty uses the UIProvider locale, then the browser's.
labelsApprovalFlowLabelsInput (a deep Partial<ApprovalFlowLabels>)Every string, English by default (DEFAULT_APPROVAL_FLOW_LABELS): title, statuses, decisions, stepStatuses, events, any, all, due(date), overdue(date), approve, reject, requestChanges, comment, cancel, rejectTitle, rejectDescription, rejectReason, rejectReasonPlaceholder, rejectReasonRequired, mentionPeople, mentionEmpty, remind, reminderSent, reminded(name), remindError, yourTurn, waitingFor(names), progress(approved, required), history(count, formatted), submitted, skipped, delegated(from), you, decided(decision), decisionError.
classNamesPartial<Record<"header" | "title" | "meta" | "status" | "steps" | "step" | "marker" | "connector" | "stepName" | "rule" | "due" | "approver" | "comment" | "remind" | "actions" | "history" | "summary", string>>Extra classes for the inner parts.
classNamestringExtra classes for the root section (the summary button in compact), merged last.
Parts
ApprovalSummary
componentThe compact form alone for table cells: avatars with status rings, the progress and the status badge. A button when it gets onClick, with an aria-label like "2 of 3 approvals, Pending, Waiting for Ioana Dumitrescu". Props: { steps; status?; size?; max?; showCount?; showStatus?; locale?; labels?; classNames?; onClick? }.
getApprovalStatus
functionThe derived overall status: approved when every step is approved or skipped, the active step's rejected or changes-requested, otherwise pending; draft without steps. Pure, for servers and lists. Signature: (steps: ApprovalStep[]) => ApprovalStatus.
getActiveStep
functionThe first step that is neither approved nor skipped. Signature: (steps: ApprovalStep[]) => ApprovalStep | null.
getStepStatus
functionOne step's outcome: any needs one approval, all needs every approver; a step without approvers counts as skipped. Signature: (step: ApprovalStep) => "approved" | "rejected" | "changes-requested" | "pending" | "skipped".
getApprovalProgress
functionApprovals given and needed across the chain (the 2 of 3 in the summary). Signature: (steps: ApprovalStep[]) => { approved: number; required: number }.
DEFAULT_APPROVAL_FLOW_LABELS
constantEvery English string: title ("Approval"), the status, decision, step status and history event names, the Approve / Reject / Request changes buttons, the reject dialog copy, due and overdue dates and the summary. Spread it into labels to translate a few. Type: ApprovalFlowLabels.
ApprovalStep
typeOne stage of the chain: { id, name, description?, rule?: "any" | "all", approvers, dueAt?, skipped? }. any needs one approval, all needs every approver.
ApprovalApprover
typeOne approver: { id, name, avatar?, role?, decision?, decidedAt?, comment?, delegatedFrom? }. comment takes mention markup such as @[Liam Brooks](u-liam).
ApprovalDecisionInput
typeWhat onDecision receives: { stepId, decision, comment, mentions }, with the ids of the people mentioned in the comment.
Examples
Approved
Every step green; replay the last approval to see the final marker pop.
Rejected with a reason
The finance director rejected: the reason sits in a bubble under her name.
Horizontal, invoice header
Steps in a row with avatar groups, inside a supplier invoice header. Stacks below 512px.
Compact in a table
Five expense requests; tap a summary for the whole flow (a sheet on phones).
Changes requested, resubmitted
The history tells the story; a colleague can nudge the late approver.
Phone, your turn
Stacked full-width actions at 390px; Reject opens a bottom sheet on a phone. Brand tone.
Dependencies
Files
- components/ui/approval-flow.tsx
FAQ
Is the React Approval Flow component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Approval Flow?
Run npx wingo-ui@latest add approval-flow. It also adds the Wingo UI items it builds on (Mention Input, Timeline, Avatar, Badge, Button and 11 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/approval-flow.
How does Approval Flow behave on phones?
Reject opens a dialog (a bottom sheet on phones) that focuses the reason field and will not go on without a reason; Cmd or Ctrl + Enter there confirms, Escape cancels and returns focus.