v1.0.0

<bmx-calendar>

A diary: what is happening, on which days.

14 properties · 4 events · 3 methods · 18 parts

Example

The whole diary is one element with a JSON attribute 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 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.
Use the keyboard. 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 Enter steps into a day so the events in it are reachable, and Escape comes back out. That two-mode model is what lets a month be a grid at all; without it the events in a month view are either forty-two extra tab stops or unreachable.
The Conference 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 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.
The hour rail is decoration and is not announced. 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 one cell whose name is its events in time order, and Enter 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.
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.
The agenda is a different view 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.
+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.
Nothing changes until the drop. The moving block is drawn from a proposal and your 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 &mdash; 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: &ldquo;the 9th to the 11th&rdquo; 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 &mdash; 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 &mdash;
    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 &mdash; a row
    per half hour, a cell per day &mdash; 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> &mdash; 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 &ldquo;Tuesday&rdquo; and
    &ldquo;next week&rdquo;, 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 &mdash; 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 &ldquo;2 more: One to one, Retro&rdquo;. 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 &mdash; 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 &mdash; 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.
    &ldquo;Payroll&rdquo; 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 &mdash; a day that is closed, a room already
    taken &mdash; 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

PropertyAttributeTypeDefaultDescription
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

EventDetailDescription
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

MethodSignatureDescription
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

SlotDescription
empty Shown when there is nothing in view at all.

CSS shadow parts

PartDescription
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

PropertyDescription
--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.