approvals-ui
componentapproval-nodesource: ok · refreshed
Approval node
The approval gate card for React Flow: approvers with unassigned seats, quorum badge, condition pill, SLA badge, plus diff, validation, and live-status rings.
npx shadcn add "https://approvals-ui.vercel.app/r/approval-node.json"free
Where it goes
stats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
badgehttps://approvals-ui.vercel.app/r/approvals-core.json
Files1 · source cached 10/8/2026, 10:48:17 AM
components/approvals-ui/approval-node.tsx
"use client";
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { CircleCheck, CircleX, Clock, UserRound } from "lucide-react";
import { useMemo } from "react";
import type { StepChange } from "@/lib/approvals-ui/diff";
import type { IssueSeverity } from "@/lib/approvals-ui/validate";
import { Badge } from "@/components/ui/badge";
import { type ApprovalStep, humanizeCondition, summarizeMode } from "@/lib/approvals-ui/policy";
import { cn } from "@/lib/utils";
/** Live run overlay: where a given request currently sits. */
export type StepStatus = "pending" | "approved" | "rejected" | "skipped";
export type ApprovalNodeData = {
step: ApprovalStep;
/** Diff overlay from a pending proposal. */
change?: StepChange["kind"];
/** Worst validation severity touching this step. */
issue?: IssueSeverity;
status?: StepStatus;
selected?: boolean;
vertical?: boolean;
hasIncoming?: boolean;
hasOutgoing?: boolean;
[key: string]: unknown;
};
export type ApprovalFlowNode = Node<ApprovalNodeData, "approval">;
export const stateRing = (data: {
change?: StepChange["kind"];
issue?: IssueSeverity;
selected?: boolean;
}): string => {
if (data.change === "added") return "ring-2 ring-emerald-500/70 border-emerald-500/40";
if (data.change === "changed") return "ring-2 ring-amber-500/70 border-amber-500/40";
if (data.change === "removed") return "opacity-50 border-dashed ring-2 ring-red-500/50";
if (data.issue === "error") return "ring-2 ring-destructive/70";
if (data.issue === "warning") return "ring-2 ring-amber-500/50";
if (data.selected) return "ring-2 ring-ring";
return "";
};
const initials = (name: string): string => {
return name
.split(/\s+/)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("");
};
/**
* Approvers carry no id, and a gate can legitimately hold two identical seats
* (e.g. two unassigned "Finance Director" seats), so content alone is not a
* unique key. Derive a stable key from the content plus a per-content
* occurrence count: identical seats get "…#0", "…#1", and the key stays tied to
* the item rather than to the raw array position.
*/
const approverKeys = (approvers: readonly ApprovalStep["approvers"][number][]): string[] => {
const seen = new Map<string, number>();
return approvers.map((approver) => {
const base = `${approver.name ?? "open"}|${approver.title}`;
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
return `${base}#${n}`;
});
};
const StatusBadge = ({ status }: { status: StepStatus }) => {
if (status === "approved") {
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<CircleCheck className="size-3.5" /> Approved
</span>
);
}
if (status === "rejected") {
return (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-red-600 dark:text-red-400">
<CircleX className="size-3.5" /> Rejected
</span>
);
}
if (status === "pending") {
return (
<span className="text-foreground inline-flex items-center gap-1 text-[11px] font-medium">
<span className="relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-500 opacity-60" />
<span className="relative inline-flex size-2 rounded-full bg-amber-500" />
</span>
Awaiting decision
</span>
);
}
return <span className="text-muted-foreground text-[11px]">Skipped</span>;
};
export const ApprovalNode = ({ data }: NodeProps<ApprovalFlowNode>) => {
const { step } = data;
const isVertical = data.vertical !== false;
const condition = humanizeCondition(step.when);
const keys = useMemo(() => approverKeys(step.approvers), [step.approvers]);
return (
<div
className={cn(
"bg-card text-card-foreground w-64 rounded-xl border shadow-sm",
data.status === "skipped" && "opacity-45",
stateRing(data)
)}
>
{data.hasIncoming !== false && (
<Handle
type="target"
position={isVertical ? Position.Top : Position.Left}
className="!bg-border !size-2 !border-none"
/>
)}
<div className="space-y-1 px-3.5 pt-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm leading-tight font-medium">{step.label}</p>
{data.status && <StatusBadge status={data.status} />}
</div>
{condition !== "always" && (
<Badge
variant="outline"
className="text-muted-foreground max-w-full font-mono text-[10px] font-normal"
>
<span className="truncate">{condition}</span>
</Badge>
)}
</div>
<div className="space-y-1.5 px-3.5 py-3">
{step.approvers.map((approver, index) => (
<div key={keys[index]} className="flex items-center gap-2">
{approver.name === null ? (
<span className="border-muted-foreground/50 text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border border-dashed">
<UserRound className="size-3" />
</span>
) : (
<span className="bg-muted flex size-6 shrink-0 items-center justify-center rounded-full text-[10px] font-medium">
{initials(approver.name)}
</span>
)}
<div className="min-w-0 leading-tight">
<p
className={cn(
"truncate text-xs",
approver.name === null && "text-muted-foreground italic"
)}
>
{approver.name ?? "Unassigned"}
</p>
<p className="text-muted-foreground truncate text-[10px]">{approver.title}</p>
</div>
</div>
))}
</div>
{(step.approvers.length > 1 || step.sla) && (
<div className="flex flex-wrap items-center gap-1.5 border-t px-3.5 py-2">
{step.approvers.length > 1 && (
<Badge variant="secondary" className="text-[10px] font-normal">
{summarizeMode(step)}
</Badge>
)}
{step.sla && (
<Badge variant="secondary" className="gap-1 text-[10px] font-normal">
<Clock className="size-3" />
{step.sla.hours}h{step.sla.escalateTo ? ` then ${step.sla.escalateTo.title}` : ""}
</Badge>
)}
</div>
)}
{data.hasOutgoing !== false && (
<Handle
type="source"
position={isVertical ? Position.Bottom : Position.Right}
className="!bg-border !size-2 !border-none"
/>
)}
</div>
);
};