Skip to content

BigCalendar

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/big-calendar.json
See it liveA month of releases in a box of its own height: a busy day folds, a release is picked up and put down on another day.→

It is read for “is that week empty”. A list answers “what is on the 14th”; a month view is for the shape of the weeks. So the month holds its box: the weeks share the height, a day never grows past its share, and what a day has no room for folds into a count. The count opens the day in place - the same height, its lines scrolling inside it - so a busy Friday does not push the next week off the screen. One day is open at a time.

Give it a height. flex-1 in a screen, or a height of its own. The grid measures one day’s room and divides it into lines.

<BigCalendar
className="flex-1"
month={month} // any day of the month: '2026-09-01'
onMonthChange={setMonth}
renderDay={(date) =>
releasesOn(date).map((release) => (
<BigCalendarItem key={release.id} color={release.colour} onClick={() => open(release)}>
{release.title}
</BigCalendarItem>
))
}
moreLabel={(count) => t('calendar.more', { count })}
fewerLabel={t('calendar.fewer')}
/>

A line is one line. renderDay returns what a day holds, one node per line, and the fold is arithmetic: a line is twenty-four pixels, and lines stand edge to edge. BigCalendarItem is that line. A line of your own has to keep the height, and read BigCalendarDayContext for its tabIndex (below).

The month’s name and its arrows are yours. They belong to the screen’s head, in one row with its filters and actions. The grid asks for a new month only when the keyboard walks off its edge, through onMonthChange; without it the cursor stops at the edge.

The grid is named by its month. Without an aria-label it is called by the month it shows, “September 2026”, in the calendar’s locale; an aria-label replaces that.

Only the weeks the month touches are drawn - four to six. The days of the neighbouring months in them are real days on a quieter ground: renderDay is asked for them too, and something can be dropped on the 31st of August from the September page.

The keyboard is the Calendar’s. The grid is one tab stop and the arrows move a cursor through the days. Tab from a day goes into it and then out of the month - the lines of the other days are out of the tab order, so a month of forty releases is not forty tab stops.

Key
← → ↑ ↓ move a day or a week; off the grid, ask for the next month
PageUp PageDown move a month
Home End the ends of the week
Enter Space press the day (onDaySelect)
Tab into the day: its plus, its lines, its count
Escape fold the open day, from anywhere inside it

A day is named by what it draws, in order — “14 Clip Single 3 more”, with the hint while something is carried over it — and described by its whole date, “Monday, 14 September 2026”. The words on its face are what someone telling voice control what they see says (WCAG 2.5.3); the date says which 14th it is. The plus is not in the name, or every day would say it.

The week starts where the locale says. en-GB on Monday, en-US on Sunday. A product that wants Monday whatever the region says so in the tag - en-US-u-fw-mon - and a DatePicker beside the month then agrees with it.

Dragging is the product’s, and the grid helps without choosing a library. Every day carries data-day, and dayOf reads it back from any element inside a day, so the handlers go on the calendar rather than on forty-two cells:

<BigCalendar
onDragOver={(event) => { event.preventDefault(); setOver(dayOf(event.target)) }}
onDrop={(event) => move(carried, dayOf(event.target))}
dropTarget={over}
dropHint={over && t('calendar.lands', { count: countOn(over) })}
…
/>

A pointer drag that carries a copy of the line under the pointer asks dayOf(document.elementFromPoint(x, y)) - give the copy pointer-events: none so it does not answer instead of the day beneath it. dropTarget lights the day something would land on; dropHint says, at its foot, what landing there means.

A click on a day’s ground calls onDaySelect; a click on a line, the plus or the count inside it does not - no line has to stop the event so that opening a release does not also book the day under it.

dayOf(target) the day an element is in, or null
linesThatFit(height) how many lines fit in a day’s room
foldDay(count, lines) { shown, more } - the count takes a line of its own
LINE_HEIGHT 24: the height of a line and the pitch of the day, the floor of a pointer target

Props

Read from the types in ui/big-calendar.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

PropTypeDefault
onAddDaywith addDayLabel(date: string) => voidAdd something on this day: a plus in the corner of the day under the pointer, and the first stop of Tab from the day the cursor is on.
addDayLabelwith onAddDaystringNames the plus. Required with it: an icon with no name announces nothing.
monthrequiredstringAny day of the month to show.
onMonthChange(month: string) => voidAsked for when the keyboard walks off the grid; any day of the new month. Without it the cursor stops at the edge.
renderDayrequired(date: string) => readonly ReactNode[]What a day holds, one node per line, each with a key.
moreLabelrequired(count: number) => stringThe words of the count a full day folds into, given how many are folded: "3 more".
fewerLabelrequiredstringThe words that fold an open day again.
onDaySelect(date: string) => voidPressing a day - a click on its ground, or Enter on the cursor.
dropTargetstring | nullnullThe day something being carried would land on.
dropHintReactNodeWhat landing on dropTarget would mean, said at the foot of it.
localestringNames the months and the weekdays, and picks the first day of the week: en-GB starts on Monday, en-US on Sunday, en-u-fw-mon on Monday whatever the region. The application's language by default.

And every prop of React's HTMLAttributes<HTMLDivElement> except children, onSelect, passed on.

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 23 rules applied and passed, and none failed. The accessibility report has every item.

History

Each release that changed ui/big-calendar.tsx - the file a product copies - newest first.

  1. v0.38.0breaking
    • fix name a day by what it draws, and make a line a target by its size 586fc82
  2. v0.34.0
    • feat what has happened stays readable, and a day says what it holds cae3dab
    • feat a month at a time, holding the box it is given 36af128