Skip to content

<CoarTimelineView> — Timeline View Preview

Gantt-lite layout: one row per logical event, horizontal time-axis. The left pane lists event titles; the right pane renders bars positioned by [start, end) against the visible window. Same CalendarEvent data as Day / Week / Month / Agenda — just rendered as a project-plan timeline instead of a calendar grid.

Recurring series collapse to one row. All occurrences of a recurring series (those with meta.__recurrence.seriesId) share a single row with one bar per occurrence — a weekly stand-up with 26 instances in the visible window renders as ONE row labelled "Standup ×26", not 26 separate rows. Standalone events (no recurrence metadata) keep one row per event. The grouping is automatic; consumer code doesn't need to do anything.

html
<CoarTimelineView :builder="builder" />

Gantt-lite vs. full Gantt

This view covers the timeline-of-events half of project planning: bars on a time-axis, sorted, with window-clamping and continues-indicators. It does not yet include the rest of a full Gantt: task hierarchy (parent / sub-task collapse), dependencies (arrows between bars), critical-path computation, milestones, or resource lanes. Those land in a separate @cocoar/vue-gantt package later, building on this view's primitives.

Live example

Rows: 6 (5 one-off + 1 series)
1. Juni – 15. Juli 2026
Event
1. Juni
2. Juni
3. Juni
4. Juni
5. Juni
6. Juni
7. Juni
8. Juni
9. Juni
10. Juni
11. Juni
12. Juni
13. Juni
14. Juni
15. Juni
16. Juni
17. Juni
18. Juni
19. Juni
20. Juni
21. Juni
22. Juni
23. Juni
24. Juni
25. Juni
26. Juni
27. Juni
28. Juni
29. Juni
30. Juni
1. Juli
2. Juli
3. Juli
4. Juli
5. Juli
6. Juli
7. Juli
8. Juli
9. Juli
10. Juli
11. Juli
12. Juli
13. Juli
14. Juli
15. Juli
Design phase
Build phase
QA + bug bash
Launch day
Retrospective
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 8px;">
    <div style="display: flex; gap: 12px; align-items: center; font-size: 13px;">
      <span :style="{ color: 'var(--coar-text-neutral-secondary)' }">
        Rows: <strong>{{ events.length + series.length }}</strong>
        ({{ events.length }} one-off + {{ series.length }} series)
      </span>
    </div>
    <div style="height: 520px; border: 1px solid var(--coar-border-neutral-tertiary); border-radius: var(--coar-radius-xs); overflow: hidden;">
      <CoarCalendar :builder="builder" />
    </div>
  </div>
</template>

<script setup lang="ts">
/**
 * Timeline-view showcase — mixes a few one-off project milestones
 * with a recurring standup series. The recurring occurrences
 * collapse into one row with N bars (one per occurrence) — labelled
 * "Daily Standup ×N" in the left pane. The one-off milestones get
 * one row per event.
 *
 * Drag empty space horizontally to pan. Bars are coloured rectangles
 * only; the row label on the left is the title source of truth.
 */

import { ref } from 'vue';
import {
  CoarCalendar,
  Temporal,
  useCalendar,
  type CalendarEvent,
  type CalendarView,
  type RecurringSeries,
} from '@cocoar/vue-calendar';

const events = ref<CalendarEvent[]>([
  {
    id: 'design',
    start: Temporal.PlainDate.from('2026-06-01'),
    end: Temporal.PlainDate.from('2026-06-08'),
    meta: { title: 'Design phase', color: '#4f46e5' },
  },
  {
    id: 'build',
    start: Temporal.PlainDate.from('2026-06-08'),
    end: Temporal.PlainDate.from('2026-06-22'),
    meta: { title: 'Build phase', color: '#06b6d4' },
  },
  {
    id: 'qa',
    start: Temporal.PlainDate.from('2026-06-22'),
    end: Temporal.PlainDate.from('2026-06-29'),
    meta: { title: 'QA + bug bash', color: '#f59e0b' },
  },
  {
    id: 'launch',
    start: Temporal.PlainDate.from('2026-06-29'),
    end: Temporal.PlainDate.from('2026-06-30'),
    meta: { title: 'Launch day', color: '#ef4444' },
  },
  {
    id: 'retro',
    start: Temporal.ZonedDateTime.from('2026-07-02T14:00:00[Europe/Vienna]'),
    end: Temporal.ZonedDateTime.from('2026-07-02T15:00:00[Europe/Vienna]'),
    meta: { title: 'Retrospective', color: '#a855f7' },
  },
]);

const series = ref<RecurringSeries[]>([
  {
    id: 'standup',
    rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
    dtstart: Temporal.ZonedDateTime.from('2026-06-01T09:00:00[Europe/Vienna]'),
    duration: { minutes: 30 },
    meta: { title: 'Daily standup', color: '#10b981' },
  },
]);

const view = ref<CalendarView>('timeline');
const cursor = ref(Temporal.PlainDate.from('2026-06-01'));

const { builder } = useCalendar();
builder
  .events(events)
  .series(series)
  .view(view)
  .date(cursor)
  .timezone('Europe/Vienna')
  .locale('de-AT')
  .firstDayOfWeek(1)
  .timelineRangeDays(45)
  .timelinePixelsPerDay(48);
</script>

The demo above mixes four one-off project-milestone events (design / build / QA / launch / retro) with one recurring "Daily standup" series. The recurring occurrences collapse into a single row labelled "Daily standup ×N" with one coloured bar per occurrence; the one-off milestones each get their own row. Drag empty space to pan; the view-switcher button bar at the top lets you flip between Timeline and the other views to see the same data rendered differently.

Standalone usage

ts
import { ref } from 'vue';
import { Temporal } from '@js-temporal/polyfill';
import {
  CoarTimelineView,
  useTimelineView,
  type CalendarEvent,
} from '@cocoar/vue-calendar';

const events = ref<CalendarEvent[]>([
  {
    id: 'design',
    start: Temporal.PlainDate.from('2026-06-01'),
    end:   Temporal.PlainDate.from('2026-06-08'),
    meta: { title: 'Design phase', color: '#4f46e5' },
  },
  {
    id: 'build',
    start: Temporal.PlainDate.from('2026-06-08'),
    end:   Temporal.PlainDate.from('2026-06-22'),
    meta: { title: 'Build phase', color: '#06b6d4' },
  },
  {
    id: 'demo',
    start: Temporal.ZonedDateTime.from('2026-06-22T14:00:00[Europe/Vienna]'),
    end:   Temporal.ZonedDateTime.from('2026-06-22T15:30:00[Europe/Vienna]'),
    meta: { title: 'Client demo', color: '#f59e0b' },
  },
]);
const date = ref('2026-06-01');

const { builder } = useTimelineView();
builder
  .events(events)
  .date(date)
  .timezone('Europe/Vienna')
  .timelineRangeDays(45)
  .timelinePixelsPerDay(40);
html
<CoarTimelineView :builder="builder" />

How bars are positioned

Each logical event becomes one row, sorted by its FIRST bar's start ascending (group id ascending as tie-break — meta.__recurrence.seriesId for recurring events, event.id for standalone). The bar's geometry comes from:

  • Left = (event.start − windowStart) days × pixelsPerDay. Cross-zone timed events project into the calendar's timezone (display zone) before their date is taken, so a meeting scheduled at 23:00 Tokyo viewed from Vienna still lands on the correct visual day (~16:00 Vienna).
  • Width = duration in days × pixelsPerDay. All-day events use their [start, end) date range; timed events use start..end projected into the display zone; missing end defaults to start + 30 min (same fallback as the other views).
  • Top = row index × rowHeight.

Single-day all-day events get a one-day-wide bar (not zero). Multi-day all-day events span their full range. Timed events that fall entirely within one day in the display zone also get a one-day bar — sub-day precision is intentionally not in this view (use Day / Week for hour-grained scheduling).

Window clamping

The visible window is [cursor, cursor + timelineRangeDays). Events that fall outside are filtered out entirely; events that straddle the window get a clippedStart or clippedEnd flag on their row + a squared-off bar edge (no rounded corner on the clipped side). Renderers can use the flags to overlay a "continues" chevron.

ts
// In a custom #bar slot:
<template #bar="{ event, row }">
  <span v-if="row.clippedStart"></span>
  <span>{{ event.meta?.title }}</span>
  <span v-if="row.clippedEnd"></span>
</template>

Performance — row virtualization

The label column and bar area are vertically virtualized: only rows inside the viewport (plus a small buffer of 8 rows on each side) render to DOM. A 1000-task project plan still costs ~30-40 DOM rows worth of nodes regardless of total count; scroll and pan stay at ~constant frame cost.

Virtualization is automatic — no opt-in flag. The rowHeight setter controls the math: a uniform row height makes the visible-range computation O(1) (floor(scrollTop / rowHeight)), no per-row measurement required. Slot renderers (label, bar) only fire for visible rows, so expensive markup inside slots doesn't get instantiated for off-screen tasks.

See the /calendar-timeline-perf playground page for an interactive bench at 100 / 500 / 1 000 / 2 500 tasks.

Panning

Click-and-drag anywhere on the timeline (empty grid cells, the date axis, label column, or row whitespace — anything that isn't an interactive child like an event bar) to pan both axes at once. The cursor switches to grab over empty areas and to grabbing during the active pan. Pointer-capture keeps the pan alive even when the cursor leaves the timeline element (e.g. drags up into the page chrome).

Bar clicks are NOT hijacked — clicking an event bar still fires onEventClick. The pan handler walks up from e.target to check for button / a / input / select / textarea / role="button" and exits early when it finds one. Custom slots that render their own interactive elements inherit this behavior automatically.

Touch panning uses the same pointer pipeline (touch-action: none on the container), so finger-drag on tablets feels identical to mouse-drag on desktop.

Sizing the view

Three knobs control the visual density:

SetterDefaultEffect
timelineRangeDays(n)60How many days the view spans starting from cursor. next() / prev() step by this much.
timelinePixelsPerDay(p)56Horizontal density. 24 = quarter overview, 56 = month (date labels readable on one line), 96 = sprint detail.
timelineRowHeight(h)32Vertical row height — pick to match your bar content + density theme.
timelineLabelWidth(w)200Left-pane label-column width.
ts
// Quarter overview: 90 days, narrow bars
builder.timelineRangeDays(90).timelinePixelsPerDay(16);

// Sprint detail: 14 days, wide bars
builder.timelineRangeDays(14).timelinePixelsPerDay(64);

Slots

<CoarTimelineView> exposes three slots for custom rendering — all optional, with sensible plain-text defaults:

SlotScopeDefault
label{ row, event }meta.title ?? id of row.bars[0].event + occurrence-count badge (e.g. ×26) when row.isRecurring.
bar{ row, bar, event }meta.title over a meta.color-tinted bar. Fires once per bar (N times for recurring rows).
dateHeader{ date, isToday, isWeekend }Intl.DateTimeFormat "MMM d" in the header axis.

The row payload is a TimelineRow with { id, top, height, isRecurring, bars[] }. Each bar is a TimelineBar with { event, left, width, clippedStart, clippedEnd }. The event prop on the bar slot is a convenience alias for bar.event. For a recurring row, the label slot sees row.bars[0].event — the first occurrence in the window, which carries the series-level meta (title, color).

Inside <CoarCalendar>

The view-switcher includes a "Timeline" button by default. Same builder feeds the embedded <CoarTimelineView>; the timeline-specific setters (timelineRangeDays etc.) live on the shared builder and are no-ops outside the timeline view, same convention as timeRange for day/week.

ts
const { builder } = useCalendar();
builder
  .timelineRangeDays(90)
  .timelinePixelsPerDay(24);

useTimelineView<TMeta>()

ts
function useTimelineView<TMeta>(): {
  builder: CalendarBuilder<TMeta>;
  api: CalendarApi<TMeta>;
};

Returns a fresh standalone builder + its imperative api. Pre-sets view: 'timeline' and availableViews: ['timeline'] so the standalone view doesn't render an inactive view-switcher.

Builder setters (timeline-specific)

SetterArgumentDefaultNotes
timelineRangeDays(n)MaybeRefOrGetter<number>60Days the view spans starting from the cursor. Also the next() / prev() step.
timelinePixelsPerDay(p)MaybeRefOrGetter<number>56Horizontal density.
timelineRowHeight(h)MaybeRefOrGetter<number>32Per-event row height in pixels.
timelineLabelWidth(w)MaybeRefOrGetter<number>200Left-pane label-column width.
eventRenderer(r)EventRenderer<TMeta>Universal renderer — fires for timeline bars too (no per-layout discriminator here yet — use the dedicated #bar slot if you need the row geometry).

Universal setters (events, eventsLoader, series, seriesLoader, recurrenceEngine, timezone, locale, …) work identically to the other views.

layoutTimeline(events, options)

Pure layout helper exported from @cocoar/vue-calendar. Returns the same row geometry the view uses internally — useful for custom timeline implementations or tests:

ts
import { layoutTimeline, Temporal } from '@cocoar/vue-calendar';

const { rows, totalWidth, totalHeight } = layoutTimeline(events, {
  windowStart: Temporal.PlainDate.from('2026-06-01'),
  windowEnd:   Temporal.PlainDate.from('2026-08-01'),
  pixelsPerDay: 56,
  rowHeight: 32,
  displayZone: 'Europe/Vienna',
});

for (const row of rows) {
  // row: { id, top, height, isRecurring, bars[] }
  for (const bar of row.bars) {
    // bar: { event, left, width, clippedStart, clippedEnd }
  }
}

Imperative API

Same surface as the other views — see <CoarWeekView> Imperative API. next() / prev() step by timelineRangeDays; getVisibleRange() returns the [cursor, cursor + timelineRangeDays) window.

<CoarTimelineView> props

PropTypeDescription
builderCalendarBuilderRequired. From useTimelineView() (or share the one from useCalendar()).

Released under the Apache-2.0 License.