JourneyTimelinebeta
A retrospective timeline that holds one event in focus with rich detail and keeps the rest as quiet context along a time axis.
Playground
- 3 Jun2 days · validation
- 5 Junmanual review
- 6 Jun
Full record for “Statements” renders here while the node is in focus.
-
<script lang="ts">
import { JourneyTimeline } from '@urbicon-ui/blocks';
const items = [
{ id: 'readings', title: 'Meter readings', status: 'complete', subtitle: 'All units collected', meta: '3 Jun', segmentLabel: '2 days · validation' },
{ id: 'validate', title: 'Validation', status: 'complete', subtitle: 'Anomalies resolved', meta: '5 Jun', connector: 'dashed', segmentLabel: 'manual review' },
{ id: 'statements', title: 'Statements', status: 'active', subtitle: 'Generating documents', meta: '6 Jun' },
{ id: 'dispatch', title: 'Dispatch', status: 'pending', subtitle: 'Email + postal' }
];
</script>
<JourneyTimeline
{items}
>
{#snippet node(item)}
<p class="text-text-secondary text-sm">
Full record for “{item.title}” renders here while the node is in focus.
</p>
{/snippet}
</JourneyTimeline>01 Examples
Each node needs an id, a title and a status. subtitle, meta (its label on the time axis), connector and segmentLabel are optional. One focusable node holds focus
at a time and renders its detail through the node snippet, starting at defaultFocusId or the first active node and readable or drivable with bind:focusId.
Vertical chronicle with inline detail
- 3 Jun 3 Jun2 days · validation
- 5 Jun 6 Junmanual review
- 6 Jun
Full record for Statements: assignments, anomalies and the audit trail live here.
dispatch queue
<div class="flex w-full max-w-lg flex-col gap-3">
<div class="flex items-center gap-2">
<span class="text-text-tertiary text-xs font-medium">Focused node:</span>
<Badge size="xs" intent="primary" variant="soft">{focusId}</Badge>
</div>
<JourneyTimeline items={billing} bind:focusId>
{#snippet meta(item)}
<div class="flex flex-col items-end gap-1">
{#if item.meta}
<span class="text-text-tertiary font-mono text-xs tabular-nums">{item.meta}</span>
{/if}
{#if actuals[item.id]}
<Badge size="xs" intent="success" variant="soft">{actuals[item.id]}</Badge>
{/if}
</div>
{/snippet}
{#snippet node(item)}
<div class="flex flex-col gap-2 py-0.5">
<p class="text-text-secondary text-sm">
Full record for <strong>{item.title}</strong>: assignments, anomalies and the audit
trail live here.
</p>
<div>
<Button size="sm" variant="outlined">Open {item.title.toLowerCase()}</Button>
</div>
</div>
{/snippet}
</JourneyTimeline>
</div>Stable readout with detail=panel
- Mon
- Tue
- Wedcustoms clearance
-
-
In transit
Scans, carrier and location history for this stage.
customs clearance<div class="w-full">
<JourneyTimeline items={shipment} detail="panel">
{#snippet node(item)}
<div class="flex flex-col items-start gap-2">
<p class="text-text-primary text-sm font-medium">{item.title}</p>
<p class="text-text-secondary text-sm">
Scans, carrier and location history for this stage.
</p>
{#if item.segmentLabel}
<Badge size="xs" intent="neutral" variant="soft">{item.segmentLabel}</Badge>
{/if}
</div>
{/snippet}
</JourneyTimeline>
</div>Horizontal lifecycle
- 1 Mar
- 5 Mar
- 19 Mar
Invoice events during “Open”: issued, reminders, payments.
<div class="w-full">
<JourneyTimeline items={lifecycle} orientation="horizontal">
{#snippet node(item)}
<p class="text-text-secondary text-sm">
Invoice events during “{item.title}”: issued, reminders, payments.
</p>
{/snippet}
</JourneyTimeline>
</div>Cockpit rows: glyph markers, attention and trailing
No help requested yet.
- ✓
- ▲must · blocks close
- ·Consumption Waits for heating Pending
- ▸
Why “Expenses & distribution” matters, its consequences and the next action live here.
- ○optional
<div class="flex w-full max-w-lg flex-col gap-3">
<p class="text-text-tertiary text-xs" aria-live="polite">
{lastHelp ? `Help requested for “${lastHelp}”.` : 'No help requested yet.'}
</p>
<JourneyTimeline
items={cockpit}
slotClasses={{ marker: 'size-5 mt-1.5', markerColumn: 'w-5' }}
>
{#snippet marker(item)}
<span class={['text-2xs leading-none font-bold', glyphTone[item.status]]}>
{glyphs[item.status]}
</span>
{/snippet}
{#snippet trailing(item)}
{#if item.status === 'blocked'}
<Badge size="xs" intent="danger" variant="soft">must · blocks close</Badge>
{:else if item.status === 'attention'}
<Badge size="xs" intent="warning" variant="soft">optional</Badge>
{/if}
{#if item.focusable !== false}
<Button size="xs" variant="ghost" onclick={() => (lastHelp = item.title)}>?</Button>
{/if}
{/snippet}
{#snippet node(item)}
<p class="text-text-secondary text-sm">
Why “{item.title}” matters, its consequences and the next action live here.
</p>
{/snippet}
</JourneyTimeline>
</div>02 Statuses
Each node's status sets its marker and its title tone:
status | Marker | Meaning |
|---|---|---|
complete | Filled success dot | Done. The connector leaving it reads as “travelled”. |
active | Ringed primary dot | The step in progress right now. |
pending | Hollow dot | Work not yet started. |
attention | Hollow warning dot | Worth a look, but it does not block. |
blocked | Danger dot, and the title turns danger too | A hard stop, so colour is never the only cue. |
skipped | Muted dot | Not applicable to this run. |
Set focusable: false for pure waypoints: they render a marker and label but never take
focus and are skipped by keyboard navigation.
All statuses
-
-
This node is
active. Its detail renders only while focused. - Automatic hold System step Pending
-
-
-
-
<div class="w-full max-w-lg">
<JourneyTimeline items={statuses} defaultFocusId="s-active">
{#snippet node(item)}
<p class="text-text-secondary text-sm">
This node is <code>{item.status}</code>. Its detail renders only while focused.
</p>
{/snippet}
</JourneyTimeline>
</div>03 JourneyTimeline vs. Stepper vs. Tab
Three components, three different jobs, and the overlap is smaller than it looks:
| Component | Its job | Reach for it when |
|---|---|---|
| JourneyTimeline | Retrospective observation | An ordered record of what happened or where things stand: shipment tracking, audit
trails, billing runs, travel logs. Time (meta)
is the first axis, and connectors and segment labels describe the stretches between
events. Focus reveals detail. It never advances a process. |
| Stepper | Prospective process | A wizard the user walks through: checkout, onboarding, multi-step forms. Steps are tasks to complete (often with embedded inputs), progress moves forward, and the component may gate navigation. No time axis. The user is the timeline. |
| Tab | Peer views | Unordered, equivalent surfaces of one thing: Account, Billing, Team. No sequence, no status, no chronology. If reordering the items would change their meaning, they are not tabs. |
Rule of thumb: does each item carry a status and does their order tell a story? JourneyTimeline. Does the user complete the items one by one? Stepper. Are the items just alternative views? Tab.
04 Customization
A primary-accented readout
Comments, reviewers and version history for “In review” live here.
<div class="w-full">
<JourneyTimeline
items={approval}
detail="panel"
slotClasses={{
marker: 'ring-2 ring-primary/30 ring-offset-2 ring-offset-surface-base',
panel: 'bg-surface-selected border-primary/40'
}}
>
{#snippet node(item)}
<p class="text-text-secondary text-sm">
Comments, reviewers and version history for “{item.title}” live here.
</p>
{/snippet}
</JourneyTimeline>
</div>This is one of five ways to restyle a block. See Customization for class, slotClasses, unstyled, preset and provider-level overrides.
05 Accessibility
Structure and ARIA
The rail is an ordered list. Each node carries aria-current="step" while its status is active. The focusable trigger exposes aria-expanded and aria-controls for its detail region (a per-node
inline region, or the shared panel in panel/horizontal mode). The status is announced
through a visually-hidden label, so the dot markers stay decorative, including any glyphs
rendered through the marker snippet.
Trailing content stays outside the trigger
trailing content renders outside the trigger button,
as a sibling in the header row: buttons and links inside it are valid HTML, become regular tab
stops after the header, and activating them never changes the focused node. Arrow-key roving stays
on the node headers only.
Keyboard
- ↑ / ↓ (vertical) or ← / → (horizontal) move the roving focus between node headers without changing the focused node.
- Home / End jump to the first / last focusable node.
- Enter / Space put the header's node in focus.
Motion
Expand/collapse runs on the motion-duration tokens, which collapse to 1 ms under prefers-reduced-motion: reduce. When activating a
node makes another card collapse above it, the component counter-scrolls so the activated
header stays visually stationary. Real user scrolling cancels it immediately.
06 API Reference
17 propsProp | Type | Default | Description | |
|---|---|---|---|---|
items required | JourneyNode[] | — | The ordered journey nodes. | |
class | string | — | Extra classes merged onto the root element. | |
defaultFocusId | string | — | Initial focused node id in uncontrolled mode. Ignored once focusId is bound. | |
detail | inlinepanel | 'inline' (vertical) / 'panel' (horizontal) | Where the focused node's detail renders. inline expands in place inside
the rail; panel renders a stable readout — beside the rail on wide
viewports, docked to the viewport bottom on narrow ones. Horizontal
orientation always uses the panel and ignores inline (DEV warning). | |
focusId | string | — | The focused node id. Supports bind:focusId. When omitted the component is
uncontrolled and falls back to defaultFocusId, then the first active
node, then the first focusable node. | |
marker | Snippet<[JourneyNode]> | — | Custom content *inside* each status dot — a glyph, count or icon. The dot
keeps its status colour, shape and size contract (scale it via
slotClasses.marker, e.g. size-5). Markers stay decorative
(aria-hidden); the status is still announced through the hidden label. | |
meta | Snippet<[JourneyNode]> | — | Rich override for the meta rail — receives each JourneyNode and replaces
the plain item.meta text (e.g. planned + actual time with a Badge). | |
node | Snippet<[JourneyNode]> | — | Renders the detail of the focused node. Receives the focused JourneyNode. | |
onFocusChange | (id: string) => void | — | Fires when the focused node changes (click or keyboard). | |
orientation | verticalhorizontal | 'vertical' | Orientation property for the JourneyTimeline component | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ JourneyTimeline: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
size | smmdlg | 'md' | Marker + label scale. | |
slotClasses | Partial<Record<JourneyTimelineSlots, string>> | — | Per-slot class overrides. Slots: base | rail | node | metaColumn | meta | markerColumn | marker | connector | content | card | header | trigger | trailing | labelGroup | title | subtitle | segment | detail | detailInner | detailContent | panel | |
trailing | Snippet<[JourneyNode]> | — | End-of-row content beside each node's header — status badges, a help affordance, quick actions. Renders *outside* the trigger button (a sibling in the header row), so interactive elements are valid HTML and activating them never moves the focused node. Right-aligned in vertical orientation, appended to the label pill in horizontal. | |
unstyled | boolean | — | Remove all default tv() classes. | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') | |
...JourneyTimelineVariants variant | VariantProps | — | Styling variants from JourneyTimelineVariants |
07 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
JourneyStatus | type | helper | 0 | Lifecycle status of a journey node — drives marker colour and title tone. | |
JourneyNode | interface | helper | 1 | A single node (waypoint) on a JourneyTimeline. | |
JourneyTimelineProps | interface | props | 0 | — | |
JourneyTimelineSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
JourneyTimelineVariants | type | variant | 0 | — | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
VariantProps | type | helper | 1 | — |
08 Installation
Import
import { JourneyTimeline, type JourneyNode } from '@urbicon-ui/blocks';