ecomcn
All blocks

Post-purchase/block

Order tracking

Where the parcel is: a status chip, the arrival window, a vertical timeline of milestones and the carrier's updates. Carrier webhooks arrive out of order and twice, so events are de-duplicated by id and sorted by when they happened — sortTrackingEvents and trackingProgress live in lib/tracking-events.ts, with no "use client", so a webhook handler runs the same code. The current node is ringed with a live pulse, passed ones solid ink, those to come hollow grey; a carrier problem turns the chip to Needs attention and says why. Rules fill as the parcel moves and the update log grows to its measured height (Motion; a fade under reduced motion). Loading and empty states included.

See order tracking in a working shop — the demo store is built from every ecomcn block.Open the demo store
$ npx shadcn@latest add @ecomcn/order-tracking
Older shadcn CLI, or want the registry pinned?

Register the namespace yourself, once per project, then run the same add command. {name} is literal — the CLI fills it in.

$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.json
Preview — live
100%

Usage

Under the order
import { OrderTracking } from "@/components/ecomcn/order-tracking"

<OrderTracking
  events={shipment.events}            // { id, status, at, description?, location? }[], any order
  carrier={{ name: "UPS", trackingNumber: shipment.number, url: shipment.trackingUrl }}
  estimate={{ from: "2026-10-22", to: "2026-10-26" }}
/>
The same order, where the webhooks land
// lib/tracking-events.ts has no "use client": import it in a route handler.
import { sortTrackingEvents, trackingProgress } from "@/lib/tracking-events"

export async function POST(request: Request) {
  const event = toTrackingEvent(await request.json())   // map the carrier's payload
  const events = sortTrackingEvents([...(await db.events(event.shipmentId)), event])
  const { delivered, issue } = trackingProgress(events)
  if (issue) await notifyCustomer(issue)
  await db.saveEvents(event.shipmentId, events)
  return new Response(null, { status: 204 })
}

Props

Read straight out of the source at build time, so this cannot drift from the file you install.

OrderTrackingProps
/** The carrier's updates, in any order, duplicates and all. */
  events: TrackingEvent[]
  carrier?: { name: string; trackingNumber?: string; url?: string }
  /** Calendar dates, "2026-10-21": the window it should arrive in. */
  estimate?: { from: string; to?: string }
  /** Your milestones, if not the five standard ones. */
  steps?: TrackingStep[]
  /** `undefined` follows the visitor's locale, once hydrated. */
  locale?: string
  /** How many updates show before "Show all". Defaults to 3. */
  visibleUpdates?: number
  loading?: boolean

Source

components/ecomcn/order-tracking.tsx
"use client"

import * as React from "react"
import { ArrowUpRight, Check, CircleAlert } from "lucide-react"
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"

import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import {
  DEFAULT_TRACKING_STEPS,
  sortTrackingEvents,
  trackingProgress,
  type TrackingEvent,
  type TrackingStatus,
  type TrackingStep,
} from "@/lib/tracking-events"
import { cn } from "@/lib/utils"

/*
 * Where the parcel is: a status chip, when it arrives, a vertical timeline
 * of the shipment's milestones, and the carrier's updates under it.
 *
 *   <OrderTracking events={carrierEvents} carrier={{ name: "UPS", trackingNumber, url }} estimate={{ from, to }} />
 *
 * Carrier webhooks arrive out of order and sometimes twice, so the block
 * never trusts arrival order: events are de-duplicated by id and sorted by
 * the time they happened — `sortTrackingEvents` and `trackingProgress` in
 * `@/lib/tracking-events`, a file with no "use client", so your webhook
 * handler can run the same code.
 * The latest milestone is the current node — ringed, with a live pulse;
 * milestones before it are solid ink; those to come are hollow grey. State
 * is never colour alone: a check, a ring, words. When the rule between two
 * nodes fills it draws down to the next (Motion; a fade under reduced
 * motion), and the full update log grows to its measured height.
 * Timestamps show in the visitor's own time zone once hydrated; calendar
 * dates (the estimate) in UTC, so they never slip a day. The estimate is two
 * formatted days joined with a dash, not `formatRange`, whose spacing
 * differs between Node's ICU and the browser's.
 */

export type { TrackingEvent, TrackingMilestone, TrackingStatus, TrackingStep } from "@/lib/tracking-events"

/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */
const MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const
const FADE = { duration: 0.15 } as const

const subscribeNothing = () => () => {}

/* ─── block ────────────────────────────────────────────────────────────── */

export interface OrderTrackingProps extends React.HTMLAttributes<HTMLElement> {
  /** The carrier's updates, in any order, duplicates and all. */
  events: TrackingEvent[]
  carrier?: { name: string; trackingNumber?: string; url?: string }
  /** Calendar dates, "2026-10-21": the window it should arrive in. */
  estimate?: { from: string; to?: string }
  /** Your milestones, if not the five standard ones. */
  steps?: TrackingStep[]
  /** `undefined` follows the visitor's locale, once hydrated. */
  locale?: string
  /** How many updates show before "Show all". Defaults to 3. */
  visibleUpdates?: number
  loading?: boolean
}

export function OrderTracking({
  events,
  carrier,
  estimate,
  steps = DEFAULT_TRACKING_STEPS,
  locale: localeProp,
  visibleUpdates = 3,
  loading = false,
  className,
  ...props
}: OrderTrackingProps) {
  const hydrated = React.useSyncExternalStore(subscribeNothing, () => true, () => false)
  const reduce = useReducedMotion()
  const uid = React.useId()
  const locale = localeProp ?? (hydrated ? undefined : "en-US")
  const timeZone = hydrated ? undefined : "UTC"
  const [showAll, setShowAll] = React.useState(false)

  const sorted = React.useMemo(() => sortTrackingEvents(events, steps), [events, steps])
  const progress = React.useMemo(() => trackingProgress(events, steps), [events, steps])
  const day = React.useMemo(
    () => new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }),
    [locale]
  )
  const stamp = React.useMemo(
    () => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", timeZone }),
    [locale, timeZone]
  )
  const calendar = (iso: string) => new Date(`${iso}T00:00:00Z`)
  const arrival = estimate
    ? !estimate.to || estimate.to === estimate.from
      ? day.format(calendar(estimate.from))
      : `${day.format(calendar(estimate.from))} – ${day.format(calendar(estimate.to))}`
    : null

  const currentStep = steps[progress.current]
  const deliveredAt = progress.reachedAt.delivered
  const headline: React.ReactNode = progress.delivered ? (
    deliveredAt ? (
      <>
        Delivered{" "}
        <time dateTime={deliveredAt} suppressHydrationWarning>
          {stamp.format(new Date(deliveredAt))}
        </time>
      </>
    ) : (
      "Delivered"
    )
  ) : arrival ? (
    `Arrives ${arrival}`
  ) : (
    (currentStep?.label ?? "Preparing your order")
  )
  const chip = progress.issue ? "Needs attention" : (currentStep?.label ?? "Not shipped yet")

  // Newest first in the log.
  const log = [...sorted].reverse()
  const shown = showAll ? log : log.slice(0, visibleUpdates)
  const labelFor = (status: TrackingStatus) =>
    status === "exception" ? "Update" : (steps.find((s) => s.status === status)?.label ?? status)

  if (loading) {
    return (
      <section data-slot="order-tracking" aria-busy className={cn("flex flex-col gap-8", className)} {...props}>
        <span className="sr-only" role="status">
          Loading tracking
        </span>
        <div aria-hidden className="flex flex-col gap-3">
          <Skeleton className="h-5 w-24 rounded-none" />
          <Skeleton className="h-7 w-64 rounded-none" />
          <Skeleton className="h-3.5 w-48 rounded-none" />
        </div>
        <div aria-hidden className="flex flex-col gap-7">
          {steps.map((s) => (
            <div key={s.status} className="flex items-center gap-4">
              <Skeleton className="size-5 rounded-full" />
              <Skeleton className="h-3.5 w-32 rounded-none" />
            </div>
          ))}
        </div>
      </section>
    )
  }

  return (
    <MotionConfig reducedMotion="user">
      <section data-slot="order-tracking" aria-labelledby={`${uid}-title`} className={cn("flex flex-col gap-8", className)} {...props}>
        {/* Status, when, and who's carrying it. */}
        <header data-slot="order-tracking-header" className="flex flex-col gap-3">
          <Badge
            variant={progress.issue ? "destructive" : progress.delivered ? "default" : "outline"}
            className="rounded-none px-2 py-0.5 text-[11px] tracking-[0.14em] uppercase"
          >
            {progress.issue ? <CircleAlert aria-hidden /> : progress.delivered ? <Check aria-hidden /> : null}
            {chip}
          </Badge>
          <h2 id={`${uid}-title`} className="text-2xl font-medium tracking-tight tabular-nums">
            {headline}
          </h2>
          {carrier ? (
            <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
              <span className="text-foreground">{carrier.name}</span>
              {carrier.trackingNumber ? <span className="font-mono text-[12.5px]">{carrier.trackingNumber}</span> : null}
              {carrier.url ? (
                <a
                  href={carrier.url}
                  target="_blank"
                  rel="noreferrer"
                  className="inline-flex items-center gap-1 underline underline-offset-4 hover:text-foreground"
                >
                  Track on {carrier.name}
                  <ArrowUpRight className="size-3.5" aria-hidden />
                  <span className="sr-only">(opens in a new tab)</span>
                </a>
              ) : null}
            </p>
          ) : null}
          {progress.issue ? (
            <p role="status" className="flex items-start gap-2 border-l-2 border-destructive py-1 pl-3 text-sm">
              {progress.issue.description ?? "The carrier reported a problem with this delivery."}
              {progress.issue.location ? <span className="text-muted-foreground"> · {progress.issue.location}</span> : null}
            </p>
          ) : null}
        </header>

        {sorted.length === 0 ? (
          <p data-slot="order-tracking-empty" className="border-y py-5 text-sm text-muted-foreground">
            Tracking appears here once your order ships{arrival ? ` — it should arrive ${arrival}` : ""}.
          </p>
        ) : (
          <>
            {/* The milestones. */}
            <ol data-slot="order-tracking-steps" className="flex flex-col">
              {steps.map((step, i) => {
                const state = i < progress.current ? "done" : i === progress.current ? "current" : "pending"
                const at = progress.reachedAt[step.status]
                const last = i === steps.length - 1
                const trouble = state === "current" && progress.issue
                return (
                  <li
                    key={step.status}
                    data-state={state}
                    aria-current={state === "current" ? "step" : undefined}
                    className="relative flex gap-4 pb-7 last:pb-0"
                  >
                    {/* The rule to the next node: grey, filled in ink once passed. */}
                    {!last ? (
                      <span aria-hidden className="absolute top-6 bottom-1 left-[9.5px] w-px bg-border">
                        <motion.span
                          className="absolute inset-0 origin-top bg-foreground"
                          initial={false}
                          animate={reduce ? { opacity: i < progress.current ? 1 : 0 } : { scaleY: i < progress.current ? 1 : 0 }}
                          transition={reduce ? FADE : MORPH}
                        />
                      </span>
                    ) : null}
                    <span className="relative mt-0.5 grid size-5 shrink-0 place-items-center">
                      {state === "current" && !progress.delivered ? (
                        <span
                          aria-hidden
                          className={cn(
                            "absolute inset-0 rounded-full motion-safe:animate-ping",
                            trouble ? "bg-destructive/40" : "bg-primary/30"
                          )}
                        />
                      ) : null}
                      <span
                        aria-hidden
                        className={cn(
                          "relative grid size-5 place-items-center rounded-full border",
                          state === "done" && "border-foreground bg-foreground text-background",
                          state === "current" &&
                            (trouble
                              ? "border-destructive bg-background text-destructive ring-2 ring-destructive ring-offset-2 ring-offset-background"
                              : progress.delivered
                                ? "border-foreground bg-foreground text-background"
                                : "border-primary bg-primary ring-2 ring-primary ring-offset-2 ring-offset-background"),
                          state === "pending" && "border-border bg-background"
                        )}
                      >
                        {state === "done" || (state === "current" && progress.delivered) ? <Check className="size-3" strokeWidth={3} /> : null}
                        {trouble ? <span className="text-[11px] font-bold">!</span> : null}
                      </span>
                    </span>
                    <span className="flex min-w-0 flex-col gap-0.5">
                      <span className={cn("text-sm", state === "pending" ? "text-muted-foreground" : "font-medium")}>{step.label}</span>
                      {at ? (
                        <time dateTime={at} suppressHydrationWarning className="text-[13px] text-muted-foreground tabular-nums">
                          {stamp.format(new Date(at))}
                        </time>
                      ) : null}
                      <span className="sr-only">
                        {state === "done" ? "done" : state === "current" ? (trouble ? "current, needs attention" : "current") : "not yet"}
                      </span>
                    </span>
                  </li>
                )
              })}
            </ol>

            {/* The carrier's updates, newest first. */}
            <div data-slot="order-tracking-updates" className="flex flex-col">
              <h3 className="border-b pb-2 text-xs tracking-[0.14em] text-muted-foreground uppercase">Updates</h3>
              <ul id={`${uid}-updates`}>
                <AnimatePresence initial={false}>
                  {shown.map((event) => (
                    <motion.li
                      key={event.id}
                      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
                      animate={reduce ? { opacity: 1 } : { opacity: 1, height: "auto" }}
                      exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
                      transition={reduce ? FADE : MORPH}
                      className="overflow-hidden"
                    >
                      <div className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-x-4 border-b py-3 text-sm sm:grid-cols-[9rem_minmax(0,1fr)]">
                        <time dateTime={event.at} suppressHydrationWarning className="text-muted-foreground tabular-nums">
                          {stamp.format(new Date(event.at))}
                        </time>
                        <span className="flex flex-col gap-0.5">
                          <span className={cn(event.status === "exception" && "text-destructive")}>
                            {event.description ?? labelFor(event.status)}
                          </span>
                          {event.location ? <span className="text-[13px] text-muted-foreground">{event.location}</span> : null}
                        </span>
                      </div>
                    </motion.li>
                  ))}
                </AnimatePresence>
              </ul>
              {log.length > visibleUpdates ? (
                <button
                  type="button"
                  aria-expanded={showAll}
                  aria-controls={`${uid}-updates`}
                  onClick={() => setShowAll((s) => !s)}
                  className="mt-3 self-start text-sm underline underline-offset-4 hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
                >
                  {showAll ? "Show fewer updates" : `Show all ${log.length} updates`}
                </button>
              ) : null}
            </div>
          </>
        )}
      </section>
    </MotionConfig>
  )
}
lib/tracking-events.ts
/*
 * A shipment's progress from carrier events — in any order, duplicates and
 * all. No "use client" and no React: run it where the webhooks land, to
 * store events in order, and the order-tracking block runs the same code.
 *
 *   const { current, issue, delivered } = trackingProgress(events)
 */

export type TrackingMilestone = "ordered" | "dispatched" | "in-transit" | "out-for-delivery" | "delivered"
/** A milestone, or a problem the carrier reports: a failed attempt, a delay. */
export type TrackingStatus = TrackingMilestone | "exception"

export interface TrackingEvent {
  /** The carrier's event id — the same update sent twice is shown once. */
  id: string
  status: TrackingStatus
  /** ISO timestamp of when it happened, not when the webhook arrived. */
  at: string
  /** "Departed facility", "Delivery attempted — no one home". */
  description?: string
  /** "Leipzig, DE". */
  location?: string
}

export interface TrackingStep {
  status: TrackingMilestone
  label: string
}

export const DEFAULT_TRACKING_STEPS: TrackingStep[] = [
  { status: "ordered", label: "Ordered" },
  { status: "dispatched", label: "Dispatched" },
  { status: "in-transit", label: "In transit" },
  { status: "out-for-delivery", label: "Out for delivery" },
  { status: "delivered", label: "Delivered" },
]

/**
 * The events once, in the order they happened. A repeated id keeps its last
 * copy (a carrier's correction); equal times keep the milestone order.
 */
export function sortTrackingEvents(events: TrackingEvent[], steps: TrackingStep[] = DEFAULT_TRACKING_STEPS) {
  const byId = new Map<string, TrackingEvent>()
  for (const event of events) byId.set(event.id, event)
  const rank = (s: TrackingStatus) => (s === "exception" ? steps.length : steps.findIndex((step) => step.status === s))
  return [...byId.values()].sort((a, b) => Date.parse(a.at) - Date.parse(b.at) || rank(a.status) - rank(b.status))
}

export interface TrackingProgress {
  /** Index into the steps of the milestone the parcel is at; -1 before any. */
  current: number
  /** When each milestone was first reached, if the carrier said so. */
  reachedAt: Partial<Record<TrackingMilestone, string>>
  /** The latest event, when it's a problem reported after the last milestone. */
  issue: TrackingEvent | null
  delivered: boolean
}

/** Where the parcel is, from events in any order. */
export function trackingProgress(events: TrackingEvent[], steps: TrackingStep[] = DEFAULT_TRACKING_STEPS): TrackingProgress {
  const sorted = sortTrackingEvents(events, steps)
  const reachedAt: TrackingProgress["reachedAt"] = {}
  let current = -1
  let issue: TrackingEvent | null = null
  for (const event of sorted) {
    if (event.status === "exception") {
      issue = event
      continue
    }
    const index = steps.findIndex((s) => s.status === event.status)
    if (index === -1) continue
    reachedAt[event.status] ??= event.at
    // The carrier's latest word is the truth — even a step back, after a re-route.
    current = index
    issue = null
  }
  return { current, reachedAt, issue, delivered: steps[current]?.status === "delivered" }
}