E-commerce

Order Status Tracker

A fulfillment rail from placed to delivered where completed legs fill and the active leg shimmers in progress.

Install

npx shadcn@latest add @paragon/order-status-tracker

Also installs: tooltip

order-status-tracker.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Check, Package, Home, Truck, MapPin, ShoppingBag } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

export interface OrderStage {
  id: string;
  label: string;
  /** Optional caption, e.g. a date or note. */
  caption?: string;
  icon?: LucideIcon;
}

export interface OrderStatusTrackerProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  stages?: OrderStage[];
  /** Index of the current stage (0-based). Stages before it are complete. */
  current?: number;
  orientation?: "horizontal" | "vertical";
  /** Opt out of enter + shimmer + pulse motion. */
  static?: boolean;
}

const DEFAULT_STAGES: OrderStage[] = [
  { id: "placed", label: "Placed", icon: ShoppingBag },
  { id: "packed", label: "Packed", icon: Package },
  { id: "shipped", label: "Shipped", icon: Truck },
  { id: "out", label: "Out for delivery", icon: MapPin },
  { id: "delivered", label: "Delivered", icon: Home },
];

// Node geometry drives the shared coordinate scale used by both the SVG
// connector track and the CSS-grid label row, so lines land on node centers.
const NODE = 32; // node diameter (px)

/**
 * A fulfillment rail. Node centers sit on a shared scale: the connector is one
 * SVG line whose segment boundaries are the computed node centers, so completed
 * / active / pending fills land exactly between nodes — never eyeballed widths.
 * Completed legs fill solid; the active leg fills to a midpoint and carries a
 * traveling shimmer to read as "in progress"; upcoming legs stay muted. The
 * active node pulses once on mount, nodes reveal with the house rise, and each
 * step is a hover/focus tooltip. Reduced motion (or `static`) drops the
 * shimmer, pulse and rise but keeps the fill and color states.
 */
export function OrderStatusTracker({
  stages = DEFAULT_STAGES,
  current = 0,
  orientation = "horizontal",
  static: isStatic = false,
  className,
  ...props
}: OrderStatusTrackerProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const ref = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
  const animate = !isStatic && !reducedMotion;
  const armed = !animate || inView;

  const vertical = orientation === "vertical";

  if (stages.length === 0) {
    return (
      <div
        data-slot="order-status-tracker"
        className={cn(
          "flex w-full items-center justify-center rounded-xl border border-dashed border-border px-4 py-8 text-sm text-muted-foreground",
          className,
        )}
      >
        No fulfillment stages to show.
      </div>
    );
  }

  const n = stages.length;
  // Fraction of the active leg that reads as "done". The remainder shimmers.
  const activeFill = 0.55;
  // Overall fill in leg units (0..n-1), computed from a single scale.
  const filledLegs = armed ? Math.min(current + activeFill, n - 1) : 0;

  const styleBlock = (
    <style href="paragon-order-status-tracker" precedence="paragon">{`
      @keyframes pg-ost-shimmer-x { to { transform: translateX(300%); } }
      @keyframes pg-ost-shimmer-y { to { transform: translateY(300%); } }
      @keyframes pg-ost-rise {
        from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
        to { opacity: 1; transform: translateY(0); filter: blur(0); }
      }
      @keyframes pg-ost-pulse {
        0% { box-shadow: 0 0 0 0 var(--color-primary); }
        70% { box-shadow: 0 0 0 7px transparent; }
        100% { box-shadow: 0 0 0 0 transparent; }
      }
      @media (prefers-reduced-motion: reduce) {
        [data-ost-shimmer], [data-ost-node], [data-ost-item] {
          animation: none !important;
        }
      }
    `}</style>
  );

  const Node = ({ stage, i }: { stage: OrderStage; i: number }) => {
    const complete = i < current;
    const active = i === current;
    const Icon = stage.icon ?? ShoppingBag;
    return (
      <Tooltip>
        <TooltipTrigger asChild>
          <button
            type="button"
            data-ost-node={active ? "active" : undefined}
            aria-current={active ? "step" : undefined}
            aria-label={`${stage.label}${
              complete
                ? " — complete"
                : active
                  ? " — in progress"
                  : " — upcoming"
            }`}
            className={cn(
              "pressable relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full outline-none transition-[background-color,color] duration-(--duration-base) ease-out",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              complete || active
                ? "bg-primary text-primary-foreground"
                : "bg-secondary text-muted-foreground",
            )}
            style={
              active && animate
                ? {
                    animation: `pg-ost-pulse 1.6s var(--ease-out) 1`,
                    animationDelay: `${n * 70}ms`,
                  }
                : undefined
            }
          >
            {complete ? (
              <Check aria-hidden className="size-4" strokeWidth={2.5} />
            ) : (
              <Icon aria-hidden className="size-4" />
            )}
          </button>
        </TooltipTrigger>
        <TooltipContent side={vertical ? "right" : "top"}>
          <span className="font-medium">{stage.label}</span>
          {stage.caption && (
            <span className="ml-1.5 tabular-nums text-primary-foreground/70">
              {stage.caption}
            </span>
          )}
        </TooltipContent>
      </Tooltip>
    );
  };

  // ── Vertical: nodes in a column, computed-scale fill on each connector ──
  if (vertical) {
    return (
      <TooltipProvider>
        {styleBlock}
        <div
          ref={ref}
          data-slot="order-status-tracker"
          className={cn("flex w-full flex-col", className)}
          {...props}
        >
          <ol className="flex flex-col">
            {stages.map((stage, i) => {
              const isLast = i === n - 1;
              const legFill = Math.max(0, Math.min(1, filledLegs - i));
              const legActive = i === current && !isLast && armed && animate;
              return (
                <li
                  key={stage.id}
                  data-ost-item
                  className="flex min-w-0 gap-3"
                  style={
                    animate
                      ? {
                          animation: `pg-ost-rise var(--duration-base) var(--ease-out) both`,
                          animationDelay: `${i * 70}ms`,
                        }
                      : undefined
                  }
                >
                  <div className="flex flex-col items-center">
                    <Node stage={stage} i={i} />
                    {!isLast && (
                      <span
                        aria-hidden
                        className="relative my-1 w-0.5 flex-1 overflow-hidden rounded-full bg-border"
                        style={{ minHeight: NODE }}
                      >
                        <span
                          className="absolute inset-0 bg-primary transition-transform duration-500 ease-out motion-reduce:transition-none"
                          style={{
                            transform: `scaleY(${legFill})`,
                            transformOrigin: "top",
                          }}
                        />
                        {legActive && (
                          <span
                            data-ost-shimmer
                            className="pointer-events-none absolute inset-x-0 top-0 h-1/2"
                            style={{
                              background:
                                "linear-gradient(180deg, transparent, color-mix(in oklch, var(--color-primary), white 60%), transparent)",
                              animation: `pg-ost-shimmer-y 1.4s var(--ease-in-out) infinite`,
                              transform: "translateY(-100%)",
                            }}
                          />
                        )}
                      </span>
                    )}
                  </div>
                  <div className="min-w-0 pb-5 pt-1">
                    <p
                      className={cn(
                        "truncate text-xs font-medium leading-tight",
                        i <= current
                          ? "text-foreground"
                          : "text-muted-foreground",
                      )}
                    >
                      {stage.label}
                    </p>
                    {stage.caption && (
                      <p className="mt-0.5 truncate text-[11px] tabular-nums text-muted-foreground">
                        {stage.caption}
                      </p>
                    )}
                  </div>
                </li>
              );
            })}
          </ol>
        </div>
      </TooltipProvider>
    );
  }

  // ── Horizontal: computed-scale track + CSS-grid node/label rows aligned ──
  // Each of the n columns is 1fr; a node is centered in its column, so node
  // centers sit at (i + 0.5)/n of the track width. Connector segments span
  // consecutive node centers, derived from that same fractional scale — so
  // every segment starts and ends exactly on a node center. Fills are scaleX
  // transforms only (compositor-safe); nothing animates width/left.
  const centerPct = (i: number) => ((i + 0.5) / n) * 100;

  return (
    <TooltipProvider>
      {styleBlock}
      <div
        ref={ref}
        data-slot="order-status-tracker"
        className={cn("w-full", className)}
        {...props}
      >
        {/* Connector track: HTML segments positioned on the shared node scale */}
        <div className="relative w-full" style={{ height: NODE }} aria-hidden>
          {stages.slice(0, -1).map((stage, i) => {
            const left = centerPct(i);
            const width = centerPct(i + 1) - left;
            const legFill = Math.max(0, Math.min(1, filledLegs - i));
            const legActive = i === current && armed && animate;
            return (
              <span
                key={stage.id}
                className="absolute overflow-hidden rounded-full bg-border"
                style={{
                  left: `${left}%`,
                  width: `${width}%`,
                  top: NODE / 2 - 1,
                  height: 2,
                }}
              >
                <span
                  className="absolute inset-0 origin-left bg-primary transition-transform duration-500 ease-out motion-reduce:transition-none"
                  style={{ transform: `scaleX(${legFill})` }}
                />
                {legActive && (
                  <span
                    data-ost-shimmer
                    className="absolute inset-y-0 w-1/3"
                    style={{
                      background:
                        "linear-gradient(90deg, transparent, color-mix(in oklch, var(--color-primary), white 60%), transparent)",
                      animation: `pg-ost-shimmer-x 1.4s var(--ease-in-out) infinite`,
                      transform: "translateX(-100%)",
                    }}
                  />
                )}
              </span>
            );
          })}
          {/* Nodes on the same 1fr grid, centered in each column */}
          <ol
            className="absolute inset-0 grid"
            style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
          >
            {stages.map((stage, i) => (
              <li
                key={stage.id}
                data-ost-item
                className="flex items-center justify-center"
                style={
                  animate
                    ? {
                        animation: `pg-ost-rise var(--duration-base) var(--ease-out) both`,
                        animationDelay: `${i * 70}ms`,
                      }
                    : undefined
                }
              >
                <Node stage={stage} i={i} />
              </li>
            ))}
          </ol>
        </div>

        {/* Label row on the identical 1fr grid — centered under each node */}
        <div
          className="mt-2 grid"
          style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}
        >
          {stages.map((stage, i) => (
            <div key={stage.id} className="min-w-0 px-1 text-center">
              <p
                className={cn(
                  "truncate text-xs font-medium leading-tight",
                  i <= current ? "text-foreground" : "text-muted-foreground",
                )}
              >
                {stage.label}
              </p>
              {stage.caption && (
                <p className="mt-0.5 truncate text-[11px] tabular-nums text-muted-foreground">
                  {stage.caption}
                </p>
              )}
            </div>
          ))}
        </div>
      </div>
    </TooltipProvider>
  );
}