Skip to content
Brok UI

Loading…

No results

Project Timeline

Open source

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.

Version
v1.0.1
Stability
stable
License
MIT
Related
Timeline
Status History
Progress

Preview

previews.components.project-timeline.default.blade.php 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" />

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/project-timeline.blade.php
  • js resources/js/ui/project-timeline.js
Registry dependencies
avatar
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

project-timeline.md
# 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

empty.blade.php Blade
{{-- No projects: the component shows its own empty message instead of an empty axis. --}}
<x-ui.project-timeline :groups="[]" />
long-content.blade.php Blade
{{-- 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" />
week.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

project-timeline project-timeline-axis project-timeline-bar project-timeline-empty project-timeline-grid project-timeline-group project-timeline-milestone project-timeline-name project-timeline-no-dates project-timeline-point project-timeline-progress project-timeline-row project-timeline-scroll project-timeline-today project-timeline-track project-timeline-unit

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

Scannable data display

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
project-timeline project-timeline-scroll project-timeline-axis project-timeline-unit project-timeline-group project-timeline-row project-timeline-name project-timeline-track project-timeline-bar project-timeline-progress project-timeline-milestone project-timeline-point project-timeline-no-dates project-timeline-grid project-timeline-today project-timeline-empty
Theming hooks
Uses semantic tokens only: border-border hairlines and gridlines, bg-muted bars with a bg-primary/20 progress fill, bg-primary for the today line and complete milestones, and muted text for the axis and empty rows.

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/project-timeline.blade.php Blade
{{--
    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>
resources/js/ui/project-timeline.js JS
/**
 * 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