Project Timeline
A data-driven project roadmap: rows of projects grouped by a status label, each with a bar from its start to its target date on a week or month axis, milestone markers, progress and a today line. The axis scrolls sideways while the names stay pinned. Server-rendered; a small Alpine behavior opens the axis at today.
Preview
{{-- Projects grouped by status on a month axis, with milestones and a fixed today line. --}}
@php
$groups = [
['label' => __('In progress'), 'rows' => [
['name' => __('Client portal'), 'href' => '#client-portal', 'start' => '2026-08-03', 'target' => '2026-11-27', 'progress' => 55, 'milestones' => [
['name' => __('Design review'), 'date' => '2026-09-04', 'progress' => 100],
['name' => __('Beta'), 'date' => '2026-10-30', 'progress' => 40],
]],
['name' => __('Invoice automation'), 'href' => '#invoice-automation', 'start' => '2026-09-14', 'target' => '2026-12-18', 'progress' => 20, 'milestones' => [
['name' => __('Bank connection'), 'date' => '2026-10-23', 'progress' => 0],
]],
]],
['label' => __('Planned'), 'rows' => [
['name' => __('Mobile check-in'), 'href' => '#mobile-check-in', 'start' => '2026-11-02', 'target' => '2027-02-26', 'progress' => 0],
['name' => __('Supplier onboarding'), 'href' => '#supplier-onboarding'],
]],
['label' => __('Completed'), 'rows' => [
['name' => __('Single sign-on'), 'href' => '#single-sign-on', 'start' => '2026-07-06', 'target' => '2026-09-11', 'progress' => 100],
]],
];
@endphp
<x-ui.project-timeline :groups="$groups" today="2026-10-14" />
Installation
php artisan ui:add project-timeline
Note
This component ships an Alpine behavior module at
resources/js/ui/project-timeline.js. Import it once from your bundle so it registers on alpine:init:
import './project-timeline.js';
Registry contract
php artisan ui:add project-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/project-timeline.blade.php -
resources/js/ui/project-timeline.js
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: Project Timeline (`project-timeline`)
A data-driven project roadmap: rows of projects grouped by a status label, each with a bar from its start to its target date on a week or month axis, milestone markers, progress and a today line. The axis scrolls sideways while the names stay pinned. Server-rendered; a small Alpine behavior opens the axis at today.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add project-timeline
```
## Usage
```blade
{{-- Projects grouped by status on a month axis, with milestones and a fixed today line. --}}
@php
$groups = [
['label' => __('In progress'), 'rows' => [
['name' => __('Client portal'), 'href' => '#client-portal', 'start' => '2026-08-03', 'target' => '2026-11-27', 'progress' => 55, 'milestones' => [
['name' => __('Design review'), 'date' => '2026-09-04', 'progress' => 100],
['name' => __('Beta'), 'date' => '2026-10-30', 'progress' => 40],
]],
['name' => __('Invoice automation'), 'href' => '#invoice-automation', 'start' => '2026-09-14', 'target' => '2026-12-18', 'progress' => 20, 'milestones' => [
['name' => __('Bank connection'), 'date' => '2026-10-23', 'progress' => 0],
]],
]],
['label' => __('Planned'), 'rows' => [
['name' => __('Mobile check-in'), 'href' => '#mobile-check-in', 'start' => '2026-11-02', 'target' => '2027-02-26', 'progress' => 0],
['name' => __('Supplier onboarding'), 'href' => '#supplier-onboarding'],
]],
['label' => __('Completed'), 'rows' => [
['name' => __('Single sign-on'), 'href' => '#single-sign-on', 'start' => '2026-07-06', 'target' => '2026-09-11', 'progress' => 100],
]],
];
@endphp
<x-ui.project-timeline :groups="$groups" today="2026-10-14" />
```
## Props
- `groups` (list<array{label: string, rows: list<array{name: string, href?: string|null, start?: string|DateTimeInterface|null, target?: string|DateTimeInterface|null, progress?: int|float|null, avatar?: string|null, initials?: string|null, milestones?: list<array{name: string, date: string|DateTimeInterface, progress?: int|float|null}>}>}>, default `[]`) — Status groups in display order, each with its projects. Dates are calendar days (any Carbon-parsable string or DateTimeInterface); progress is 0 to 100. A group without rows is skipped.
- `from` (string|DateTimeInterface|null, default `null`) — First day of the axis. Null uses the earliest date in the data minus one unit. Snapped to the start of its week (Monday) or month.
- `to` (string|DateTimeInterface|null, default `null`) — Last day of the axis. Null uses the latest date in the data plus one unit. Snapped to the end of its week or month.
- `scale` (week|month, default `month`) — Axis unit: one column per week (labelled with its Monday) or per month (the year shows on the first column and on January).
- `today` (string|DateTimeInterface|false|null, default `null`) — The day the today line marks; null is today, false hides the line.
- `label` (string|null, default `null`) — Accessible name of the scrolling region; null uses the translated "Project timeline".
## Use when
- Use to summarize, sequence, or present data so users can scan it quickly.
- Showing several projects as date bars on a shared week or month axis, grouped by status, with milestones and a today line.
- Linking each project row to its detail page from an overview.
## Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Placing sessions on an hourly axis per room; use the scheduling-gantt block instead.
- Listing the ordered events of one record; use status-history or timeline instead.
## Anti-patterns
- Adding display ornament without informational value
## Rules
- Use the `<brok:project-timeline>` tag (or `<x-ui.project-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/project-timeline
- Registry JSON (files, props, contract): https://brokui.dev/r/open/project-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
{{-- No projects: the component shows its own empty message instead of an empty axis. --}}
<x-ui.project-timeline :groups="[]" />
Long Content
{{-- Long names truncate in the pinned column; rows without dates, with one date, or outside the range stay readable. --}}
@php
$groups = [
['label' => __('Waiting for approval from the steering committee'), 'rows' => [
['name' => __('Replace the legacy warehouse management integration for every regional distribution centre'), 'href' => '#warehouse', 'start' => '2026-06-15', 'target' => '2027-06-30', 'progress' => 12, 'initials' => 'WM', 'milestones' => [
['name' => __('Vendor selection'), 'date' => '2026-09-30', 'progress' => 100],
['name' => __('Pilot site live'), 'date' => '2026-12-01', 'progress' => 25],
]],
['name' => __('Accessibility audit'), 'href' => '#audit', 'target' => '2026-11-20'],
['name' => __('Pricing page refresh'), 'start' => '2026-10-19'],
['name' => __('Data retention policy'), 'href' => '#retention'],
]],
];
@endphp
<x-ui.project-timeline :groups="$groups" from="2026-09-01" to="2027-02-28" today="2026-10-14" />
{{-- The same kind of data on a week axis: one column per week, labelled with its Monday. --}}
@php
$groups = [
['label' => __('This sprint'), 'rows' => [
['name' => __('Search filters'), 'href' => '#search-filters', 'start' => '2026-10-05', 'target' => '2026-10-23', 'progress' => 70, 'milestones' => [
['name' => __('Code freeze'), 'date' => '2026-10-21', 'progress' => 0],
]],
['name' => __('Export to CSV'), 'href' => '#export-csv', 'start' => '2026-10-12', 'target' => '2026-10-30', 'progress' => 35],
]],
['label' => __('Next sprint'), 'rows' => [
['name' => __('Audit trail'), 'href' => '#audit-trail', 'start' => '2026-10-26', 'target' => '2026-11-13'],
]],
];
@endphp
<x-ui.project-timeline :groups="$groups" scale="week" today="2026-10-14" />
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| groups | list<array{label: string, rows: list<array{name: string, href?: string | null, start?: string | DateTimeInterface | null, target?: string | DateTimeInterface | null, progress?: int | float | null, avatar?: string | null, initials?: string | null, milestones?: list<array{name: string, date: string | DateTimeInterface, progress?: int | float | null}>}>}> | [] | Status groups in display order, each with its projects. Dates are calendar days (any Carbon-parsable string or DateTimeInterface); progress is 0 to 100. A group without rows is skipped. |
| from | string | DateTimeInterface | null | null | First day of the axis. Null uses the earliest date in the data minus one unit. Snapped to the start of its week (Monday) or month. |
| to | string | DateTimeInterface | null | null | Last day of the axis. Null uses the latest date in the data plus one unit. Snapped to the end of its week or month. |
| scale | week | month | month | Axis unit: one column per week (labelled with its Monday) or per month (the year shows on the first column and on January). |
| today | string | DateTimeInterface | false | null | null | The day the today line marks; null is today, false hides the line. |
| label | string | null | null | Accessible name of the scrolling region; null uses the translated "Project timeline". |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Bars run from the start day to the end of the target day; a target before the start is swapped. Bars and markers outside the axis are clamped to its edges or left out.
- A row with neither date shows no bar and a muted "No dates" beside the pinned names; a row with one date shows a thin tick at that day.
- Milestones are diamond markers at their date: filled when their progress is 100, outlined otherwise; the name and progress are in the marker title and the row's screen-reader summary.
- On load the axis scrolls, without animation, so the today line is centred in the visible track when the axis overflows; the user scrolls freely after that.
- The axis scrolls horizontally inside the component (at 390px too) while the name column stays pinned at the start edge; the page itself never scrolls sideways.
- Month and week labels and all dates are formatted in the app locale through Carbon isoFormat; the chart mirrors under dir="rtl".
- The axis is capped at 156 units so a very wide range cannot render an unbounded row.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, authoredStateFixtures, 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.
- Showing several projects as date bars on a shared week or month axis, grouped by status, with milestones and a today line.
- Linking each project row to its detail page from an overview.
Avoid when
- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- Placing sessions on an hourly axis per room; use the scheduling-gantt block instead.
- Listing the ordered events of one record; use status-history or timeline 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
- managed
- Focus
managed
- The scrolling area is a focusable, labelled region, so keyboard users can scroll the axis with the arrow keys.
- Each group is a list named by its status label; each row with an href is one link whose text is the project name plus a screen-reader summary of its dates, progress and milestones.
- The axis, bars, markers, gridlines and today line are aria-hidden; the summary carries the same facts as text.
- Focus shows a ring on the pinned name cell and highlights the whole row; smooth scrolling runs only without a reduced-motion preference.
- 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
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="project-timeline-{{ $record->id }}">
{{-- Projects grouped by status on a month axis, with milestones and a fixed today line. --}}
@php
$groups = [
['label' => __('In progress'), 'rows' => [
['name' => __('Client portal'), 'href' => '#client-portal', 'start' => '2026-08-03', 'target' => '2026-11-27', 'progress' => 55, 'milestones' => [
['name' => __('Design review'), 'date' => '2026-09-04', 'progress' => 100],
['name' => __('Beta'), 'date' => '2026-10-30', 'progress' => 40],
]],
['name' => __('Invoice automation'), 'href' => '#invoice-automation', 'start' => '2026-09-14', 'target' => '2026-12-18', 'progress' => 20, 'milestones' => [
['name' => __('Bank connection'), 'date' => '2026-10-23', 'progress' => 0],
]],
]],
['label' => __('Planned'), 'rows' => [
['name' => __('Mobile check-in'), 'href' => '#mobile-check-in', 'start' => '2026-11-02', 'target' => '2027-02-26', 'progress' => 0],
['name' => __('Supplier onboarding'), 'href' => '#supplier-onboarding'],
]],
['label' => __('Completed'), 'rows' => [
['name' => __('Single sign-on'), 'href' => '#single-sign-on', 'start' => '2026-07-06', 'target' => '2026-09-11', 'progress' => 100],
]],
];
@endphp
<x-ui.project-timeline :groups="$groups" today="2026-10-14" />
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Project timeline: projects grouped by a status label, each row a bar from
its start to its target date on a week or month axis, with milestone
markers and a today line. Data-driven and server-rendered; a small Alpine
behavior (project-timeline.js) only opens the axis at today.
The axis scrolls sideways inside the component while the project names stay
pinned at the start edge. Bars, markers and the today line are placed with
inset-inline-start percentages, so the whole chart mirrors under RTL.
--}}
@props([
// [['label' => 'In progress', 'rows' => [['name', 'href', 'start', 'target', 'progress', 'avatar', 'initials', 'milestones' => [['name', 'date', 'progress']]]]]]
'groups' => [],
// Axis range; either may be null to use the data range padded by one unit.
'from' => null,
'to' => null,
// week | month
'scale' => 'month',
// The date the today line marks; false hides it.
'today' => null,
// Accessible name of the scrolling region.
'label' => null,
])
@php
$locale = app()->getLocale();
$scale = $scale === 'week' ? 'week' : 'month';
$label ??= __('Project timeline');
// Every date becomes a calendar day at UTC midnight, so day arithmetic
// never drifts across a daylight-saving change.
$parse = static function (mixed $value): ?\Illuminate\Support\Carbon {
if (! $value instanceof \DateTimeInterface) {
if (! is_string($value) || trim($value) === '') {
return null;
}
try {
$value = \Illuminate\Support\Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
return \Illuminate\Support\Carbon::createFromFormat('!Y-m-d', $value->format('Y-m-d'), 'UTC');
};
$percent = static fn (mixed $value): ?int => is_numeric($value) ? (int) round(max(0, min(100, (float) $value))) : null;
// Normalise the data once: parsed dates, clamped progress, initials.
$normalized = [];
$known = [];
foreach (is_iterable($groups) ? $groups : [] as $group) {
$rows = [];
foreach (($group['rows'] ?? []) as $row) {
$start = $parse($row['start'] ?? null);
$target = $parse($row['target'] ?? null);
if ($start && $target && $target->lt($start)) {
[$start, $target] = [$target, $start];
}
$milestones = [];
foreach (($row['milestones'] ?? []) as $milestone) {
$date = $parse($milestone['date'] ?? null);
if ($date) {
$milestones[] = ['name' => (string) ($milestone['name'] ?? ''), 'date' => $date, 'progress' => $percent($milestone['progress'] ?? null)];
$known[] = $date;
}
}
array_push($known, ...array_filter([$start, $target]));
$name = (string) ($row['name'] ?? '');
$words = preg_split('/\s+/u', trim($name), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$initials = $row['initials'] ?? mb_strtoupper(implode('', array_map(static fn ($word) => mb_substr($word, 0, 1), array_slice($words, 0, 2))));
$rows[] = [
'name' => $name,
// A script or data URL never becomes a link.
'href' => filled($row['href'] ?? null) && ! preg_match('/^\s*(?:javascript|data|vbscript):/i', (string) $row['href']) ? (string) $row['href'] : null,
'start' => $start,
'target' => $target,
'progress' => $percent($row['progress'] ?? null),
'avatar' => filled($row['avatar'] ?? null) ? (string) $row['avatar'] : null,
'initials' => (string) $initials,
'milestones' => $milestones,
];
}
$normalized[] = ['label' => (string) ($group['label'] ?? ''), 'rows' => $rows];
}
$rowCount = array_sum(array_map(static fn ($group) => count($group['rows']), $normalized));
$todayDate = $today === false ? null : ($parse($today) ?? $parse(\Illuminate\Support\Carbon::today()));
// Range: explicit bounds win; otherwise the data range padded by one unit,
// or three months / eight weeks from today when no row has a date.
$rangeStart = $parse($from);
$rangeEnd = $parse($to);
$anchor = $todayDate ?? $parse(\Illuminate\Support\Carbon::today());
if (! $rangeStart) {
$rangeStart = $known ? min($known)->copy() : $anchor->copy();
$rangeStart = $scale === 'week' ? $rangeStart->subWeek() : $rangeStart->subMonthNoOverflow();
}
if (! $rangeEnd) {
$rangeEnd = $known ? max($known)->copy() : $anchor->copy()->add($scale === 'week' ? '7 weeks' : '2 months');
$rangeEnd = $scale === 'week' ? $rangeEnd->addWeek() : $rangeEnd->addMonthNoOverflow();
}
if ($rangeEnd->lt($rangeStart)) {
[$rangeStart, $rangeEnd] = [$rangeEnd, $rangeStart];
}
$rangeStart = $scale === 'week' ? $rangeStart->startOfWeek(\Carbon\CarbonInterface::MONDAY) : $rangeStart->startOfMonth();
$rangeEnd = ($scale === 'week' ? $rangeEnd->endOfWeek(\Carbon\CarbonInterface::SUNDAY) : $rangeEnd->endOfMonth())->addDay()->startOfDay();
// Axis units, capped so a wide range cannot render an unbounded row.
$units = [];
$cursor = $rangeStart->copy();
while ($cursor->lt($rangeEnd) && count($units) < 156) {
$next = $scale === 'week' ? $cursor->copy()->addWeek() : $cursor->copy()->addMonthNoOverflow()->startOfMonth();
$localized = $cursor->copy()->locale($locale);
$units[] = [
'label' => $scale === 'week'
? $localized->isoFormat('D MMM')
: (count($units) === 0 || $cursor->month === 1 ? $localized->isoFormat('MMM YYYY') : $localized->isoFormat('MMM')),
'current' => $todayDate && $todayDate->gte($cursor) && $todayDate->lt($next),
];
$cursor = $next;
}
$rangeEnd = $cursor;
$totalDays = max(1, $rangeStart->diffInDays($rangeEnd));
// Position of a date as a percentage of the axis, clamped to its edges.
$at = static fn (\Illuminate\Support\Carbon $date): float => round(max(0, min(100, $rangeStart->diffInDays($date, false) / $totalDays * 100)), 3);
$inRange = static fn (?\Illuminate\Support\Carbon $date): bool => $date !== null && $date->gte($rangeStart) && $date->lt($rangeEnd);
$format = static fn (\Illuminate\Support\Carbon $date): string => $date->copy()->locale($locale)->isoFormat('ll');
$unitClass = $scale === 'week' ? '[--pt-unit:4rem]' : '[--pt-unit:7rem]';
$trackWidth = 'calc(var(--pt-units) * var(--pt-col))';
$uid = $attributes->get('id') ?? 'pt-'.substr(md5(json_encode([$label, $groups, $rangeStart->toDateString(), $scale]) ?: ''), 0, 8);
@endphp
<div
data-slot="project-timeline"
data-scale="{{ $scale }}"
{{ $attributes->merge(['class' => 'relative w-full min-w-0 rounded-lg border border-border bg-background text-sm text-foreground [--pt-name:9rem] sm:[--pt-name:15rem] '.$unitClass]) }}
style="--pt-units: {{ count($units) }};"
>
@if ($rowCount === 0)
<div data-slot="project-timeline-empty" class="flex flex-col items-center gap-2 px-6 py-12 text-center">
<p class="font-medium text-foreground">{{ __('No projects') }}</p>
<p class="text-muted-foreground">{{ __('Projects with a start or target date will appear here.') }}</p>
</div>
@else
<div
data-slot="project-timeline-scroll"
role="region"
aria-label="{{ $label }}"
x-data="uiProjectTimeline"
tabindex="0"
class="@container overflow-x-auto overscroll-x-contain rounded-lg focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-safe:scroll-smooth"
>
{{-- A column is one unit wide, or wider when the units do not fill the visible width. --}}
<div class="relative w-max min-w-full" style="--pt-col: max(var(--pt-unit), calc((100cqi - var(--pt-name)) / var(--pt-units)));">
{{-- Axis header --}}
<div data-slot="project-timeline-axis" class="flex border-b border-border" aria-hidden="true">
<div class="sticky start-0 z-20 flex w-(--pt-name) shrink-0 items-center border-e border-border bg-background px-2 py-2 text-xs font-medium text-muted-foreground sm:px-4">
{{ __('Project') }}
</div>
<div class="flex shrink-0" style="width: {{ $trackWidth }};">
@foreach ($units as $unit)
<div
data-slot="project-timeline-unit"
@if ($unit['current']) data-current="true" @endif
class="w-(--pt-col) shrink-0 truncate px-2 py-2 text-xs {{ $unit['current'] ? 'font-medium text-foreground' : 'text-muted-foreground' }}"
>{{ $unit['label'] }}</div>
@endforeach
</div>
</div>
@foreach ($normalized as $g => $group)
@continue(count($group['rows']) === 0)
<div data-slot="project-timeline-group">
<div class="flex border-b border-border bg-muted">
<div id="{{ $uid }}-g{{ $g }}" class="sticky start-0 z-20 flex w-(--pt-name) shrink-0 items-center gap-2 bg-muted px-2 py-2 text-xs font-medium text-foreground sm:px-4">
<span dir="auto" class="truncate">{{ $group['label'] }}</span>
<span class="shrink-0 tabular-nums text-muted-foreground">{{ count($group['rows']) }}</span>
</div>
<div class="shrink-0" style="width: {{ $trackWidth }};"></div>
</div>
<ul role="list" aria-labelledby="{{ $uid }}-g{{ $g }}">
@foreach ($group['rows'] as $row)
@php
$hasBar = $row['start'] && $row['target'];
$summary = [];
if ($hasBar) {
$summary[] = __(':start to :target', ['start' => $format($row['start']), 'target' => $format($row['target'])]);
} elseif ($row['start']) {
$summary[] = __('Starts :date, no target date', ['date' => $format($row['start'])]);
} elseif ($row['target']) {
$summary[] = __('Target :date, no start date', ['date' => $format($row['target'])]);
} else {
$summary[] = __('No dates');
}
if ($row['progress'] !== null) {
$summary[] = __(':percent% complete', ['percent' => $row['progress']]);
}
foreach ($row['milestones'] as $milestone) {
$summary[] = $milestone['progress'] === null
? __('Milestone :name on :date', ['name' => $milestone['name'], 'date' => $format($milestone['date'])])
: __('Milestone :name on :date, :percent% complete', ['name' => $milestone['name'], 'date' => $format($milestone['date']), 'percent' => $milestone['progress']]);
}
$tag = $row['href'] ? 'a' : 'div';
$barStart = $hasBar ? $at($row['start']) : 0;
$barEnd = $hasBar ? $at($row['target']->copy()->addDay()) : 0;
@endphp
<li data-slot="project-timeline-row" class="border-b border-border last:border-b-0">
<{{ $tag }}
@if ($row['href']) href="{{ $row['href'] }}" @endif
class="group/row flex outline-none"
>
<span class="sticky start-0 z-20 flex w-(--pt-name) shrink-0 items-center gap-2 border-e border-border bg-background px-2 py-2 group-hover/row:bg-muted sm:px-4 group-focus-visible/row:bg-muted group-focus-visible/row:ring-[length:var(--ring-width)] group-focus-visible/row:ring-ring group-focus-visible/row:ring-inset">
<x-ui.avatar size="xs" :src="$row['avatar']" :fallback="$row['initials']" class="max-sm:hidden" />
<span data-slot="project-timeline-name" dir="auto" class="min-w-0 truncate font-medium">{{ $row['name'] }}</span>
<span class="sr-only">{{ implode('. ', $summary) }}</span>
</span>
<span
data-slot="project-timeline-track"
class="relative block h-12 shrink-0 group-hover/row:bg-muted/50 group-focus-visible/row:bg-muted/50"
style="width: {{ $trackWidth }};"
aria-hidden="true"
>
@if ($hasBar)
<span
data-slot="project-timeline-bar"
class="absolute inset-y-3 z-10 flex min-w-2 items-center overflow-hidden rounded-sm border border-border bg-muted"
style="inset-inline-start: {{ $barStart }}%; width: {{ max(0, $barEnd - $barStart) }}%;"
title="{{ $summary[0] }}"
>
@if ($row['progress'] !== null)
<span data-slot="project-timeline-progress" class="absolute inset-y-0 start-0 bg-primary/20" style="width: {{ $row['progress'] }}%;"></span>
<span class="relative truncate px-2 text-2xs font-medium tabular-nums text-foreground">{{ $row['progress'] }}%</span>
@endif
</span>
@elseif (! $row['start'] && ! $row['target'])
<span data-slot="project-timeline-no-dates" class="sticky start-(--pt-name) flex h-full w-max items-center px-4 text-xs text-muted-foreground">{{ __('No dates') }}</span>
@else
@php $single = $row['start'] ?? $row['target']; @endphp
@if ($inRange($single))
<span data-slot="project-timeline-point" class="absolute inset-y-3 z-10 w-px bg-muted-foreground" style="inset-inline-start: {{ $at($single) }}%;"></span>
@endif
@endif
@foreach ($row['milestones'] as $milestone)
@continue(! $inRange($milestone['date']))
<span
data-slot="project-timeline-milestone"
data-complete="{{ $milestone['progress'] === 100 ? 'true' : 'false' }}"
class="absolute top-1/2 z-10 size-3 -translate-x-1/2 -translate-y-1/2 rotate-45 border rtl:translate-x-1/2 {{ $milestone['progress'] === 100 ? 'border-primary bg-primary' : 'border-muted-foreground bg-background' }}"
style="inset-inline-start: {{ $at($milestone['date']) }}%;"
title="{{ $milestone['progress'] === null ? $milestone['name'] : $milestone['name'].', '.$milestone['progress'].'%' }}"
></span>
@endforeach
</span>
</{{ $tag }}>
</li>
@endforeach
</ul>
</div>
@endforeach
{{-- Unit gridlines, painted over row backgrounds and under the bars;
the pinned name column covers them. --}}
<div data-slot="project-timeline-grid" class="pointer-events-none absolute inset-y-0 z-0 flex start-(--pt-name)" style="width: {{ $trackWidth }};" aria-hidden="true">
@foreach ($units as $u => $unit)
<span class="h-full w-(--pt-col) shrink-0 {{ $u > 0 ? 'border-s border-border' : '' }}"></span>
@endforeach
</div>
{{-- Above the bars, under the pinned names. --}}
@if ($inRange($todayDate))
<div
data-slot="project-timeline-today"
class="pointer-events-none absolute inset-y-0 z-10 w-px bg-primary"
style="inset-inline-start: calc(var(--pt-name) + var(--pt-units) * var(--pt-col) * {{ $at($todayDate) / 100 }});"
aria-hidden="true"
></div>
@endif
</div>
</div>
@endif
</div>
/**
* Project timeline behavior: open the axis at today.
*
* The chart renders from the start of its range, so on a narrow screen the
* first months fill the view and the current work sits off screen. On init
* the scrolling region jumps (no smooth scroll) so the today line is centred
* in the visible track, the part of the region the pinned names do not cover.
* It runs only when the axis overflows and a today line is rendered, and it
* works in RTL because scrollLeft grows towards the visual left edge in both
* directions. Later scrolling is left to the user.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiProjectTimeline', () => ({
init() {
this.$nextTick(() => this.revealToday());
},
revealToday() {
const region = this.$el;
const today = region.querySelector('[data-slot="project-timeline-today"]');
if (!today || region.scrollWidth <= region.clientWidth) {
return;
}
const name = region.querySelector('[data-slot="project-timeline-axis"] > :first-child');
const pinned = name ? name.getBoundingClientRect().width : 0;
const box = region.getBoundingClientRect();
const rtl = getComputedStyle(region).direction === 'rtl';
const trackStart = rtl ? box.left : box.left + pinned;
const trackEnd = rtl ? box.right - pinned : box.right;
const offset = today.getBoundingClientRect().left - (trackStart + trackEnd) / 2;
region.scrollTo({ left: region.scrollLeft + offset, behavior: 'instant' });
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.1 - 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