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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/order-tracking.jsonPrompt
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
nowthe 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.DateTimeFormatparts 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 ?? earliestis 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.
Streak Tracker
A habit-streak card — the live run with a tiered flame, this week as seven day cells, a spendable freeze wallet and an at-risk countdown, all derived from an injected instant.
Live Cursors
A multiplayer cursor overlay — labelled peer pointers interpolated between low-rate updates, chart-token colour per peer, idle fade, relayed click ripples and a spoken roster, with the transport left to you.