<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
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
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
| Event | Detail | Description |
|---|---|---|
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
| Method | Signature | Description |
|---|---|---|
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
| Part | Description |
|---|---|
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
| Property | Description |
|---|---|
--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. |