ChronousDocumentation

Guides

Layout & lanes

Overlapping events packed into columns, long events promoted to bars, and the “+2 more” cut-off.

Boxes: events inside the grid

Overlapping events are packed into columns the way a calendar draws them, one day at a time. Every box arrives with its position and size already computed, as fractions of the day.

Type
type CalendarBox<TData = unknown> = {
  event: TimedEntry<TData>
  start: IsoDateTime
  end: IsoDateTime
  startMinute: number
  endMinute: number
  minutes: number
  top: number
  height: number
  left: number
  width: number
  column: number
  columns: number
  span: number
  continuesBefore: boolean
  continuesAfter: boolean
}
Packing
import { buildCalendar } from '@midstem/chronous'
import type { EventInput } from '@midstem/chronous'

const events: EventInput[] = [
  { id: 'a', start: '2026-03-18T09:00', duration: 'PT2H' },
  { id: 'b', start: '2026-03-18T09:30', duration: 'PT1H' },
  { id: 'c', start: '2026-03-18T14:00', duration: 'PT1H' },
]

const calendar = buildCalendar(
  { view: 'day', currentDate: '2026-03-18', timeZone: 'Europe/Kyiv' },
  events,
)

console.log(
  calendar.days[0].boxes.map(box => [box.event.id, box.left, box.width]),
)
// [ [ 'a', 0, 0.5 ], [ 'b', 0.5, 0.5 ], [ 'c', 0, 1 ] ]
// a and b overlap and split the day in two; c has nothing beside it.
  • Events that overlap form a cluster. The cluster is cut into as few columns as it needs, and each event then widens to the right until it meets a neighbour — an event with nothing beside it takes the full width.
  • An event is clipped to every day it touches, so one event crossing midnight is placed once per day with continuesBefore and continuesAfter saying where it carries on.
  • minutes is the real elapsed length of the clipped piece, which is not always its height: on 29 March 2026 in Europe/Kyiv an event from 01:00 to 04:00 is drawn three hours tall and reports two.
tsx
import { createCalendarComponents } from '@midstem/chronous-react'
import type { CalendarRange, EventInput } from '@midstem/chronous-react'

type EventData = { title: string }

const Calendar = createCalendarComponents<EventData>()

const range: CalendarRange = {
  view: 'week',
  currentDate: '2026-03-18',
  timeZone: 'Europe/Kyiv',
}

const events: EventInput<EventData>[] = [
  // 22:00 to 02:00: one event, placed once on each day it touches
  {
    id: 'deploy',
    start: '2026-03-18T22:00',
    end: '2026-03-19T02:00',
    data: { title: 'Deploy window' },
  },
]

export const Board = () => (
  <Calendar.Root range={range} events={events}>
    <Calendar.TimeGrid hourHeight={48}>
      <Calendar.DayColumns className="column">
        <Calendar.TimedEvents className="event">
          {({ event, box }) => (
            <>
              {box.continuesBefore && '↑ '}
              {event.data?.title}
              {box.continuesAfter && ' ↓'}
            </>
          )}
        </Calendar.TimedEvents>
      </Calendar.DayColumns>
    </Calendar.TimeGrid>
  </Calendar.Root>
)

Lanes: events above the grid

Long events are drawn as bars above the grid instead of inside it. Every all-day event gets a bar; a timed event gets one when it covers twenty-four hours or more by the wall clock, and it then leaves the grid entirely.

Type
type CalendarBar<TData = unknown> = {
  event: CalendarEntry<TData>
  start: IsoDate
  end: IsoDate
  startDay: number
  endDay: number
  dayCount: number
  lane: number
  lanes: number
  left: number
  width: number
  continuesBefore: boolean
  continuesAfter: boolean
}
  • A range is cut into lane rows: a month grid breaks at every week, so a bar never crosses a grid row, and every other view is one row.
  • Bars read across the row, longest first, and each takes the lowest free lane. Every bar in a row reports the same lanes count, so a row can be sized before it is drawn.
  • startDay and endDay are day indices inside the row, endDay exclusive, and left / width are the same span as fractions of the row.

Overflow, and the empty all-day row

MonthRows takes maxLanes and the laneHeight its bars are drawn at, because both are shared between siblings. MonthAllDayEvents then stops drawing bars past that lane, and every MonthDays cell is handed the bars that cover it — bars for all of them, hiddenBars for the ones the cut-off dropped, and lanes counting only what is drawn. That is the whole “+2 more” affordance.

tsx
import { createCalendarComponents } from '@midstem/chronous-react'
import type { CalendarRange, EventInput } from '@midstem/chronous-react'

type EventData = { title: string }

const Calendar = createCalendarComponents<EventData>()

const LANE_HEIGHT = 20

const range: CalendarRange = {
  view: 'month',
  currentDate: '2026-03-18',
  timeZone: 'Europe/Kyiv',
}

const events: EventInput<EventData>[] = ['one', 'two', 'three', 'four'].map(
  (title, index) => ({
    id: title,
    start: '2026-03-16',
    end: '2026-03-20',
    data: { title: `Release ${index + 1}` },
  }),
)

export const Month = () => (
  <Calendar.Root range={range} events={events} locale="en-GB">
    <Calendar.MonthGrid className="month">
      <Calendar.MonthRows
        className="row"
        maxLanes={3}
        laneHeight={LANE_HEIGHT}
      >
        <Calendar.MonthDays className="day">
          {({ dayLabel, lanes, hiddenBars }) => (
            <>
              <span>{dayLabel}</span>
              {/* room for the bars that float above the cells */}
              <div style={{ height: lanes * LANE_HEIGHT }} />
              {hiddenBars.length > 0 && (
                <button className="more">+{hiddenBars.length} more</button>
              )}
            </>
          )}
        </Calendar.MonthDays>

        <Calendar.MonthAllDayEvents className="bar">
          {({ event }) => event.data?.title}
        </Calendar.MonthAllDayEvents>
      </Calendar.MonthRows>
    </Calendar.MonthGrid>
  </Calendar.Root>
)

AllDayRow renders nothing when the range holds no all-day event, which frees the space but moves the grid under it as you step between weeks. minLanes holds the row open instead, and a row that needs more lanes still gets them.

tsx
import { Calendar } from '@midstem/chronous-react'
import type { CalendarRange, EventInput } from '@midstem/chronous-react'

const range: CalendarRange = {
  view: 'week',
  currentDate: '2026-03-18',
  timeZone: 'Europe/Kyiv',
}

// No all-day event this week — without minLanes the strip would vanish
// and the grid below it would jump as you step between weeks.
const events: EventInput[] = [
  { id: 'standup', start: '2026-03-18T09:00', duration: 'PT30M' },
]

export const Board = () => (
  <Calendar.Root range={range} events={events} locale="en-GB">
    <Calendar.AllDayRow className="all-day" minLanes={1} gutterCell="all-day">
      <Calendar.AllDayEvents className="bar" />
    </Calendar.AllDayRow>

    <Calendar.TimeGrid hourHeight={48}>
      <Calendar.DayColumns className="column">
        <Calendar.TimeSlots className="line" />
        <Calendar.TimedEvents className="event" />
      </Calendar.DayColumns>
    </Calendar.TimeGrid>
  </Calendar.Root>
)