<bmx-calendar>
A diary: what is happening, on which days.
14 properties · 4 events · 3 methods · 18 parts
Example
end is inclusive here: “the 9th to the 11th” is three days, because that is what
the person typing it means. The iCalendar convention is exclusive and is right for a machine, which can subtract a
day.
grid at all; without it the events in a month view are either forty-two extra tab stops or unreachable.
weekBars in src/core/diary.ts, which is the same lane-packing function a day column will
use for overlapping meetings: a three-day event crossing a week and two meetings overlapping at half past ten look
nothing alike and are the same problem.
day-start and day-end window the view, because a diary drawn midnight to midnight spends
two thirds of its height on hours nobody has meetings in. Tuesday's early call starts at half past six and
the grid begins at eight — it is clipped to the top edge and drawn open, and still announced at half
past six, because the reader is being told when the meeting is rather than where the box was cut. The
Conference has no time of day, so it has no position on a time axis: it is in the strip and never
in the column.
+2 more is drawn and never read: what it announces is “2 more: One to one, Retro”. And a
limit that would hide exactly one hides none, because a +1 takes the room of the event it replaced and
says less — the same rule, and the same function, as the chip set and the avatar group.
editable lets a reader move and resize. The keyboard is the real gesture and the pointer is
the second one: Enter steps into the day, Space picks a meeting up, the arrows move
it a quarter hour or a day, Shift with them resizes, Enter drops it and Esc puts
it back — and every step is announced, because a block that moves on screen and says nothing is a mode with
no output. Both routes are the same state machine in src/core/diary.ts, so they cannot disagree.
“Payroll” is readonly and refuses to be picked up at all.
events array is untouched; on release the component emits bmxEventChange with both the
old times and the new ones, and the page reassigns events if it accepts. A page that ignores the
event has a calendar whose blocks always snap back, which is the honest behaviour for a page that has not agreed
to the change. To refuse a drop before the reader lets go — a day that is closed, a room already
taken — assign a dropFilter function from script; the block is then drawn and announced as
refused while it is still in their hand.
Show markup
<bmx-calendar
label="Team diary"
date="2026-09-07"
max-per-day="3"
working-days="1,2,3,4,5"
events='[
{"id":"1","title":"Standup","start":"2026-09-07T09:00","end":"2026-09-07T09:15"},
{"id":"2","title":"Design review","start":"2026-09-07T11:00","end":"2026-09-07T12:00"},
{"id":"3","title":"Lunch with Priya","start":"2026-09-07T12:30","end":"2026-09-07T13:30"},
{"id":"4","title":"One to one","start":"2026-09-07T15:00","end":"2026-09-07T15:30"},
{"id":"5","title":"Retro","start":"2026-09-07T16:00","end":"2026-09-07T17:00"},
{"id":"6","title":"Conference","start":"2026-09-09","end":"2026-09-11","allDay":true},
{"id":"7","title":"Board meeting","start":"2026-09-15T10:00","end":"2026-09-15T12:00"},
{"id":"8","title":"Release","start":"2026-09-24","end":"2026-09-25","allDay":true}
]'
></bmx-calendar>
<div class="row" style="margin-block-start: 1.5rem">
<span class="note">
<strong>The whole diary is one element with a JSON attribute</strong> and no script — which is what a Razor
page, a Blazor component, a Django template or an htmx swap can actually produce. An all-day event's
<code>end</code> is <em>inclusive</em> here: “the 9th to the 11th” is three days, because that is what
the person typing it means. The iCalendar convention is exclusive and is right for a machine, which can subtract a
day.
</span>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
<strong>Use the keyboard.</strong> Tab reaches the grid once, not forty-two times. The arrows move a day, up and
down move a week, and Page Up and Page Down move a month — then <kbd>Enter</kbd> steps <em>into</em> a day so
the events in it are reachable, and <kbd>Escape</kbd> comes back out. That two-mode model is what lets a month be a
<code>grid</code> at all; without it the events in a month view are either forty-two extra tab stops or unreachable.
</span>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
The <strong>Conference</strong> is one bar across three days rather than three pieces that happen to line up —
and if it crossed a Sunday it would draw as two bars, each open at the end where it continues. That clipping is
<code>weekBars</code> in <code>src/core/diary.ts</code>, which is the same lane-packing function a day column will
use for overlapping meetings: a three-day event crossing a week and two meetings overlapping at half past ten look
nothing alike and are the same problem.
</span>
</div>
<div class="row" style="margin-block-start: 1.5rem">
<bmx-calendar
label="This week"
view="week"
date="2026-09-07"
day-start="08:00"
day-end="18:00"
working-days="1,2,3,4,5"
events='[
{"id":"1","title":"Standup","start":"2026-09-07T09:00","end":"2026-09-07T09:15"},
{"id":"2","title":"Design review","start":"2026-09-07T09:00","end":"2026-09-07T10:00"},
{"id":"3","title":"Sync","start":"2026-09-07T09:30","end":"2026-09-07T10:30"},
{"id":"4","title":"Deep work","start":"2026-09-07T14:00","end":"2026-09-07T16:00"},
{"id":"5","title":"Early call","start":"2026-09-08T06:30","end":"2026-09-08T09:00"},
{"id":"6","title":"Conference","start":"2026-09-09","end":"2026-09-11","allDay":true},
{"id":"7","title":"Keynote","start":"2026-09-09T10:00","end":"2026-09-09T11:30"}
]'
></bmx-calendar>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
<strong>The hour rail is decoration and is not announced.</strong> The obvious markup for a time grid — a row
per half hour, a cell per day — is a lie the moment two meetings overlap or one runs from 9:15 to 10:45:
there is no honest cell for it to be in, and a reader arrowing through that table hears fragments of meetings in
slots they do not start in. So each day is <em>one</em> cell whose name is its events in time order, and
<kbd>Enter</kbd> steps into it for the detail. Left and right walk the days; up and down move between the all-day
strip and the column, because that is what is above and below in a week.
</span>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
<code>day-start</code> and <code>day-end</code> window the view, because a diary drawn midnight to midnight spends
two thirds of its height on hours nobody has meetings in. <strong>Tuesday's early call starts at half past six and
the grid begins at eight</strong> — it is clipped to the top edge and drawn open, and still announced at half
past six, because the reader is being told when the meeting is rather than where the box was cut. The
<strong>Conference</strong> has no time of day, so it has no position on a time axis: it is in the strip and never
in the column.
</span>
</div>
<div class="row" style="margin-block-start: 1.5rem">
<bmx-calendar
label="What is coming up"
view="agenda"
date="2026-09-07"
agenda-days="30"
events='[
{"id":"1","title":"Standup","start":"2026-09-07T09:00"},
{"id":"6","title":"Conference","start":"2026-09-09","end":"2026-09-11","allDay":true},
{"id":"7","title":"Board meeting","start":"2026-09-15T10:00"}
]'
></bmx-calendar>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
The agenda is a <em>different view</em> rather than a stylesheet over the same markup, because it is a different
thing to a screen reader: a month is a grid, where the position of a cell means “Tuesday” and
“next week”, and an agenda has no second dimension at all. It also leaves empty days out, which is the
whole difference between an agenda and a very thin month — somebody asking what is coming up should not have
to scroll past twenty empty rows to find out.
</span>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
<code>+2 more</code> is drawn and never read: what it announces is “2 more: One to one, Retro”. And a
limit that would hide exactly one hides none, because a <code>+1</code> takes the room of the event it replaced and
says less — the same rule, and the same function, as the chip set and the avatar group.
</span>
</div>
<div class="row" style="margin-block-start: 1.5rem">
<bmx-calendar
label="Rescheduling"
view="day"
date="2026-09-07"
day-start="08:00"
day-end="18:00"
editable
drag-step="15"
events='[
{"id":"1","title":"Standup","start":"2026-09-07T09:00","end":"2026-09-07T09:15"},
{"id":"2","title":"Design review","start":"2026-09-07T09:00","end":"2026-09-07T10:00"},
{"id":"3","title":"Sync","start":"2026-09-07T09:30","end":"2026-09-07T10:30"},
{"id":"4","title":"Deep work","start":"2026-09-07T14:00","end":"2026-09-07T16:00"},
{"id":"5","title":"Payroll","start":"2026-09-07T16:30","end":"2026-09-07T17:00","readonly":true}
]'
></bmx-calendar>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
<code>editable</code> lets a reader move and resize. <strong>The keyboard is the real gesture and the pointer is
the second one</strong>: <kbd>Enter</kbd> steps into the day, <kbd>Space</kbd> picks a meeting up, the arrows move
it a quarter hour or a day, <kbd>Shift</kbd> with them resizes, <kbd>Enter</kbd> drops it and <kbd>Esc</kbd> puts
it back — and every step is announced, because a block that moves on screen and says nothing is a mode with
no output. Both routes are the same state machine in <code>src/core/diary.ts</code>, so they cannot disagree.
“Payroll” is <code>readonly</code> and refuses to be picked up at all.
</span>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
<strong>Nothing changes until the drop.</strong> The moving block is drawn from a proposal and your
<code>events</code> array is untouched; on release the component emits <code>bmxEventChange</code> with both the
old times and the new ones, and the page reassigns <code>events</code> if it accepts. A page that ignores the
event has a calendar whose blocks always snap back, which is the honest behaviour for a page that has not agreed
to the change. To refuse a drop <em>before</em> the reader lets go — a day that is closed, a room already
taken — assign a <code>dropFilter</code> function from script; the block is then drawn and announced as
refused while it is still in their hand.
</span>
</div>
<bmx-calendar
label="Team diary"
date="2026-09-07"
events='[{"id":"1","title":"Standup","start":"2026-09-07T09:00","end":"2026-09-07T09:15"}]'
></bmx-calendar>
IT IS NOT bmx-date-picker GROWN UP
They share the arithmetic in src/core/calendar.ts and nothing else. The
picker asks "which day?" and is finished the moment it is told; a diary asks
"what is happening?", and everything hard about it comes from the answer
overlapping with itself. That difference is src/core/diary.ts, which is
where the layout lives - as arithmetic over intervals, testable without a
browser, because the layout is where every calendar component goes wrong.
A MONTH IS A GRID AND AN AGENDA IS A LIST
Not a styling choice: in a month view the position of a cell carries
meaning - this column is Tuesday, this row is next week - so it is a grid,
with the arrow keys moving in two dimensions and Enter stepping into a cell
to reach the events inside it. An agenda has no second dimension. It is a
sequence of days with things under them, and it is announced as one, which is
why it is a separate view rather than a stylesheet over the same markup.
The agenda is also the answer to a question the month view cannot answer well: a reader who wants to know what is next should not have to scan a six-by-seven grid for the first non-empty cell. It leaves empty days out entirely, which is the whole difference between an agenda and a thin month.
A TIME GRID IS NOT A TABLE OF HALF-HOURS
The week and day views draw a time axis, and the obvious markup for one - a row per half hour, a cell per day - is a lie the moment two events overlap or one runs from 9:15 to 10:45. There is no honest cell for it to be in, so a reader arrowing through that table hears fragments of meetings in slots they do not start in.
So the axis is drawn and not announced: the hour rail is aria-hidden
decoration, and each day is one cell whose name is its events in time order -
"Monday 7 September, 5 events, Standup 9:00 to 9:15, ...". A reader gets the
day's shape in one utterance and steps into it for the detail, which is what
they came for. Position is left to the eyes it is drawn for.
The strip above it is the other half of the same rule: an event with no time
of day has no position on a time axis, and drawing a conference at midnight
is the classic way to get this wrong. All-day and multi-day events go into
their own row, laid out by weekBars - the same function the month view uses,
because it is the same problem.
+2 MORE IS A TRUNCATION, AND IS NOT READ OUT
The fourth time this library has met the same rule - after the badge's
99+, the avatar's initials and the avatar group's +2 - and it is answered
the same way: the cell draws the short form aria-hidden and what is
announced names the events that did not fit. A limit that would hide exactly
one hides none, which is splitAt's rule, shared rather than rewritten.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
agendaDays |
agenda-days |
number |
30 |
How many days the agenda covers. |
date |
date |
string |
— | The month or the day the view opens on, as YYYY-MM-DD. Assignable, so a page can drive the calendar from its own controls. |
dayEnd |
day-end |
string |
'20:00' |
The last hour those views draw, as HH:MM. |
dayStart |
day-start |
string |
'07:00' |
The first hour the week and day views draw, as HH:MM. A window rather than a whole day, because a diary that draws midnight to midnight spends two thirds of its height on hours nobody has meetings in. Events outside it are not dropped - they are clipped to the edge and drawn open, so the reader can see there is more. |
dragStep |
drag-step |
number |
15 |
The smallest move a drag makes, in minutes. Fifteen, because a diary is booked in quarter hours and a drag that lands on 09:37 is a drag the reader has to correct. A month view ignores it: the unit there is a day. |
dropFilter |
property only | (detail: BmxCalendarChangeDetail) => boolean |
— | Whether a drop may land here, asked continuously while the drag moves. A page can always refuse a change afterwards by ignoring bmxEventChange - that is the one-way flow, and it works from every stack including the ones that can only write attributes. This is the other half: a rule the reader can see before they let go, so a block that may not go on a Sunday is drawn refused and announced as refused while it is still in their hand, rather than snapping back a moment after they thought they had put it somewhere. It is a function, so it is assigned from script - the React, Vue and Angular wrappers set properties and this arrives with them. A Razor or Blazor page that can only write attributes uses the event instead, and src/core/markup.ts refuses a string here rather than evaluating one. js diary.dropFilter = ({ start }) => !start.endsWith('-13'); |
editable |
editable |
boolean |
false |
Whether the reader may move and resize events. Off by default, because most calendars on most pages are a picture of a diary rather than a diary you can rearrange, and a component that let a reader drag a meeting the server will refuse has told them a lie. It is not draggable: that is a global HTML attribute with a meaning of its own, and a @Prop() of that name would fight the platform for it. |
events |
property only | BmxCalendarEvent[] |
[] |
The events. Read through src/core/markup.ts, so a JSON attribute written by a Razor page, a Blazor component or an htmx swap is the list it looks like rather than a string this component tries to map over. See defect 45. |
firstDayOfWeek |
first-day-of-week |
number |
— | Which day a week starts on, 0 for Sunday. Falls back to the locale's. |
label |
label |
string |
— | What this diary is. Announced as the grid's name. |
locale |
locale |
string |
— | The locale for month and weekday names. Falls back to the document's. |
maxPerDay |
max-per-day |
number |
3 |
The most events to draw in one month cell before the rest collapse. |
view |
view |
BmxCalendarFace |
'month' |
Which face to draw. |
workingDays |
working-days |
number[] | string |
[] |
Which weekdays are working days, 0 for Sunday. Read through src/core/markup.ts as a comma-separated list, because working-days="1,2,3,4,5" is what a template can write. |
Events
| Event | Detail | Description |
|---|---|---|
bmxChange |
{ readonly date: string; readonly view: BmxCalendarFace; } |
The view or the month changed. |
bmxDaySelect |
BmxCalendarDayDetail |
A day was chosen. |
bmxEventChange |
BmxCalendarChangeDetail |
An event was dropped somewhere new. Emitted once, on the drop - never while the pointer is moving. The component does not touch events: the page reassigns it and the calendar redraws, which is the one-way flow every other component here uses and the only shape that works from React, from Blazor and from a plain page at the same time. A page that ignores this event has a calendar whose blocks always snap back, which is the honest behaviour for a page that has not agreed to the change. |
bmxSelect |
BmxCalendarEventDetail |
An event was chosen. |
Methods
| Method | Signature | Description |
|---|---|---|
next |
next() => Promise<void> |
Move to the next month, or the next span of agenda days. |
previous |
previous() => Promise<void> |
Move back. |
today |
today() => Promise<void> |
Return to today. |
Slots
| Slot | Description |
|---|---|
empty |
Shown when there is nothing in view at all. |
CSS shadow parts
| Part | Description |
|---|---|
agenda |
The agenda list. |
agenda-day |
One day of it. |
allday |
One day's cell in the all-day strip. |
bar |
A multi-day event drawn across the week. |
column |
One day's column of timed events. |
day |
One day cell. |
daynumber |
The number inside it. |
entry |
One timed event inside a cell. |
grid |
The month grid. |
grip |
|
header |
The bar with the month name and its controls. |
hour |
One hour rule across a column. |
live |
|
more |
The "+2 more" control. |
now |
The line marking the current time. |
timegrid |
The week or day time grid. |
title |
|
weekday |
One weekday heading. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-calendar-bar-background |
The fill of a multi-day bar. |
--bmx-calendar-bar-color |
Its text. |
--bmx-calendar-bar-height |
How tall one bar is. |
--bmx-calendar-border |
The lines between days. |
--bmx-calendar-day-min-height |
How tall a day cell is at its shortest. |
--bmx-calendar-entry-background |
The fill of a timed event in a cell. |
--bmx-calendar-entry-color |
Its text. |
--bmx-calendar-focus-ring |
The focus indicator. |
--bmx-calendar-gutter |
How wide the hour rail down the side is. |
--bmx-calendar-hour-line |
The rule drawn across each hour. |
--bmx-calendar-hours-height |
How tall the whole time column is. |
--bmx-calendar-nonworking-background |
The fill behind a day outside working days. |
--bmx-calendar-now-color |
The line marking the current time. |
--bmx-calendar-outside-opacity |
How faint a day from a neighbouring month is. |
--bmx-calendar-radius |
The corner radius of the whole grid. |
--bmx-calendar-today-color |
The mark on today. |
--bmx-calendar-weekday-color |
The column headings. |