Skip to content
Nizivo UI

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

  1. Ordered, Completed
  2. Packed, Completed
  3. Shipped, Current step
  4. 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

  1. Ordered, Completed
  2. Packed, Completed
  3. Shipped, Completed
  4. 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

shipment-tracker.css
@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 carries aria-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.DateTimeFormat with an explicit locale and time zone, inside <time dateTime>; an app passes the customer's time zone.

On this page