Blog//6 min read
Appointment booking slots in React without a backend
Build a React appointment booking component with a 14 day date strip, grouped time slots, taken states and a confirm callback for Cal.com or a webhook.
MSMarco Schulz, founder of Facade UI
Most local business sites handle booking with an embed. A Calendly iframe, a Square widget, a third party button that opens a popup in someone else's brand. It works, but it loads a few hundred kilobytes of script, looks nothing like the rest of the page, and the business owner cannot change the copy without logging into another tool. The Booking slots component in Facade is my answer: the whole picking experience rendered in your own React tree, with a single callback at the end that you connect to whatever actually stores the appointment.
A 14 day date strip
A calendar grid is the wrong control for a service appointment. Nobody books a haircut for the 23rd of next month. They want "this Thursday" or "the first free Saturday". A horizontal strip of the next two weeks fits that mental model and fits a phone screen.
The strip is built on mount from today's date forward, days entries long, with 14 as the default. Each day is a button showing the weekday and the date, and the whole strip is a role="radiogroup" with one role="radio" per day, so arrow keys move between days exactly like a native radio group. Closed weekdays, Sundays by default via closedWeekdays={[0]}, render dimmed with ", closed" appended to their accessible label.
One small decision makes a big difference: if today has no bookable time left, the component skips ahead and preselects the first day that does. A visitor at 8 pm on a Tuesday lands on Wednesday instead of staring at a grid of disabled buttons. The selected day gets a sliding pill behind it, animated with a layoutId so it glides between dates.
Grouping slots by morning, afternoon and evening
Twenty two half hour slots in one flat list is a wall. Three groups with headings is a menu. The component splits the day's times into Morning (before 12), Afternoon (12 to 5) and Evening (5 onward), and only renders groups that have slots.
Where do the times come from? A getSlots(date) function that returns the bookable times for a date as "HH:mm" strings. The default is 9:00 to 19:30 every 30 minutes, which is a placeholder, not a schedule. Replace it with your real hours:
function getSlots(date: Date): string[] {
const saturday = date.getDay() === 6;
const start = saturday ? 8 * 60 : 9 * 60;
const end = saturday ? 14 * 60 : 18 * 60;
const out: string[] = [];
for (let m = start; m < end; m += 45) {
out.push(`${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`);
}
return out;
}A 45 minute cadence for a 45 minute service means no odd gaps. Because the function receives the date, you can return different grids for different weekdays, or none at all.
Disabled and taken states
Three things can make a slot unavailable: it is in the past, it is already taken, or the day is closed. The component handles the first and third itself. Past slots on today are disabled with a 30 minute buffer, because nobody can book an appointment that starts in four minutes.
Taken slots are your data. isSlotDisabled(date, time) returns true for any time you cannot offer. The default is a stable pseudo random pattern so the demo looks realistic; in production you pass a lookup against your bookings:
const taken = new Set(["2026-11-20T10:30", "2026-11-20T11:15"]);
function isSlotDisabled(date: Date, time: string) {
return taken.has(`${date.toISOString().slice(0, 10)}T${time}`);
}Disabled slots stay visible, dimmed and not clickable, rather than disappearing. A visitor who sees that 10:30 is gone understands the business is busy; a visitor who sees a sparse list wonders if the booking is broken. Above the grid, a small "12 open" counter does the same job in a number.
The summary and confirm callback
Below the grid sits a summary line with the service name, the price, the chosen date, time and duration, all from props. It is an aria-live="polite" region so screen reader users hear the selection change without hunting for it. The confirm button is disabled until a time is picked, then turns green and reads as confirmed after the click.
The confirm click calls onConfirm with a tiny object: { date: "2026-11-20", time: "10:30" }. The date is an ISO day string, the time is the same "HH:mm" string your getSlots returned. That is the entire contract. The component does not know or care what happens next.
Wiring it to Cal.com or a webhook
"Without a backend" means the picker has none. The appointment still has to land somewhere, and there are three cheap options.
The simplest is a webhook. Post the selection plus the visitor's contact details to a Make, Zapier or n8n endpoint, and let the automation create a calendar event and send the confirmation email. For a solo barber this is often all they need.
The second is Cal.com or a similar scheduling API. Fetch the busy times for the next 14 days from their API in a server component, pass them into isSlotDisabled, and create the booking in onConfirm through a route handler. The visitor never sees the third party brand.
The third is your own database. A bookings table with a unique constraint on date and time, inserted from a route handler. The unique constraint handles two visitors picking the same slot in the same second; the second insert fails and you show a "just taken, pick another" message.
Here is the component wired to a route handler, with the visitor's details collected first by the Contact section form on the same page:
"use client";
import { BookingSlots, type BookingSelection } from "@/components/facade/booking-slots";
export function BookCleaning({ taken }: { taken: string[] }) {
const takenSet = new Set(taken);
return (
<BookingSlots
serviceName="Dental cleaning"
durationMinutes={45}
priceLabel="$120"
days={14}
closedWeekdays={[0]}
getSlots={getSlots}
isSlotDisabled={(date, time) => takenSet.has(`${date.toISOString().slice(0, 10)}T${time}`)}
confirmLabel="Request this time"
onConfirm={async (selection: BookingSelection) => {
await fetch("/api/book", { method: "POST", body: JSON.stringify(selection) });
}}
/>
);
}Both components are Pro items. After the one time private registry setup in the installation docs, they install together:
npx shadcn@latest add @facade/booking-slots @facade/contact-sectionOn a dental site I place the picker right after the team grid, because patients choose a hygienist first and a time second. The full block order is on the dentist page.
FAQ
How do I handle time zones for the slots?
The strip is built with the visitor's local Date, which is right for a business where visitors and the shop share a zone, which is nearly every local business. If you serve several zones, compute the slot strings on the server in the business zone and label the picker with the zone name.
Can visitors book several slots or a range?
Not out of the box; the contract is one date and one time. For a two hour service, return times at two hour intervals from getSlots instead of asking visitors to pick a range. For recurring bookings, collect the first appointment and let the confirmation flow ask about repeats.
Does it send confirmation emails?
No. Whatever receives onConfirm is responsible for emails, calendar invites and reminders. Keeping that out of the component is what lets it drop into any stack.