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% { opacity: 0.45; transform: scale(1); }
70%, 100% { opacity: 0; transform: scale(1.9); }
}
[data-customs-pulse] {
animation: customs-current-pulse 2.2s var(--ease-out) infinite;
}
@media (prefers-reduced-motion: reduce) {
[data-customs-pulse] { animation: none !important; opacity: 0; }
}
`}</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}
aria-current={isCurrent ? "step" : undefined}
className="flex gap-3"
>
{/* Rail column */}
<div className="flex flex-col items-center">
<span
aria-hidden
className={cn(
"relative 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",
)}
>
{/* Radar halo on the active stage — transform/opacity only. */}
{isCurrent && animate && (
<span
data-customs-pulse=""
className="pointer-events-none absolute -inset-0.5 rounded-full border border-primary"
/>
)}
{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
? // Connectors draw top-to-bottom in sequence.
`transform 350ms var(--ease-in-out) ${i * 120}ms`
: 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}
<span className="sr-only">
{complete
? " — completed"
: isCurrent
? " — in progress"
: " — upcoming"}
</span>
</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>
);
}