Display

Order Tracking

A shipment tracking card — a four-stage rail, an ETA derived from an injected instant, exceptions that interrupt without colour alone, per-parcel tabs and a copyable tracking number.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  ChevronDown,
  Clock,
  Copy,
  ExternalLink,
  MapPin,
  Package,
  PackageCheck,
  TriangleAlert,
  Truck,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/order-tracking.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "OrderTracking" component: the shipment
card on an order detail page. lucide-react for icons, zod for the contract, cn()
(clsx + tailwind-merge) for classes, no other dependency. The reader can only
watch — every value on the card is a fact the carrier reported.

Contract
- One zod file is the single source of truth; the props type is z.infer of it,
  never a hand-written parallel interface.
  Stage = "ordered" | "shipped" | "out_for_delivery" | "delivered". The INDEX in
  that array is the semantics: "has it got past here" is index arithmetic, so the
  array is never reordered.
  Event = { id; occurredAt (ISO 8601); description; location?; stage;
  kind?: "scan" | "exception" }. `stage` is the SCAN's own stage, not the
  parcel's — a failed attempt sends a parcel back to the depot, so a "shipped"
  scan can legally arrive after an "out_for_delivery" one.
  Exception = { kind: "failed_attempt" | "customs_hold" | "address_issue" |
  "damaged" | "delayed"; message; since?; resolvedAt?; action?: { label; href } }.
  `resolvedAt` present = the exception is history.
  Eta = { earliest; latest? } — two instants describe a window, one describes a
  promise.
  Carrier = { name; trackingNumber; trackingUrl? }.
  Parcel = { id; label?; carrier; stage; eta: Eta | null; exception?; events[] }.
  `eta` is a REQUIRED key with a nullable value: null states out loud that the
  carrier committed to nothing, which a missing field could never distinguish
  from "the estimate has not loaded yet".
  Envelope = { status: "loading" | "empty" | "error" | "ready"; parcels[] }.
- export const OrderTracking = React.forwardRef<HTMLDivElement, OrderTrackingProps>,
  remaining props spread onto the root <div>. On top of the contract:
  now: string | number | Date (REQUIRED); activeParcelId / defaultActiveParcelId /
  onActiveParcelChange; locale (default "en-US"); timeZone (default "UTC");
  label (default "Order tracking"); exceptionLabels?: Partial<Record<kind,string>>;
  onRetry?; errorMessage?; collapseAfter (default 5); skeletonEvents (default 3);
  copyResetMs (default 3000); className.
- `now` is required on purpose. Every relative day, the ETA sentence and the
  overdue verdict are measured against it, so the card is a pure function of its
  props: no Date.now() during render, no interval inside the component. The host
  decides whether to freeze it (tests, SSR, screenshots) or tick it.

Behavior
- Four first-class branches, not `&&` afterthoughts. loading = a skeleton with
  the geometry of a real card (rail, ETA line, N scan rows) plus aria-busy on the
  root. empty = "nothing has been handed to a carrier yet". error = one sentence
  plus a Try again button that exists ONLY when onRetry was passed. ready = the
  card. status="ready" with zero parcels is its own honest sentence ("ready to
  track, but no parcel arrived with it") rather than being folded into empty.
- De-duplication, twice. Parcels with a repeated id: the second copy is dropped,
  because two tabs keyed identically would point one aria-controls at two panels.
  Events with a repeated id: same rule, first occurrence wins — a carrier
  replaying its feed is the normal way duplicates arrive.
- Event order: sort newest first by Date.parse(occurredAt); rows whose timestamp
  does not parse sink below every dated row and keep their incoming order, since
  guessing a position for them would be inventing a timeline. THEN group by the
  scan's own stage and render the groups latest-stage-first under stage headers.
- Truncation is honest and computed in DISPLAY order (groups flattened), not in
  time order — the cut must append at the bottom of what is on screen, never
  inject a row into a group above. The toggle reads "Show 3 earlier scans" /
  "Show fewer scans", carries aria-expanded + aria-controls, and stays mounted
  across the toggle so focus never drops. collapseAfter of 0 or Infinity shows
  everything.
- The rail. reached = STAGES.indexOf(parcel.stage). Node i is done when
  i < reached, current when i === reached, upcoming when i > reached. The parcel's
  own `stage` drives it — never the events, which can move backwards. Each
  segment is decided by ONE rule keyed on the node to its left, so a segment can
  never be solid on one side of a node and dashed on the other.
- ETA maths, all against the injected now:
  * stage === "delivered" -> "Delivered <day>", where <day> comes from the newest
    parseable "delivered" scan; with none, just "Delivered".
  * eta === null, or neither edge parses -> "No delivery estimate yet".
  * far edge = latest ?? earliest. Lateness is measured against the FAR edge,
    because a parcel still inside its own Tue-Thu window is not late on Wednesday.
  * far < now -> lateDays = civilDay(now) - civilDay(far); >= 1 gives
    "Overdue by 2 days", 0 gives "Overdue - the window closed at 13:20".
  * otherwise "Arrives <day>, 09:00-13:00" when both edges fall on the same
    calendar day, "Arrives tomorrow - Thursday" when they do not, and
    "Arrives tomorrow, 14:30" for a single-instant promise.
  * <day> is "today" / "tomorrow" / "yesterday", then a weekday name inside the
    coming week, then "12 May". The comparison is a CIVIL DAY index, not a
    24-hour subtraction: 23:30 tonight and 00:30 tomorrow are one hour apart and
    still two different days. Read year/month/day through
    Intl.DateTimeFormat.formatToParts in the display zone and rebuild the index
    with Date.UTC — pin that one formatter to "en-US" so a locale with its own
    numbering system cannot hand back non-ASCII digits.
- Exceptions interrupt without colour alone: an open one gets a triangle-alert
  icon, the KIND SPELLED OUT ("Delivery attempt failed"), the message, when it was
  raised, an optional real <a> for the next step, a left border, AND it dashes the
  rail segment straight after the current stage. A resolved one keeps the same
  slot but flips to a check plus "· Resolved" and leaves the rail solid — the ETA
  is trusted again. Exception SCANS in the thread carry the same icon and the
  literal word "Exception" before the description.
- Multiple parcels become a tablist; ONE parcel renders no tab layer at all,
  because a tabpanel with no tab is a lie. With nothing requested, the parcel
  carrying an open exception opens first — that is the one being looked for. An
  activeParcelId that no longer exists resolves to a real parcel instead of an
  empty panel. Controlled and uncontrolled both supported.
- Copy: navigator.clipboard.writeText behind try/catch (an insecure origin or a
  denied permission is a normal Tuesday), reported as icon + the word "Copied" /
  "Copy failed" and announced in a polite live region. An attempt counter is read
  AND written synchronously in the handler, so a second press while the first
  write is in flight settles exactly once; the reset timer is cleared on the new
  attempt and on unmount, and a mounted flag set in the effect BODY (not only in
  cleanup) keeps it working under StrictMode's mount-cleanup-mount. Copy state is
  stored WITH its parcel id, so switching tabs can never show another parcel's
  "Copied".
- Keyboard map. Tabs: Left / Right wrap through the parcels, Home / End jump to
  the ends, and selection follows focus (the panel is already rendered, so manual
  activation would only add a keystroke). Roving tabindex — the strip is one tab
  stop. Everything else is a real button or a real link, so Tab / Enter / Space
  work by default. Nothing here is a gesture, so there is nothing to provide a
  keyboard equivalent for.
- ARIA contract. Root: role="group" + aria-labelledby pointing at the visible
  title, aria-busy while loading, tabIndex={-1} so it can be the focus successor.
  Tabs: role="tablist" / role="tab" with aria-selected + aria-controls, the panel
  role="tabpanel" + aria-labelledby; each tab appends an sr-only ", <stage>" and
  ", exception raised". Rail: <ol> with <li> as direct children,
  aria-current="step" on the current node, and every node appends an sr-only
  ", completed" / ", current stage" / ", not reached yet" — the shapes are never
  the only carrier of state. Each stage group is a role="group" +
  aria-labelledby its header (NOT a landmark region, which would spam the
  landmark list). Icons are aria-hidden without exception. ONE polite live region,
  mounted from the first render, carries the copy result and the loading sentence
  — a region created at the same moment as its text is usually not announced.
- Focus discipline. The Try again button unmounts itself the moment the host
  flips to "loading", so the handler focuses the card root FIRST and calls onRetry
  second: focus lands on something that still names what is being loaded, never on
  <body>. No control is ever natively `disabled`.
- Edge cases that must not crash or lie: an unparseable occurredAt renders raw
  and outside <time> (whose dateTime must parse); an unparseable ETA reads as "no
  estimate", never NaN; an invalid locale or IANA zone falls back to en-US/UTC
  inside a try/catch around the Intl constructors; a missing parcel label falls
  back to "Parcel 1", "Parcel 2"; a blank-whitespace label counts as missing; a
  44-character tracking number wraps (break-all) instead of overflowing the card;
  a parcel with no scans says so instead of rendering an empty list.
- Cleanup: the copy reset timer is the only timer, and it is cleared on unmount
  and superseded on every new attempt. No interval, no rAF, no listener, no
  observer.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground (the card), bg-primary +
  text-primary-foreground (completed rail nodes), border-primary (current node),
  border-dashed + border-muted-foreground/40 (upcoming), text-destructive +
  bg-destructive/5 + border-destructive (open exception, overdue ETA, exception
  scans), bg-muted/40 (carrier bar, resolved exception), text-muted-foreground
  (meta lines), ring-ring for every focus-visible ring. No hex, no rgb(), no
  oklch(), no chart tokens on text.
- Every className goes through cn() so a consumer's className merges rather than
  fights.
- Motion budget is two effects: a ping halo on the current rail node and a
  chevron rotation on the disclosure, both motion-reduce-disabled, plus the
  skeleton pulse which stops under motion-reduce:animate-none. Nothing about the
  card depends on animation.
- Narrow-card discipline: the rail is four flex-1 columns whose labels wrap
  (break-words, leading-tight), long descriptions wrap, locations truncate, and
  the whole card is min-w-0 — it stays readable at ~20rem, which is what lets a
  demo grid auto-fit it without squeezing.
- "use client" is required: state, refs, effects and the clipboard.

Customization levers
- Stage vocabulary: STAGES is the running order and STAGE_TEXT the words. Add
  "returned" or "at_pickup_point" at either END of STAGES (never in the middle —
  the index is the semantics) and the rail, the grouping and the comparisons all
  follow. STAGE_ICON is a lookup table: swap the warehouse for a plane, or use one
  neutral dot for every stage.
- Exception wording: pass exceptionLabels to translate or re-voice any kind
  without touching the component; EXCEPTION_TEXT stays the fallback.
- Time display: replace the Intl bag in makeFormatters for a different date
  style, and dayText for a different relative vocabulary ("in 2 days" instead of
  a weekday). WEEKDAY_HORIZON decides how far ahead a weekday name stays
  unambiguous.
- Density: collapseAfter tunes how much thread is visible before the cut, and
  skeletonEvents matches the skeleton to your typical parcel. Drop the rail
  entirely for a compact list row, or drop the events and keep the rail plus the
  ETA line for an order-list card.
- Zone and locale: timeZone defaults to UTC so server and client cannot disagree;
  pass the customer's zone once the request knows it, and locale alongside it.
- Composition: the tracking number is rendered as a link only when the carrier
  supplies trackingUrl, and the exception's next step only when `action` is
  present — both destinations stay the consumer's, so nothing here is a fake
  affordance.

Concepts

  • Carrier scan thread — the events are a log, not a state machine: each scan files under its OWN stage, so a failed attempt that sends a parcel back to the depot appears under “Shipped” after an “Out for delivery” scan without ever dragging the rail backwards.
  • Injected instant — “tomorrow”, “Overdue by 2 days” and every relative day are measured against a now the host passes in. The component reads no clock and starts no interval, which is what makes the same props render the same card in a test, a screenshot and an SSR pass.
  • Civil-day comparison — lateness and “tomorrow” are calendar questions, not 24-hour subtractions. The instant is turned into a day index through Intl.DateTimeFormat parts in the display zone, so 23:30 tonight and 00:30 an hour later are correctly two different days.
  • Far-edge lateness — a delivery window is a promise about its LATE end. Comparing against latest ?? earliest is why a parcel inside its own Tue–Thu window is not painted as overdue on Wednesday.
  • Exception interrupt — an unresolved exception is announced by an icon, the kind spelled out, a left border and a dashed rail segment; a resolved one keeps the slot but flips to a check and lets the ETA be trusted again. Colour is the last of four signals, never the only one.
  • Per-parcel state keying — “Copied” and “scans expanded” are stored with the parcel id they belong to, so switching tabs cannot show another parcel’s confirmation, and no effect is needed to reset anything.

On This Page