calendar-tsx

Timeline view

Resources as rows on a horizontal time axis, with stacked events.

Resources as rows on a horizontal time axis. Overlapping events in a row stack, and the row grows to fit them. An event that crosses midnight is one bar.

Loading the preview…

Live, with sample events. Click an event to see what it reports.
import { timelineView } from '@calendar-tsx/core';

timelineView({ days: 7, groupBy: 'resource' }); // a week, one row per resource
timelineView({ days: 1, groupBy: 'resource', slotMinutes: 30 }); // a day
timelineView({ period: 'month', groupBy: 'resource', slotMinutes: 1440, minSlotWidth: '6rem' });
timelineView({ period: 'year', slotMinutes: 1440, minSlotWidth: '6rem' });

Nested resources

Set a resource's parentId to another resource ID. Parents remain schedulable resources. Events, backgrounds and availability use direct resource IDs; collapsing a parent hides its descendants without moving their events into the parent row.

Register resourceExpansionFeature to enable expansion actions. Resources start expanded by default:

import { calendarFeatures, calendarViews, resourceExpansionFeature, timelineView } from '@calendar-tsx/core';

const features = calendarFeatures({ tree: resourceExpansionFeature({ initiallyExpanded: true }) });
const views = calendarViews({
  features,
  views: {
    timeline: timelineView({ days: 7, groupBy: 'resource' }),
    compact: timelineView({ days: 7, groupBy: 'resource', features: { tree: { initiallyExpanded: false } } }),
  },
});

Pass features and views to the calendar. Call calendar.api.resourceExpansion.toggle(id) or setExpanded(id, boolean). Leaf actions do nothing; unknown IDs throw. State is a resourceExpansion map of resource IDs to Boolean overrides. Omitted IDs use that view's initiallyExpanded default. Overrides persist by ID across navigation, view switches and resource replacement, including while an ancestor is collapsed.

For controlled expansion, pass state: { resourceExpansion } and apply the map emitted through onResourceExpansionChange. Rows change after acknowledgment. External stores use atoms: { resourceExpansion }. Without the feature, hierarchical Timeline rows remain fully expanded.

Render TimelineViewResourceToggle inside TimelineViewResource, passing row and the typed calendar instance. The native button exposes aria-expanded, supplies an accessible label, and renders only for parents. Supply its visible text or icon as children. preventDefault() skips the action. The existing Timeline examples show this composition for React and Solid. Labels and content remain consumer-owned. The parts retain the labelled-region semantics and native tab order; they do not implement treegrid arrow-key navigation.

Expansion affects only Timeline. Hidden resources remain in scheduling constraints. Hit detection and draft previews use visible rows; programmatic operations can still target a hidden resource. An active preview disappears if its target is collapsed and returns if expanded again.

Settings

Every view factory takes exactly one of days, weeks, months or period. Counts are positive integers.

DurationRangenext() / prev()
days: NN dates from date; 7 keeps week alignment.N days.
weeks: NN whole weeks from firstDayOfWeek.N weeks, keeping the weekday.
months: NN calendar months from the first of the anchor month.N months, landing on the first day.
period'month', 'quarter' or 'year', aligned to its calendar boundary.One period, landing on its first day.
SettingTypeDefaultDescription
groupBy'resource' or ['resource']noneOne row per resource. Without it, the layout has one row.
slotMinutesnumber60Positive integer slot length; the final slot may be shorter.
minSlotWidthstringnonePassed through as the slot columns' minimum width, such as '3rem'.
minEventMinutesnumberhalf of slotMinutesMinimum display duration, also used for stacking. 0 turns it off.
hiddenDaysISO weekdaysnoneSee hidden weekdays.
timeWindow{ startMinutes, endMinutes }[0, 1440)See visible hours.

Set minSlotWidth on month and year timelines to keep daily columns readable; the supplied themes then scroll the timeline horizontally.

  • An event appears in every row whose resource it names.
  • All-day events have no separate row. They stack with timed events, from the start of their first day to the start of the day after their last.
  • A resource's availability is shaded as in the resource view.
  • Background events shade the track without taking stack rows. Register backgroundFeature to add background layers.

Brief events

A brief timed event is displayed at least minEventMinutes long, half a slot by default, measured in visible clock minutes across hidden dates and daylight-saving changes. With daily slots and minSlotWidth: '6rem', a bar is then at least 3rem wide. Stacking uses the enlarged span, so neighboring brief events stay separately selectable. The event keeps its real start and end for labels. Bars clip at the visible range's end, where one can be narrower than the minimum. All-day events keep their date spans. Keep CSS padding and borders inside the computed width: a CSS-only minimum would enlarge bars without restacking them.

Hidden weekdays

hiddenDays omits ISO weekdays. Use the exported DayOfWeek constants (DayOfWeek.Monday is 1, DayOfWeek.Sunday is 7) or the numbers; the same values work for firstDayOfWeek and weekly background daysOfWeek.

import { DayOfWeek, timeGridView } from '@calendar-tsx/core';

timeGridView({ weeks: 1, hiddenDays: [DayOfWeek.Saturday, DayOfWeek.Sunday] }); // a workweek

Hidden weekdays change neither the nominal period, the inclusive fetch range nor the navigation step. Hiding all seven weekdays throws. When every date in the range is hidden, such as a one-day view on a hidden Saturday, the range shows its dates anyway.

Visible hours

timeWindow limits the clock hours shown, in integer clock minutes: 0 <= startMinutes < 1440 and startMinutes < endMinutes <= startMinutes + 1440. The default is [0, 1440).

timeGridView({ days: 7, timeWindow: { startMinutes: 480, endMinutes: 1080 } }); // 08:00 to 18:00
timelineView({ days: 1, timeWindow: { startMinutes: 1320, endMinutes: 1800 } }); // 22:00 to 06:00

An overnight window belongs to its starting date, so hiding Tuesday still shows Monday's Tuesday-morning portion. All-day items keep their calendar dates. slotMinutes need not divide the window; the final slot keeps its actual size, and each slot's dayOffset labels after-midnight times. Fetch events for navigation's inputRange, which adds the following date when an overnight window needs it.

Example

useLayout narrows the layout for <Show>; see narrowing the layout.

timeline-view.tsx
export const Timeline = (props: { calendar: DayCalendar }) => {
  const timeline = useLayout('timeline');

  return (
    <Show when={timeline()}>
      {timeline => (
        <TimelineView model={timeline()}>
          <TimelineViewHeader>
            <For each={timeline().days}>
              {day => (
                <TimelineViewDayHeader day={day}>
                  <TimelineViewDayTrigger calendar={props.calendar} day={day} view='day'>
                    {day.date.toLocaleString('en-US', { weekday: 'short', day: 'numeric' })}
                  </TimelineViewDayTrigger>
                </TimelineViewDayHeader>
              )}
            </For>
            <Show when={timeline().days.some(day => day.colSpan > 1)}>
              <For each={timeline().slots}>
                {slot => (
                  <TimelineViewSlotLabel slot={slot}>
                    {slot.time.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit' })}
                  </TimelineViewSlotLabel>
                )}
              </For>
            </Show>
          </TimelineViewHeader>
          <TimelineViewBody>
            <For each={timeline().rows}>
              {row => (
                <TimelineViewRow row={row}>
                  <TimelineViewResource row={row}>
                    <Show when={props.calendar.api.resourceExpansion}>
                      {expansion => (
                        <TimelineViewResourceToggle row={row} calendar={{ api: { resourceExpansion: expansion() } }}>
                          <span aria-hidden='true'>{row.isExpanded ? '−' : '+'}</span>
                        </TimelineViewResourceToggle>
                      )}
                    </Show>
                    {row.resource?.title}
                  </TimelineViewResource>
                  <TimelineViewLane row={row}>
                    <For each={row.backgrounds}>{background => <TimelineViewBackground background={background} />}</For>
                    <For each={row.backgroundEvents}>
                      {segment => <TimelineViewBackgroundEvent segment={segment} />}
                    </For>
                    <For each={row.unavailable}>{segment => <TimelineViewUnavailable segment={segment} />}</For>
                    <For each={row.segments}>
                      {segment => (
                        <TimelineViewEvent
                          segment={segment}
                          title={formatEvent(segment.event)}
                          aria-label={formatEvent(segment.event)}
                        >
                          {segment.event.data.title}
                        </TimelineViewEvent>
                      )}
                    </For>
                  </TimelineViewLane>
                </TimelineViewRow>
              )}
            </For>
            <TimelineViewNowIndicator model={timeline()} />
          </TimelineViewBody>
        </TimelineView>
      )}
    </Show>
  );
};

Model

layout is { kind: 'timeline', coordinates, slotUnits, slotMinutes, hourOffset, days, slots, rows, now, minSlotWidth }. Every start and span is a fraction of the concatenated visible windows. Dates keep equal widths across daylight-saving transitions.

Time grids and timelines use a fixed clock axis in timeZone: 09:00 sits at the 09:00 position on daylight-saving days too, and missing hours stay on the axis. The two occurrences of a repeated hour share clock positions; overlapping events then take separate lanes or stack rows. A bar that crosses a transition encloses every clock position its real interval visits, and segment.event keeps the real timestamps and offsets. Each timed segment's fragments keeps its exact coverage (offsets, occurrences and the reason each fragment stops) apart from that enclosing rectangle.

The core builds one coordinate plan (layout.coordinates) per stable view definition and reuses it across event, resource and clock updates. Navigation, timeZone, firstDayOfWeek, hiddenDays or timeWindow changes rebuild it.

  • days holds one { key, date, isToday, colStart, colSpan, start, span } per day, for the header. colStart and colSpan count slot columns.
  • slots holds { key, date, time, dayOffset, col, start, span } per slot. Fractional bounds give partial slots their actual width.
  • slotMinutes is each slot's length, and hourOffset the slot units from each day's window start to its first hour.
  • rows holds one row per resource, or a single row without groupBy.
  • now is the current time's fraction of the visible scale, omitted on hidden dates and outside the range.
Row fieldDescription
resourceThe resource; absent without groupBy.
depthZero-based depth in the resource hierarchy.
hasChildrenWhether the resource has direct children.
isExpandedWhether descendants can be visible; false for leaves.
segmentsThe row's events.
backgroundEventsTimed and all-day background-event coverage, without stack rows.
backgroundsLayer coverage, only with backgroundFeature.
rowsStack depth: how many events stack at the busiest point.
unavailableFractions of the range outside the resource's availability.
isUnavailableThe resource has no availability in the range.
Segment fieldDescription
eventThe event, in the calendar's time zone.
start, spanDisplay position and duration as fractions of the visible range.
fragmentsExact coverage before clock enclosure and minimum sizing.
rowZero-based stack row; each event takes the lowest row free for it.
isAllDayThe event is all-day.
isMultiDayThe event covers more than one date, before clipping.
continuesBefore, continuesAfterThe event extends past the range.

Stacking follows event order. Display spans that only touch don't overlap.

Background events and layers are fractions of the track and take no stack rows. Timed shading clips to visible anchor windows and can split at dates or DST gaps; all-day shading follows anchor dates, including their overnight portions. Hidden weekdays get no shading. Layer ranges merge before inverse gaps are computed, and range callbacks receive inputRange. In a resource timeline, unassigned background events shade every row, and assigned ones and resource-limited layers shade only matching rows. An ungrouped timeline shows every background event.

Parts

PartModel propElementNotes
TimelineViewmodeldivrole='region', labelled "Timeline". Scrolls both ways. --cal-slot-columns sizes header slots.
TimelineViewHeadernonedivSticky; holds the day headers and the slot labels.
TimelineViewDayHeaderdaydivdata-today, data-date, data-weekday.
TimelineViewDayTriggercalendar, day, viewbuttonNavigates through calendar to view on the day. See day triggers.
TimelineViewSlotLabelslotdivslot is the time slot, not the HTML attribute.
TimelineViewBodynonedivHolds the rows and the now indicator.
TimelineViewRowrowdivdata-unavailable, data-resource-id.
TimelineViewResourcerowdivThe resource cell; sticks to the start edge.
TimelineViewResourceTogglecalendar, rowbuttonExpands/collapses descendants; aria-expanded, default label; omitted for leaves.
TimelineViewLanerowdivHolds the row's unavailable time and events.
TimelineViewUnavailablesegmentdivaria-hidden.
TimelineViewBackgroundbackgrounddivaria-hidden, data-layer, data-kind; render inside TimelineViewLane.
TimelineViewBackgroundEventsegmentdivaria-hidden, continuation flags; render inside TimelineViewLane.
TimelineViewEventsegmentbuttondata-all-day, data-multi-day, data-continues-before, data-continues-after.
TimelineViewNowIndicatormodeldivaria-hidden. Renders only while now is inside the range.

The parts render no slot cells; themes draw the slot lines as a background of each lane.

Rows expose data-depth, data-parent-id and data-expanded. Resource cells expose data-depth, data-expanded and --cal-resource-depth. Themes indent each level by --calendar-resource-indent (1rem by default); override that token to change the spacing. Toggle icons inherit the theme's text color and focus style.

With one slot per day, omit the slot-label row; the date header is enough. For subday slots, include minutes in the labels. The examples put the full event dates and times in title and aria-label, so clipped bar text stays available.