Journey Timeline

A vertical stepper that shows where someone is in a multi-stage process — completed stages, the current stage, and what's still ahead. Reusable for application journeys, onboarding checklists, order tracking, or any linear, ordered flow.

Preview

📨
Applied
✓
📞
Phone Screen
✓
🎤
InterviewCurrent stage
🎉
OfferTap to update

Anatomy

Each row shares the same four parts. The row itself, not the connecting line between rows, carries the meaning — the line is decorative.

PartPurpose
Icon circleFilled with the accent color for completed/active stages; a neutral tint for upcoming ones. Icon communicates the stage's meaning, not just its position.
Stage labelThe stage name. Bold for the active stage; secondary weight for completed; lowest-emphasis tertiary color for upcoming — this weight ramp is what lets someone scan the whole list and immediately spot 'where am I' without reading every line.
Hint textOne line, shown only on the active stage by default ('Current stage') or an upcoming stage with an available action ('Tap to update'). Omit for completed stages — they don't need a call to action.
Trailing statusA checkmark for completed stages, a small filled dot for the active stage, or nothing for upcoming. Never the only signal of state — label weight and icon fill must also change.

When to use

✓

Linear, ordered stages

The process has a fixed, known sequence — applied → screen → interview → offer, or order placed → shipped → delivered. Each stage happens after the last.

✓

Small, bounded stage count

Roughly 3–7 stages. Below that, a simple progress bar communicates the same thing with less visual weight; above it, consider grouping into phases.

✗

Non-linear or parallel states

If items can be in multiple states at once, or move backward and sideways (not just forward), use a Kanban board instead — a timeline implies a single forward path.

✗

Continuous or percentage-based progress

A file upload or a habit-completion streak doesn't have discrete named stages — use a progress bar or the Goal & Streak pattern instead.

Implementation

TimelineRow.tsx
tsx
type StageState = "completed" | "active" | "upcoming";

interface TimelineRowProps {
  icon: React.ReactNode;
  label: string;
  state: StageState;
  hint?: string;
  trailing?: React.ReactNode;
}

// A single stage in a vertical stepper timeline: status icon, stage label,
// current-state hint text, and an optional trailing status glyph.
export function TimelineRow({ icon, label, state, hint, trailing }: TimelineRowProps) {
  const isFilled = state === "completed" || state === "active";

  return (
    <div
      className="flex items-center gap-4 py-2.5"
      role="listitem"
      aria-current={state === "active" ? "step" : undefined}
    >
      <div
        className={`flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-full ${
          isFilled ? "bg-[rgb(var(--accent))] text-white" : "bg-[rgb(var(--text-primary)/0.08)] text-[rgb(var(--text-secondary))]"
        }`}
      >
        {icon}
      </div>

      <div className="flex flex-col gap-0.5">
        <span
          className={`text-[15px] ${
            state === "active"
              ? "font-bold text-[rgb(var(--text-primary))]"
              : state === "completed"
                ? "font-normal text-[rgb(var(--text-secondary))]"
                : "font-normal text-[rgb(var(--text-tertiary))]"
          }`}
        >
          {label}
        </span>
        {hint && (
          <span
            className={`text-[12px] ${
              state === "active" ? "text-[rgb(var(--accent))]" : "text-[rgb(var(--text-tertiary))]"
            }`}
          >
            {hint}
          </span>
        )}
      </div>

      <div className="ml-auto">{trailing}</div>
    </div>
  );
}

Accessibility

  • →Group each row's icon, label, and hint into a single accessibility element (aria via a wrapping container role, or .accessibilityElement(children: .combine) in SwiftUI) so VoiceOver/TalkBack announce one coherent stop per stage, not four fragments.
  • →Mark the active stage with aria-current="step" (web) or an equivalent selected trait (SwiftUI) — don't rely on color or font weight alone to convey which stage is current.
  • →State (completed/active/upcoming) must be distinguishable without color: pair it with the trailing glyph (✓, dot) and label copy ('Current stage'), since color-only differences fail for colorblind users and in monochrome contexts.
  • →If a row is tappable (e.g. to log an update), give it an accessible label that states the stage name and its state together: 'Interview, current stage' — not just 'Interview'.
  • →Wrap the whole sequence in role="list" and each row in role="listitem" on the web so assistive tech announces the row count and position ('item 3 of 4').