A vertical customs stepper with per-stage document states — approved, in review, rejected, or required.
npx shadcn@latest add @paragon/customs-clearance-steps"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Check, Clock, FileText, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type DocumentState = "approved" | "pending" | "rejected" | "required";
export interface ClearanceDocument {
name: string;
state: DocumentState;
}
export interface ClearanceStep {
label: string;
detail?: string;
documents?: ClearanceDocument[];
}
export interface CustomsClearanceStepsProps
extends React.ComponentProps<"div"> {
steps: ClearanceStep[];
/** Index of the current step; earlier steps are complete. */
current: number;
/** Suppresses the connector draw. */
static?: boolean;
}
const DOC_META: Record<
DocumentState,
{ label: string; icon: React.ReactNode; className: string }
> = {
approved: {
label: "Approved",
icon: <Check className="size-3" />,
className: "bg-success/12 text-success",
},
pending: {
label: "In review",
icon: <Clock className="size-3" />,
className: "bg-warning/15 text-warning",
},
rejected: {
label: "Rejected",
icon: <X className="size-3" />,
className: "bg-destructive/12 text-destructive",
},
required: {
label: "Required",
icon: <FileText className="size-3" />,
className: "bg-secondary text-muted-foreground",
},
};
/**
* A vertical customs-clearance stepper. Each stage shows completion state and
* an optional list of documents with their own states (approved / in review /
* rejected / required). The connector between completed stages draws in once
* on view; reduced motion shows the final state.
*/
export function CustomsClearanceSteps({
steps,
current,
static: isStatic = false,
className,
...props
}: CustomsClearanceStepsProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
const clampedCurrent = Math.min(Math.max(current, 0), steps.length - 1);
return (
<div
ref={ref}
data-slot="customs-clearance-steps"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<style href="paragon-customs-clearance-steps" precedence="paragon">{`
@keyframes customs-current-pulse {
0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--color-primary) 30%, transparent); }
70% { box-shadow: 0 0 0 5px color-mix(in oklch, var(--color-primary) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
[data-customs-current] { animation: none !important; }
}
`}</style>
<ol className="flex flex-col">
{steps.map((step, i) => {
const complete = i < clampedCurrent;
const isCurrent = i === clampedCurrent;
const isLast = i === steps.length - 1;
return (
<li key={step.label} className="flex gap-3">
{/* Rail column */}
<div className="flex flex-col items-center">
<span
data-customs-current={isCurrent && animate ? "" : undefined}
aria-hidden
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-full border-2 transition-[background-color,border-color,color] duration-200",
complete && "border-primary bg-primary text-primary-foreground",
isCurrent && "border-primary bg-background text-primary",
!complete && !isCurrent && "border-border bg-background",
)}
style={
isCurrent && animate
? { animation: "customs-current-pulse 2.2s ease-out infinite" }
: undefined
}
>
{complete ? (
<Check className="size-3.5" />
) : (
<span
className={cn(
"size-1.5 rounded-full",
isCurrent ? "bg-primary" : "bg-border",
)}
/>
)}
</span>
{!isLast && (
<span className="relative my-1 w-0.5 flex-1 overflow-hidden rounded-full bg-border">
<span
className="absolute inset-x-0 top-0 origin-top rounded-full bg-primary"
style={{
height: "100%",
transform: `scaleY(${drawn && complete ? 1 : 0})`,
transition: animate
? "transform 400ms var(--ease-in-out)"
: undefined,
}}
/>
</span>
)}
</div>
{/* Content column */}
<div className={cn("min-w-0 flex-1", !isLast && "pb-5")}>
<div className="flex items-baseline justify-between gap-2">
<p
className={cn(
"text-sm font-medium",
complete || isCurrent
? "text-foreground"
: "text-muted-foreground",
)}
>
{step.label}
</p>
{step.detail && (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{step.detail}
</span>
)}
</div>
{step.documents && step.documents.length > 0 && (
<ul className="mt-2 flex flex-col gap-1.5">
{step.documents.map((doc) => {
const meta = DOC_META[doc.state];
return (
<li
key={doc.name}
className="flex items-center justify-between gap-2 rounded-md bg-secondary/40 px-2.5 py-1.5"
>
<span className="flex min-w-0 items-center gap-1.5 text-xs">
<FileText
aria-hidden
className="size-3.5 shrink-0 text-muted-foreground"
/>
<span className="truncate">{doc.name}</span>
</span>
<span
className={cn(
"flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium",
meta.className,
)}
>
{meta.icon}
{meta.label}
</span>
</li>
);
})}
</ul>
)}
</div>
</li>
);
})}
</ol>
</div>
);
}