Timeline
Sequenced event displays — a free-form timeline and a stepper that covers both a numbered install/checkout sequence and a plain status stepper.
Timeline — A vertical or horizontal timeline with alternating layout, variant nodes, optional cards, and an `aside` slot across the rail for a sticky date or year.
Preview
-
Jan 2026
Project kickoff
Scope agreed and the team assembled.
-
Feb 2026
Prototype approval
Stakeholders signed off on the prototype.
-
Mar 2026
Unexpected delays
A third-party API outage pushed the timeline.
-
Apr 2026
Beta launch
Shipped to the first cohort of users.
<x-ui.timeline class="mx-auto w-full max-w-2xl py-2">
<x-ui.timeline.item>
<x-ui.timeline.date>{{ __('Jan 2026') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Project kickoff') }}</x-ui.timeline.title>
<x-ui.timeline.description>{{ __('Scope agreed and the team assembled.') }}</x-ui.timeline.description>
</x-ui.timeline.item>
<x-ui.timeline.item variant="secondary">
<x-ui.timeline.date>{{ __('Feb 2026') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Prototype approval') }}</x-ui.timeline.title>
<x-ui.timeline.description>{{ __('Stakeholders signed off on the prototype.') }}</x-ui.timeline.description>
</x-ui.timeline.item>
<x-ui.timeline.item variant="destructive">
<x-ui.timeline.date>{{ __('Mar 2026') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Unexpected delays') }}</x-ui.timeline.title>
<x-ui.timeline.description>{{ __('A third-party API outage pushed the timeline.') }}</x-ui.timeline.description>
</x-ui.timeline.item>
<x-ui.timeline.item variant="outline" hollow>
<x-ui.timeline.date>{{ __('Apr 2026') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Beta launch') }}</x-ui.timeline.title>
<x-ui.timeline.description>{{ __('Shipped to the first cohort of users.') }}</x-ui.timeline.description>
</x-ui.timeline.item>
</x-ui.timeline>
Installation
php artisan ui:add timeline
Registry contract
php artisan ui:add timeline
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/timeline.blade.php -
resources/views/components/ui/timeline/item.blade.php -
resources/views/components/ui/timeline/date.blade.php -
resources/views/components/ui/timeline/title.blade.php -
resources/views/components/ui/timeline/description.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Timeline (`timeline`)
A vertical or horizontal timeline with alternating layout, variant nodes, optional cards, and an `aside` slot across the rail for a sticky date or year.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add timeline
```
## Usage
```blade
<x-ui.timeline class="mx-auto w-full max-w-2xl py-2">
<x-ui.timeline.item>
<x-ui.timeline.date>{{ __('Jan 2026') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Project kickoff') }}</x-ui.timeline.title>
<x-ui.timeline.description>{{ __('Scope agreed and the team assembled.') }}</x-ui.timeline.description>
</x-ui.timeline.item>
<x-ui.timeline.item variant="secondary">
<x-ui.timeline.date>{{ __('Feb 2026') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Prototype approval') }}</x-ui.timeline.title>
<x-ui.timeline.description>{{ __('Stakeholders signed off on the prototype.') }}</x-ui.timeline.description>
</x-ui.timeline.item>
<x-ui.timeline.item variant="destructive">
<x-ui.timeline.date>{{ __('Mar 2026') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Unexpected delays') }}</x-ui.timeline.title>
<x-ui.timeline.description>{{ __('A third-party API outage pushed the timeline.') }}</x-ui.timeline.description>
</x-ui.timeline.item>
<x-ui.timeline.item variant="outline" hollow>
<x-ui.timeline.date>{{ __('Apr 2026') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Beta launch') }}</x-ui.timeline.title>
<x-ui.timeline.description>{{ __('Shipped to the first cohort of users.') }}</x-ui.timeline.description>
</x-ui.timeline.item>
</x-ui.timeline>
```
## Props
- `orientation` (string, default `vertical`) — Declared by @props in the shipped Blade source.
- `alternating` (bool, default `true`) — Declared by @props in the shipped Blade source.
- `alignment` (string, default `top/left`) — Declared by @props in the shipped Blade source.
- `noCards` (bool, default `false`) — Declared by @props in the shipped Blade source.
- `horizItemWidth` (int, default `220`) — Declared by @props in the shipped Blade source.
- `vertItemMaxWidth` (int, default `350`) — Declared by @props in the shipped Blade source.
- `horizItemSpacing` (int, default `24`) — Declared by @props in the shipped Blade source.
- `vertItemSpacing` (int, default `32`) — Declared by @props in the shipped Blade source.
- `variant` (string, default `default`) — Declared by @props in the registry Blade source.
- `hollow` (bool, default `false`) — Declared by @props in the registry Blade source.
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Presenting a sequence of dated or ordered events along a vertical or horizontal rail.
- Alternating events left and right on a vertical timeline for a narrative, or laying them out horizontally for a compact roadmap.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The events need sorting, filtering, or pagination as data; use table or list-panel instead.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:timeline>` tag (or `<x-ui.timeline>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/timeline
- Registry JSON (files, props, contract): https://brokui.dev/r/open/timeline.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Orientations
{{-- Horizontal, every card above the rail. The rail sits at the same height
in every item whatever the cards' heights, and each branch runs to the
next dot. --}}
<x-ui.timeline orientation="horizontal" :alternating="false" alignment="top/left" class="max-w-full overflow-x-auto py-2">
<x-ui.timeline.item>
<x-ui.timeline.date>{{ __('09:00') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Standup') }}</x-ui.timeline.title>
</x-ui.timeline.item>
<x-ui.timeline.item variant="secondary">
<x-ui.timeline.date>{{ __('11:30') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Design review') }}</x-ui.timeline.title>
<x-ui.timeline.description>{{ __('Walk through the new onboarding flow.') }}</x-ui.timeline.description>
</x-ui.timeline.item>
<x-ui.timeline.item variant="outline">
<x-ui.timeline.date>{{ __('15:00') }}</x-ui.timeline.date>
<x-ui.timeline.title>{{ __('Deploy') }}</x-ui.timeline.title>
</x-ui.timeline.item>
</x-ui.timeline>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | string | vertical | Declared by @props in the shipped Blade source. |
| alternating | bool | true | Declared by @props in the shipped Blade source. |
| alignment | string | top/left | Declared by @props in the shipped Blade source. |
| noCards | bool | false | Declared by @props in the shipped Blade source. |
| horizItemWidth | int | 220 | Declared by @props in the shipped Blade source. |
| vertItemMaxWidth | int | 350 | Declared by @props in the shipped Blade source. |
| horizItemSpacing | int | 24 | Declared by @props in the shipped Blade source. |
| vertItemSpacing | int | 32 | Declared by @props in the shipped Blade source. |
| variant | string | default | Declared by @props in the registry Blade source. |
| hollow | bool | false | Declared by @props in the registry Blade source. |
Slots
default—aside—x-ui.timeline.item— Installed subcomponent from the registry item.x-ui.timeline.date— Installed subcomponent from the registry item.x-ui.timeline.title— Installed subcomponent from the registry item.x-ui.timeline.description— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- orientation, alternating, and spacing props set on the root are read by every child timeline.item via @aware, so layout is configured once.
- Item spacing is drawn as padding on each item rather than a flex gap, because a gap would leave a visible hole in the connecting rail.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Present data for rapid scanning.
Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Presenting a sequence of dated or ordered events along a vertical or horizontal rail.
- Alternating events left and right on a vertical timeline for a narrative, or laying them out horizontally for a compact roadmap.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- The events need sorting, filtering, or pagination as data; use table or list-panel instead.
Use instead
- Table for exact comparison
- Plain text for a single value
Anti-patterns
- Adding display ornament without informational value
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- No component-owned keyboard interaction; native element behavior applies.
- Focus
none
- The root is a semantic ol/li list, and the connecting rail between dots is purely decorative and marked aria-hidden.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Livewire can update this component through forwarded wire:* attributes.
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'orientation' => 'vertical',
'alternating' => true,
'alignment' => 'top/left',
'noCards' => false,
'horizItemWidth' => 220,
'vertItemMaxWidth' => 350,
'horizItemSpacing' => 24,
'vertItemSpacing' => 32,
])
@php
// Layout props are inherited by <x-ui.timeline.item> via @aware, so authors
// configure the whole timeline once on the root. `alternating` side is
// resolved with CSS :nth-child inside the item (no per-item index needed).
$vertical = $orientation !== 'horizontal';
$gap = $vertical ? (int) $vertItemSpacing : (int) $horizItemSpacing;
$flow = $vertical ? 'flex-col' : 'flex-row items-stretch';
@endphp
{{-- Spacing lives on the items (padding, read through @aware), never as a flex
gap: each item draws its own stretch of the rail, so a gap between items
would leave a hole in the line. --}}
<ol
data-slot="timeline"
data-orientation="{{ $orientation }}"
{{ $attributes->merge(['class' => 'group/timeline flex '.$flow]) }}
>
{{ $slot }}
</ol>
@aware([
'orientation' => 'vertical',
'alternating' => true,
'alignment' => 'top/left',
'noCards' => false,
'horizItemWidth' => 220,
'vertItemMaxWidth' => 350,
'horizItemSpacing' => 24,
'vertItemSpacing' => 32,
])
@props([
'variant' => 'default',
'hollow' => false,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$vertical = $orientation !== 'horizontal';
$cards = ! $noCards;
$primarySide = in_array($alignment, ['top/left', 'top', 'left'], true);
// Dot + branch colour per variant. `outline` is inherently hollow.
// The dot carries the variant; the rail is one neutral hairline from the
// first item to the last, so the timeline reads as one connected line.
$accent = [
'default' => ['bg' => 'bg-primary', 'border' => 'border-primary'],
'secondary' => ['bg' => 'bg-muted-foreground', 'border' => 'border-muted-foreground'],
'success' => ['bg' => 'bg-success', 'border' => 'border-success'],
'destructive' => ['bg' => 'bg-destructive', 'border' => 'border-destructive'],
'outline' => ['bg' => 'bg-background', 'border' => 'border-input'],
][$variant] ?? null;
$accent ??= ['bg' => 'bg-primary', 'border' => 'border-primary'];
$accent['line'] = 'bg-border';
$isHollow = $hollow || $variant === 'outline';
$dotClasses = $isHollow
? 'border-2 bg-background '.$accent['border']
: $accent['bg'].' border-2 '.$accent['border'];
$cardBox = $cards
? 'rounded-lg border border-border bg-card p-6 text-card-foreground shadow-sm'
: '';
// ── Flex direction / alternation ─────────────────────────────────────────
if ($vertical) {
$li = 'relative flex gap-4';
$flip = $alternating ? 'even:flex-row-reverse' : ($primarySide ? 'flex-row-reverse' : '');
} else {
// Horizontal items are a three-row grid: content above, the rail, content
// below. The rows are `minmax(0, 1fr)`, so once the flex row stretches
// every item to the tallest one, the rail sits at the same height in each
// item and the dots line up whatever the cards' heights are. When every
// card sits on one side the empty row collapses and the rail stays at the
// edge, so alternating items flip which row holds the card.
$li = 'relative grid';
$flip = $alternating
? 'grid-rows-[minmax(0,1fr)_auto_minmax(0,1fr)] even:[&>[data-slot=timeline-content]]:row-start-3 even:[&>[data-slot=timeline-content]]:self-start even:[&>[data-slot=timeline-content]]:pt-4 even:[&>[data-slot=timeline-content]]:pb-0'
: ($primarySide ? 'grid-rows-[minmax(0,1fr)_auto]' : 'grid-rows-[auto_minmax(0,1fr)]');
}
// Card-content alignment (point text toward the branch).
if ($vertical) {
// The card hugs the rail on either side; the text inside always reads
// from the start edge.
$contentAlign = $alternating
? 'items-end text-start even:items-start'
: ($primarySide ? 'items-start text-start' : 'items-end text-start');
$contentWrap = 'flex min-w-0 flex-1 flex-col '.$contentAlign;
// A zero/null max width lets the content take the whole column (a
// changelog entry, a CV role) instead of the default 350px card.
$cardStyle = (int) $vertItemMaxWidth > 0 ? 'max-width: '.((int) $vertItemMaxWidth).'px;' : '';
} else {
// Odd items sit above the rail (card pinned to its bottom edge), even
// items below it (pinned to its top edge) when alternating.
$contentWrap = 'flex w-full flex-col items-center text-center '
.($alternating || $primarySide ? 'row-start-1 self-end pb-4' : 'row-start-2 self-start pt-4');
$cardStyle = '';
}
// Hide the trailing branch on the last item.
// The last item draws no branch below/after its dot.
$hideLast = '[&:last-child_[data-slot=timeline-branch]]:hidden';
// Item spacing is padding after the item (none after the last), so the
// branch line runs through it unbroken.
$liStyle = ($vertical ? '' : 'width: '.((int) $horizItemWidth).'px;')
.($vertical ? 'padding-bottom: '.((int) $vertItemSpacing).'px;' : 'padding-inline-end: '.((int) $horizItemSpacing).'px;');
@endphp
<li
data-slot="timeline-item"
data-variant="{{ $variant }}"
style="{{ $liStyle }}"
{{ $attributes->merge(['class' => trim($li.' '.$flip.' '.$hideLast.' last:!pb-0 last:!pe-0')]) }}
>
{{-- Content side --}}
<div data-slot="timeline-content" class="{{ $contentWrap }}">
<div @class([$cardBox, 'space-y-1', 'w-full' => ! $vertical || $cardStyle === '']) style="{{ $cardStyle }}">
{{ $slot }}
</div>
</div>
{{-- Rail: continuous branch line + node --}}
@if ($vertical)
<div class="relative flex w-3 shrink-0 items-start justify-center self-stretch" data-slot="timeline-rail">
{{-- Runs from this dot through the item's own spacing to the next dot. --}}
<span data-slot="timeline-branch" class="absolute top-2 w-px {{ $accent['line'] }}" style="inset-inline-start: calc(50% - 0.5px); bottom: -{{ (int) $vertItemSpacing }}px" aria-hidden="true"></span>
<span class="relative z-10 mt-2 size-3 shrink-0 rounded-full ring-4 ring-background {{ $dotClasses }}"></span>
</div>
@else
<div class="relative flex h-3 w-full items-center justify-center {{ $alternating ? 'row-start-2' : ($primarySide ? 'row-start-2' : 'row-start-1') }}" data-slot="timeline-rail">
<span data-slot="timeline-branch" class="absolute top-1/2 h-px -translate-y-1/2 {{ $accent['line'] }}" style="inset-inline-start: 50%; inset-inline-end: calc(-50% - {{ (int) $horizItemSpacing }}px)" aria-hidden="true"></span>
<span class="relative z-10 size-3 shrink-0 rounded-full ring-4 ring-background {{ $dotClasses }}"></span>
</div>
@endif
{{-- The `aside` slot sits across the rail from the content (a sticky
date, a year, a duration); it replaces the alternating spacer. --}}
@if ($vertical && isset($aside))
<div data-slot="timeline-aside" {{ $aside->attributes->merge(['class' => 'shrink-0'.($alternating ? ' flex-1' : '')]) }}>
{{ $aside }}
</div>
@elseif ($alternating && $vertical)
{{-- Spacer for alternating vertical layouts (the grid handles horizontal) --}}
<div class="flex-1" aria-hidden="true"></div>
@endif
</li>
<span
data-slot="timeline-item-date"
{{ $attributes->merge(['class' => 'block text-xs font-medium text-muted-foreground']) }}
>
{{ $slot }}
</span>
<h3
data-slot="timeline-item-title"
{{ $attributes->merge(['class' => 'text-sm font-semibold text-foreground']) }}
>
{{ $slot }}
</h3>
<p
data-slot="timeline-item-description"
{{ $attributes->merge(['class' => 'text-sm text-muted-foreground']) }}
>
{{ $slot }}
</p>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0