v3.0.0

<bmx-booking>

An appointment booking picker for the people a business serves: salons and clinics, consultants, tutors, workshops and classes, car servicing, demos and support calls.

14 properties · 6 events · 9 methods · 6 parts

Example

A fictional physiotherapy clinic: five services, three physiotherapists in Leeds and one who works by video from New York, a Pilates class with six places, and three weeks of times already taken. Choose a service, a physio (or anyone), a day and a time, and fill in the form - the page takes the booking and answers with a reference after a short pause. Change "Times shown in" to see the same free times in another zone.

Show markup
<bmx-booking id="ex-bk" label="Book at Harbourside Physio" now="2026-10-19T08:30:00+01:00" time-zone="Europe/London" locale="en-GB" waitlist></bmx-booking>
<p class="note" id="ex-bk-out" role="status">A fictional physiotherapy clinic: five services, three physiotherapists in Leeds and one who works by video from New York, a Pilates class with six places, and three weeks of times already taken. Choose a service, a physio (or anyone), a day and a time, and fill in the form - the page takes the booking and answers with a reference after a short pause. Change "Times shown in" to see the same free times in another zone.</p>

<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block: 0.5rem 1.5rem">
  <button type="button" id="ex-bk-move">Move an existing booking</button>
  <button type="button" id="ex-bk-new">Start a new booking</button>
  <label><input type="checkbox" id="ex-bk-fail" /> Pretend the time was just taken</label>
</div>

<script type="module">
  await customElements.whenDefined('bmx-booking');
  const el = document.getElementById('ex-bk');
  const out = document.getElementById('ex-bk-out');

  // Three weeks of taken times, the same on every visit.
  let seed = 7;
  const random = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
  const busy = [];
  for (let day = 0; day < 21; day++) {
    const date = new Date(Date.UTC(2026, 9, 19 + day));
    if (date.getUTCDay() === 0) continue;
    for (const staff of ['maya', 'tom', 'priya']) {
      for (let hour = 9; hour < 17; hour++) {
        if (random() < 0.42) {
          const start = new Date(Date.UTC(2026, 9, 19 + day, hour - 1, random() < 0.5 ? 0 : 30));
          busy.push({ staff, start: start.toISOString(), end: new Date(start.getTime() + 45 * 60000).toISOString() });
        }
      }
    }
  }
  // Tuesday's class has four of its six places taken.
  busy.push({ staff: 'maya', service: 'pilates', seats: 4, start: '2026-10-20T17:30:00Z', end: '2026-10-20T18:30:00Z' });

  el.setup = {
    timeZone: 'Europe/London',
    rules: { leadMinutes: 120, horizonDays: 42, step: 15 },
    closed: ['2026-12-25', '2026-12-26'],
    services: [
      { id: 'assess', name: 'Initial assessment', category: 'Physiotherapy', duration: 60, bufferAfter: 15, price: 65, currency: 'GBP', location: 'Harbourside Physio, 12 Wharf Street, Leeds', description: 'History, examination and a plan to get you moving.' },
      { id: 'follow', name: 'Follow-up treatment', category: 'Physiotherapy', duration: 30, price: 45, currency: 'GBP', location: 'Harbourside Physio, 12 Wharf Street, Leeds', step: 30 },
      { id: 'massage', name: 'Sports massage', category: 'Physiotherapy', duration: 45, bufferAfter: 15, price: 50, currency: 'GBP', location: 'Harbourside Physio, 12 Wharf Street, Leeds', staff: ['tom', 'priya'] },
      { id: 'video', name: 'Video consultation', category: 'Online', duration: 30, price: 40, currency: 'GBP', location: 'Video call (link sent by email)', staff: ['sam', 'maya'] },
      { id: 'pilates', name: 'Clinical Pilates class', category: 'Classes', duration: 60, capacity: 6, price: 14, currency: 'GBP', location: 'Studio 2, 12 Wharf Street, Leeds', staff: ['maya'], hours: { tue: ['18:30', '19:30'], thu: ['18:00', '19:00'] }, description: 'Tuesdays 18:30 and Thursdays 18:00. Small group, all levels.' },
    ],
    staff: [
      { id: 'maya', name: 'Maya Lindqvist', role: 'Clinical lead', color: '#2563eb', hours: { mon: [['09:00', '13:00'], ['14:00', '17:30']], tue: [['09:00', '13:00'], ['17:30', '19:30']], wed: ['09:00', '17:30'], thu: ['12:00', '20:00'] }, exceptions: [{ date: '2026-10-29', closed: true }] },
      { id: 'tom', name: 'Tom Achebe', role: 'Sports physiotherapist', color: '#0d9488', hours: { mon: ['08:00', '16:00'], tue: ['08:00', '16:00'], wed: ['12:00', '20:00'], fri: ['08:00', '16:00'], sat: ['09:00', '13:00'] } },
      { id: 'priya', name: 'Priya Nair', role: 'Physiotherapist', color: '#d97706', hours: { tue: ['10:00', '18:00'], wed: ['10:00', '18:00'], thu: ['10:00', '18:00'], fri: ['10:00', '18:00'] } },
      { id: 'sam', name: 'Sam Rivera', role: 'Physiotherapist (video)', color: '#7c3aed', timeZone: 'America/New_York', hours: { mon: ['07:00', '11:00'], wed: ['07:00', '11:00'], fri: ['07:00', '11:00'] }, services: ['video'] },
    ],
    busy,
  };
  el.fields = [
    { name: 'name', label: 'Full name', type: 'text', required: true, autocomplete: 'name' },
    { name: 'email', label: 'Email', type: 'email', required: true, autocomplete: 'email', hint: 'Your confirmation and any video link go here.' },
    { name: 'phone', label: 'Mobile', type: 'tel', autocomplete: 'tel' },
    { name: 'reason', label: 'What would you like help with?', type: 'textarea', maxLength: 600 },
    { name: 'policy', label: 'I have read the cancellation policy (24 hours’ notice).', type: 'checkbox', required: true },
  ];

  let count = 1040;
  const handle = name => e => {
    // A real page sends the request to its own server here.
    e.preventDefault();
    out.textContent = `${name}: ${e.detail.serviceName} with ${e.detail.staffName}, ${e.detail.start} (${e.detail.timeZone})…`;
    setTimeout(() => {
      if (document.getElementById('ex-bk-fail').checked && e.detail.kind === 'book') el.failBooking('', { slotTaken: true });
      else el.completeBooking({ reference: `HP-${++count}`, id: `b${count}` });
    }, 700);
  };
  el.addEventListener('bmxBook', handle('Booking'));
  el.addEventListener('bmxReschedule', handle('Moving'));
  el.addEventListener('bmxCancel', handle('Cancelling'));
  el.addEventListener('bmxWaitlist', handle('Waiting list'));
  el.addEventListener('bmxMonthChange', e => console.info('Load the taken times for', e.detail.month, e.detail.from, '→', e.detail.to));

  document.getElementById('ex-bk-move').addEventListener('click', () => {
    el.setBusy([...busy, { id: 'b900', staff: 'tom', start: '2026-10-20T08:00:00Z', end: '2026-10-20T08:30:00Z' }]);
    el.appointment = { id: 'b900', reference: 'HP-0900', service: 'follow', staff: 'tom', start: '2026-10-20T08:00:00Z', end: '2026-10-20T08:30:00Z' };
    out.textContent = 'Moving booking HP-0900: choose a new day and time, or cancel it.';
  });
  document.getElementById('ex-bk-new').addEventListener('click', () => {
    el.appointment = undefined;
    el.reset();
    out.textContent = 'A new booking.';
  });
</script>

WHAT IT DOES

  • The customer chooses a service, who they would like to see (or anyone), a day on a month calendar that shows which days have room, and a free time - in their own time zone, whatever zone each provider works in.
  • Free times come from each provider's weekly hours, exceptions and closed days, less the times already taken, the buffers a service needs either side, the least notice, how far ahead bookings may be made and a daily limit. A group service fills its places one booking at a time.
  • Then their details (a form the page defines), a review, and the booking: the page is handed the request (bmxBook) and stores it - the picker keeps nothing and sends nothing anywhere. A confirmation with an iCalendar file follows.
  • Moving a booking and cancelling one; a waiting list for a full day.

Properties

PropertyAttributeTypeDefaultDescription
appointment appointment BmxBookingAppointment | string — A booking to move or cancel (an object, or its JSON).
chooseStaff choose-staff boolean true Offer a choice of provider (otherwise "anyone").
fields fields readonly Partial<BmxBookingField>[] | string — The form the customer fills in: fields as an array or its JSON, or none for no form. Unset: name, email, phone and notes.
hourCycle hour-cycle 'auto' | 'h12' | 'h23' 'auto' Clock: auto (the locale's), h12 or h23.
label label string — The element's accessible name.
locale locale string — The locale for dates, times and prices. Unset: the page's lang.
now now string — Now, as an ISO date and time. Unset: the computer's clock.
service service string — The chosen service's id. Set it to start past the service step.
setup setup Partial<BmxBookingSetup> | string — Services, providers with their hours, times already taken, rules (an object, or its JSON).
staff staff string — The chosen provider's id, or any.
strings strings Partial<BmxBookingStrings> | string — Replacements for any of the wording (an object, or its JSON).
timeZone time-zone string — The zone times are shown in. Unset: the computer's.
waitlist waitlist boolean false Offer a waiting list on a fully booked day.
zonePicker zone-picker boolean true Let the customer change the zone times are shown in.

Events

EventDetailDescription
bmxBook BmxBookingRequest Fired when the customer confirms a booking. Cancelable: cancel it, store the booking, then call completeBooking (or failBooking).
bmxCancel BmxBookingRequest Fired when the customer cancels a booking. Cancelable, as bmxBook.
bmxChange BmxBookingChangeDetail Fired as the customer chooses a service, provider, day, time or zone, and moves between steps.
bmxMonthChange BmxBookingMonthDetail Fired when another month is shown, so the page can load the times taken in it (setBusy).
bmxReschedule BmxBookingRequest Fired when the customer moves a booking. Cancelable, as bmxBook.
bmxWaitlist BmxBookingRequest Fired when the customer joins the waiting list for a full day. Cancelable, as bmxBook.

Methods

MethodSignatureDescription
completeBooking completeBooking(result?: BmxBookingResult) => Promise<void> Finishes a request the page took over: the booking (or move, cancellation, waiting list) is done.
failBooking failBooking(message?: string, options?: { slotTaken?: boolean; }) => Promise<void> Ends a request the page took over without success; the customer can try again. With slotTaken, the time is shown as gone.
getSelection getSelection() => Promise<{ step: BmxBookingStep; service?: string; staff?: string; date?: string; start?: string; end?: string; seats: number; timeZone: string; details: Record<string, string | boolean>; }> What has been chosen so far.
getSlots getSlots(date: string) => Promise<{ start: string; end: string; staff: string[]; seats: number; }[]> The free times on a day (YYYY-MM-DD in the shown zone), for the chosen service and provider.
reset reset() => Promise<void> Starts again.
selectDate selectDate(date: string) => Promise<void> Shows a day (YYYY-MM-DD in the shown zone).
selectService selectService(id: string) => Promise<boolean> Chooses a service (and moves on).
setBusy setBusy(busy: readonly Partial<BmxBookingBusy>[], options?: { merge?: boolean; }) => Promise<void> Replaces the times already taken (or, with merge, adds to them).
setFocus setFocus() => Promise<void> Moves the focus to the current step's heading.

CSS shadow parts

PartDescription
calendar The month calendar.
form The details form.
header The steps and the back button.
main The current step.
slots The free times of the chosen day.
summary What has been chosen so far.

CSS custom properties

PropertyDescription
--bmx-booking-accent The colour of chosen days, times and the main buttons.
--bmx-booking-min-height Least height of the step area, so the frame does not jump between steps.
--bmx-booking-radius Corner radius of the frame.
--bmx-booking-summary-width Width of the summary beside the step.