Feedback

Customs Clearance Steps

A vertical customs stepper with per-stage document states — approved, in review, rejected, or required.

Install

npx shadcn@latest add @paragon/customs-clearance-steps

customs-clearance-steps.tsx

"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>
  );
}