Shipment tracker
A shipment's steps as an ordered list, the current one marked with aria-current="step" and every step's status said in words, a column on a narrow card and a row on a wide one. A server composition; it ships no JavaScript.
From Figma 05 Commerce, Shipment Tracker (7275:14195; Packed 7275:14196, Shipped 7275:14224,
Delivered 7275:14252). Uses Card and
Text, with the Receipt, Package, Truck and House icons from
@nizivo/icons. There is no Steps component, so the steps live in this pattern's
stylesheet.
Examples
In transit
Figma 05's Shipment Tracker (7275:14195) at Step=Shipped. There is no Steps component, so the tracker is an <ol> in the pattern's own stylesheet: the current step carries aria-current="step", and every step says its status in words. It is a column on a narrow container and a row from 34rem, and its line follows the reading direction. A server composition: it ships no JavaScript.
Arriving Saturday, October 17
Order #1042
- Ordered, Completed
- Packed, Completed
- Shipped, Current step
- Delivered, Not started
import type { JSX, ReactNode } from 'react';
import { House, Package, Receipt, Truck } from '@nizivo/icons';
import { Card } from '@nizivo/react/card';
import { Text } from '@nizivo/react/text';
type StepStatus = 'complete' | 'current' | 'upcoming';
/** The words a screen reader hears for each status; sighted users also see the node and the weight. */
const STATUS_TEXT: Record<StepStatus, string> = {
complete: 'Completed',
current: 'Current step',
upcoming: 'Not started',
};
// An explicit locale and time zone, so the server and the browser print the same text. An app
// passes the customer's time zone.
const WHEN = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: 'UTC',
});
const DAY = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
interface Step {
label: string;
icon: ReactNode;
}
/** The four steps Figma's doc text names: Ordered, Packed, Shipped and Delivered. */
const STEPS: Step[] = [
{ label: 'Ordered', icon: <Receipt /> },
{ label: 'Packed', icon: <Package /> },
{ label: 'Shipped', icon: <Truck /> },
{ label: 'Delivered', icon: <House /> },
];
interface Shipment {
order: string;
/** The index of the step the shipment is at. Earlier steps are complete, later ones upcoming. */
current: number;
/** When each step happened, as UTC instants, for the steps reached so far. */
times: string[];
/** The expected delivery day, a UTC instant. */
expected: string;
}
/** One step: its node, its name, its status in words and, once reached, when it happened. */
function StepItem({
step,
status,
at,
}: {
step: Step;
status: StepStatus;
at?: string | undefined;
}): JSX.Element {
return (
<li
className={`nzp-shipment-tracker-step nzp-shipment-tracker-step-${status}`}
aria-current={status === 'current' ? 'step' : undefined}
>
<span className="nzp-shipment-tracker-node">{step.icon}</span>
<span className="nzp-shipment-tracker-text">
<Text render={<span />} variant={status === 'current' ? 'label-m-strong' : 'label-m'}>
{step.label}
<span className="nz-visually-hidden">{`, ${STATUS_TEXT[status]}`}</span>
</Text>
{at === undefined ? null : (
<Text render={<time dateTime={at} />} tone="secondary">
{WHEN.format(new Date(at))}
</Text>
)}
</span>
</li>
);
}
/** A shipment's card: when it should arrive, then its steps as an ordered list. */
function ShipmentTracker({
shipment,
delivered,
}: {
shipment: Shipment;
delivered: boolean;
}): JSX.Element {
const day = DAY.format(new Date(shipment.expected));
return (
<Card.Root
className="nzp-shipment-tracker"
render={<section aria-label={`Shipment for order #${shipment.order}`} />}
>
<div>
<Card.Title>{delivered ? `Delivered ${day}` : `Arriving ${day}`}</Card.Title>
<Text tone="secondary">{`Order #${shipment.order}`}</Text>
</div>
<ol className="nzp-shipment-tracker-steps" aria-label="Progress">
{STEPS.map((step, index) => (
<StepItem
key={step.label}
step={step}
status={
index < shipment.current
? 'complete'
: index === shipment.current
? 'current'
: 'upcoming'
}
at={shipment.times[index]}
/>
))}
</ol>
</Card.Root>
);
}
export function ShipmentTrackerExample(): JSX.Element {
return (
<ShipmentTracker
delivered={false}
shipment={{
order: '1042',
current: 2,
times: ['2026-10-14T09:12:00Z', '2026-10-14T15:40:00Z', '2026-10-15T08:05:00Z'],
expected: '2026-10-17T00:00:00Z',
}}
/>
);
}
Delivered
Step=Delivered: every step reached, the last one current, as Figma draws it.
Delivered Monday, October 12
Order #1038
- Ordered, Completed
- Packed, Completed
- Shipped, Completed
- Delivered, Current step
import type { JSX, ReactNode } from 'react';
import { House, Package, Receipt, Truck } from '@nizivo/icons';
import { Card } from '@nizivo/react/card';
import { Text } from '@nizivo/react/text';
type StepStatus = 'complete' | 'current' | 'upcoming';
/** The words a screen reader hears for each status; sighted users also see the node and the weight. */
const STATUS_TEXT: Record<StepStatus, string> = {
complete: 'Completed',
current: 'Current step',
upcoming: 'Not started',
};
// An explicit locale and time zone, so the server and the browser print the same text. An app
// passes the customer's time zone.
const WHEN = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: 'UTC',
});
const DAY = new Intl.DateTimeFormat('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
});
interface Step {
label: string;
icon: ReactNode;
}
/** The four steps Figma's doc text names: Ordered, Packed, Shipped and Delivered. */
const STEPS: Step[] = [
{ label: 'Ordered', icon: <Receipt /> },
{ label: 'Packed', icon: <Package /> },
{ label: 'Shipped', icon: <Truck /> },
{ label: 'Delivered', icon: <House /> },
];
interface Shipment {
order: string;
/** The index of the step the shipment is at. Earlier steps are complete, later ones upcoming. */
current: number;
/** When each step happened, as UTC instants, for the steps reached so far. */
times: string[];
/** The expected delivery day, a UTC instant. */
expected: string;
}
/** One step: its node, its name, its status in words and, once reached, when it happened. */
function StepItem({
step,
status,
at,
}: {
step: Step;
status: StepStatus;
at?: string | undefined;
}): JSX.Element {
return (
<li
className={`nzp-shipment-tracker-step nzp-shipment-tracker-step-${status}`}
aria-current={status === 'current' ? 'step' : undefined}
>
<span className="nzp-shipment-tracker-node">{step.icon}</span>
<span className="nzp-shipment-tracker-text">
<Text render={<span />} variant={status === 'current' ? 'label-m-strong' : 'label-m'}>
{step.label}
<span className="nz-visually-hidden">{`, ${STATUS_TEXT[status]}`}</span>
</Text>
{at === undefined ? null : (
<Text render={<time dateTime={at} />} tone="secondary">
{WHEN.format(new Date(at))}
</Text>
)}
</span>
</li>
);
}
/** A shipment's card: when it should arrive, then its steps as an ordered list. */
function ShipmentTracker({
shipment,
delivered,
}: {
shipment: Shipment;
delivered: boolean;
}): JSX.Element {
const day = DAY.format(new Date(shipment.expected));
return (
<Card.Root
className="nzp-shipment-tracker"
render={<section aria-label={`Shipment for order #${shipment.order}`} />}
>
<div>
<Card.Title>{delivered ? `Delivered ${day}` : `Arriving ${day}`}</Card.Title>
<Text tone="secondary">{`Order #${shipment.order}`}</Text>
</div>
<ol className="nzp-shipment-tracker-steps" aria-label="Progress">
{STEPS.map((step, index) => (
<StepItem
key={step.label}
step={step}
status={
index < shipment.current
? 'complete'
: index === shipment.current
? 'current'
: 'upcoming'
}
at={shipment.times[index]}
/>
))}
</ol>
</Card.Root>
);
}
export function ShipmentTrackerDeliveredExample(): JSX.Element {
return (
<ShipmentTracker
delivered
shipment={{
order: '1038',
current: 3,
times: [
'2026-10-09T11:20:00Z',
'2026-10-09T16:02:00Z',
'2026-10-10T07:45:00Z',
'2026-10-12T13:31:00Z',
],
expected: '2026-10-12T00:00:00Z',
}}
/>
);
}
Stylesheet
@layer nz.tokens, nz.themes, nz.reset, nz.base, nz.components, nz.patterns, nz.utilities;
@layer nz.patterns {
/* the steps lay out by the card's width, not the viewport's: a column, then a row from 34rem */
.nzp-shipment-tracker {
container-type: inline-size;
}
.nzp-shipment-tracker-steps {
display: flex;
flex-direction: column;
gap: var(--nz-space-4);
margin: 0;
padding: 0;
list-style: none;
}
.nzp-shipment-tracker-step {
position: relative;
display: flex;
align-items: start;
gap: var(--nz-space-3);
}
/* the line to the next step, down the node's centre; logical insets, so it follows the reading
direction */
.nzp-shipment-tracker-step:not(:last-child)::after {
position: absolute;
inset-block: calc(2.25rem + var(--nz-space-1)) calc(var(--nz-space-1) - var(--nz-space-4));
inset-inline-start: calc(1.125rem - var(--nz-border-width-focus) / 2);
inline-size: var(--nz-border-width-focus);
border-radius: var(--nz-radius-full);
background: var(--nz-border-default);
content: '';
}
/* a completed step's line leads to a step that has been reached */
.nzp-shipment-tracker-step-complete:not(:last-child)::after {
background: var(--nz-border-brand);
}
.nzp-shipment-tracker-node {
display: grid;
flex: none;
place-items: center;
inline-size: 2.25rem;
block-size: 2.25rem;
border: var(--nz-border-width-default) solid transparent;
border-radius: var(--nz-radius-full);
font-size: var(--nz-size-icon-md);
}
.nzp-shipment-tracker-step-complete .nzp-shipment-tracker-node {
background: var(--nz-bg-brand-subtle);
color: var(--nz-icon-brand);
}
.nzp-shipment-tracker-step-current .nzp-shipment-tracker-node {
background: var(--nz-bg-brand);
color: var(--nz-text-on-brand);
}
/* Figma's upcoming node, bg/subtle, is 1.04:1 on white: a dashed ring shows it */
.nzp-shipment-tracker-step-upcoming .nzp-shipment-tracker-node {
border-style: dashed;
border-color: var(--nz-border-strong);
color: var(--nz-icon-secondary);
}
.nzp-shipment-tracker-text {
display: flex;
flex-direction: column;
gap: var(--nz-space-0-5);
padding-block-start: var(--nz-space-2);
}
.nzp-shipment-tracker-step-upcoming .nzp-shipment-tracker-text {
color: var(--nz-text-secondary);
}
@container (width >= 34rem) {
.nzp-shipment-tracker-steps {
flex-direction: row;
gap: 0;
}
.nzp-shipment-tracker-step {
flex: 1;
flex-direction: column;
gap: var(--nz-space-2);
min-inline-size: 0;
padding-inline-end: var(--nz-space-2);
}
/* the line to the next step, along the node's centre */
.nzp-shipment-tracker-step:not(:last-child)::after {
inset-block: calc(1.125rem - var(--nz-border-width-focus) / 2) auto;
inset-inline: calc(2.25rem + var(--nz-space-2)) var(--nz-space-2);
inline-size: auto;
block-size: var(--nz-border-width-focus);
}
.nzp-shipment-tracker-text {
padding-block-start: 0;
}
}
}
Notes
- An ordered list. The steps are an
<ol>named "Progress"; the current step carriesaria-current="step", and every step's name is followed by its status in visually hidden words ("Completed", "Current step", "Not started"). The icons and the lines between the nodes are decorative. - Not colour alone. The current step is bold on a solid node, a completed step's node is
tinted and its line is green, and an upcoming step has a dashed ring: Figma's upcoming node,
bg/subtle, is 1.04:1 on white and all but invisible. - Four steps. Figma's doc text names four steps but its Step axis has three (no Ordered); here Ordered is the first step. Completed steps carry their date and time, as Figma draws them; an upcoming step carries none, where Figma prints a dash a screen reader would read.
- Narrow and wide. Figma's four 150 px columns do not fit a phone and overflow in longer languages. The tracker is a column until its card is 34rem wide (a container query, so it follows the space it is given, not the viewport), then a row.
- RTL. The lines are placed with logical properties, so the row and its lines run right to left. The truck is not mirrored: it is not a directional icon.
- Dates.
Intl.DateTimeFormatwith an explicit locale and time zone, inside<time dateTime>; an app passes the customer's time zone.
Payment method
A choice of saved cards, one ChoiceCard of a native radio group each, the brand in words, the last four digits, the expiry spoken as a month and year, and the default marked with a Badge.
Plan card
A subscription plan with its name, its price per interval, its features and a link to start, with a featured plan, and a row of plans with a monthly or yearly price.