Booking Widget React block for Tailwind CSS v4
v1.0.0Added in 1.14.0
A React booking widget: pick a service and a team member, a day and time, add your details and get a confirmation with an .ics file.
A Cal.com style booking flow in one card. A summary column on the left shows the host's avatar, the service, its length, the location, the price, your time zone and, once picked, the day and time range; it updates as you choose. The steps at the top (Service, Time, Details) show where you are, and completed steps are buttons that go back. Step 1: pick a service from soft cards with the price at the end, and optionally a team member (Anyone available first, then people who offer that service). Step 2: the inline date and time picker, with weekends, past days and days past the booking window disabled, the free times of the chosen day beside the calendar, and a time zone switch in its footer that keeps the moment and moves the wall clock. Step 3: full name, email, phone (optional unless requirePhone) and notes. Continue and Back are real buttons; Continue without a time, or with an empty name or a bad email, shows the error in place and focuses the first problem. Confirm booking spins while onBook runs, then the card shows You're booked with a recap, Add to calendar (downloads an .ics file), Reschedule (back to the times with your details kept, confirmed as New time confirmed) and Book another. Keyboard: Tab moves through the cards, calendar, times and fields; arrows move between service cards, days and times as in the date-time-picker; Enter on a time continues, Enter in a field submits the step; focus moves to each new step's heading. Below 768px the card stacks: the summary becomes a header row with the avatar, service, length and the picked time, the times drop under the calendar, and the Back / Continue bar sticks to the bottom of the screen above the safe area. Each step fades in; reduced motion keeps it still.
Booking Widget is a Pro React block in the React form components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add booking-widget, together with Avatar, Button, Date Time Picker, Input, Phone Input and 9 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add booking-widgetBooking Widget is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import BookingWidget from @/components/blocks/booking-widget.
import { BookingWidget, type Booking, type BookingSlotContext } from "@/components/blocks/booking-widget"
const services = [
{ id: "cut", name: "Haircut", duration: 45, price: 55, location: "120 Mercer St, New York", locationType: "in-person" as const },
{ id: "color", name: "Color and gloss", duration: 90, price: 140, location: "120 Mercer St, New York", locationType: "in-person" as const },
]
const staff = [
{ id: "emma", name: "Emma Carter", role: "Senior stylist" },
{ id: "liam", name: "Liam Brooks", role: "Colorist", services: ["color"] },
]
async function getTimeSlots(day: Date, { service, staff, timeZone }: BookingSlotContext) {
const params = new URLSearchParams({ day: day.toDateString(), service: service.id, staff: staff?.id ?? "any", timeZone })
const res = await fetch(`/api/availability?${params}`)
return (await res.json()) as string[]
}
export function BookAppointment() {
const book = async (booking: Booking) => {
await fetch("/api/bookings", { method: "POST", body: JSON.stringify(booking) })
}
return <BookingWidget services={services} staff={staff} getTimeSlots={getTimeSlots} onBook={book} location="Salon" locationType="in-person" />
}Props
servicesdefault"DEFAULT_BOOKING_SERVICES"BookingService[]What can be booked: { id, name, duration (minutes), price?, description?, location?, locationType?, icon?, disabled? }. With one service and no staff the service step is skipped.
staffdefault"[]"BookingStaff[]Optional team members: { id, name, role?, avatar?, services? (ids they offer), disabled? }. Adds a who choice under the services; people who do not offer the picked service are hidden.
hostdefault"DEFAULT_BOOKING_HOST"BookingHostThe person the summary shows while no team member is picked: { name, role?, avatar? }.
defaultServicedefault""stringThe service id picked at first. Empty: the first enabled service.
defaultStaffdefault""stringThe team member id picked at first. Empty: "any" when anyStaff is on.
anyStaffdefaulttruebooleanOffers Anyone available as the first team choice; getTimeSlots then gets staff: null.
getTimeSlots(day: Date, context: BookingSlotContext) => (string | DateTimeSlot)[] | Promise<(string | DateTimeSlot)[]>The free times of a day as "HH:mm" wall times in context.timeZone (or { time, disabled, label }). Gets the service, the team member (null for anyone) and the zone. A promise shows skeleton slots. Pass a stable function. Without it every slot from startTime to endTime is free.
startTimedefault"09:00"stringThe first slot of a day when there is no getTimeSlots.
endTimedefault"17:00"stringWhen the day ends without getTimeSlots; the last slot still finishes by then.
minuteStepdefault30numberMinutes between slots without getTimeSlots.
disabledDaysdefault"DEFAULT_BOOKING_DISABLED_DAYS"DateMatcher or DateMatcher[] (DateMatcher: Date, Date[], { from, to }, { before }, { after }, { dayOfWeek } or (date) => boolean)Days that cannot be booked, as calendar matchers. Weekends by default; past days are always off.
bookingWindowdefault60numberHow many days ahead can be booked. 0 means no limit.
defaultValuedefault"null"Date | nullThe start picked at first, e.g. the booking being moved. The details step opens with it filled in.
defaultDetailsPartial<BookingDetails>Prefilled name, email, phone (E.164) and notes, for a signed-in visitor.
defaultDaydefault""stringThe day the calendar opens on, as "YYYY-MM-DD", with its times listed before one is picked. Empty: no day until the visitor picks one.
timeZonestringThe IANA zone the times are shown in, controlled. Pair it with onTimeZoneChange.
defaultTimeZonedefault""stringThe starting zone while uncontrolled. Empty: the visitor's device zone.
onTimeZoneChange(zone: string) => voidFires when the visitor switches the zone in the picker footer.
timeZonesdefault"["America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "Europe/London", "Europe/Berlin", "Asia/Tokyo"]"string[]The zones the switch offers (the current one is always included). [] shows the zone as plain text.
hourCycledefault"auto""auto" | "h12" | "h23"12 or 24 hour times; auto follows the locale.
step"service" | "time" | "details" | "confirmation"The current step, controlled. A step that is not reachable yet (details without a time) falls back to the first missing one.
defaultStepdefault"service""service" | "time" | "details" | "confirmation"The starting step while uncontrolled.
onStepChange(step: BookingStep) => voidFires on Continue, Back, a step click and after booking.
layoutdefault"split""split" | "stacked"split: the summary column beside the steps from 768px. stacked: the summary as a header row at every width, in a narrower card. Phones always stack.
showPhonedefaulttruebooleanShows the phone field (with a country picker) in the details step.
requirePhonedefaultfalsebooleanMakes the phone required. Optional phones are still checked when filled in.
showNotesdefaulttruebooleanShows the optional notes field.
defaultCountrydefault"US"stringThe phone field's starting country, as an ISO code ("US", "GB").
locationdefault"Video call"stringWhere the meeting happens, for every service that has no location of its own.
locationTypedefault"video""video" | "phone" | "in-person"The icon next to the location.
currencydefault"USD"stringThe ISO currency of the prices. A price of 0 shows the free label.
localedefault""stringFormats dates, times and prices. Empty: the UIProvider locale, then the browser's.
onBook(booking: Booking) => void | Promise<unknown>Called by Confirm booking with { service, staff, start, end, timeZone, details, bookedAt, previous? }. A promise keeps the button loading; a rejection shows the bookFailed message. Default: a pretend 900ms request.
rescheduleHrefstring | ((booking: Booking) => string)Turns Reschedule into a link (your reschedule page). Without it Reschedule goes back to the times here.
icsFileNamedefault"booking.ics"stringThe file name of the Add to calendar download.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"Paints the main button, the selected cards, the steps, the picked day and time.
colordefault""stringAny CSS color; replaces the tone everywhere. Leave empty to use the tone.
radiusdefault"lg""none" | "sm" | "md" | "lg"The card's corners: 0, 12, 16 or 24px.
nowDate | numberWhat now means for past days and the booking window. Default: the clock.
labelsPartial<BookingWidgetLabels>Every string (DEFAULT_BOOKING_WIDGET_LABELS holds the English defaults): step names and headings, timeDescription(days), field labels and placeholders, buttons, the confirmation, the duration formatter and the error messages.
classNamesPartial<Record<"summary" | "header" | "host" | "main" | "steps" | "body" | "title" | "services" | "staff" | "picker" | "fields" | "error" | "actions" | "confirmation" | "recap", string>>Extra classes for the inner parts.
classNamestringExtra classes for the card, merged last (max-w-[960px] for split, max-w-xl for stacked).
Parts
buildBookingIcs
functionTurns a Booking into an iCalendar file (the Add to calendar download). Use it to attach the same file to your confirmation email.
booking*BookingWhat onBook received.
options{ title?: string; location?: string; description?: string; organizer?: string }The event title (default "<service> with <person>"), location, description and the organizer name.
DEFAULT_BOOKING_SERVICES
constantThe three sample services: Intro call (free), Consultation and Strategy session.
DEFAULT_BOOKING_DISABLED_DAYS
constantThe default disabledDays: { dayOfWeek: [0, 6] }, weekends off.
DEFAULT_BOOKING_HOST
constantThe sample host, Emma Carter.
DEFAULT_BOOKING_TIME_ZONES
constantThe zones the switch offers by default: four US zones, London, Berlin and Tokyo.
DEFAULT_BOOKING_WIDGET_LABELS
constantThe English labels; spread it to change a few.
Booking
typeWhat onBook gets: service, staff, start, end, timeZone, details, bookedAt and previous after a reschedule.
BookingService
typeOne bookable service.
BookingStaff
typeOne team member and the services they offer.
BookingHost
typeThe person in the summary.
BookingDetails
typeThe visitor's name, email, phone (E.164) and notes.
BookingSlotContext
typeThe second argument of getTimeSlots: service, staff and timeZone.
BookingTimeSlots
typeThe getTimeSlots signature.
BookingStep
type"service" | "time" | "details" | "confirmation".
BookingWidgetLabels
typeThe shape of labels.
Examples
Salon with stylists
Services with icons, stylists filtered by what they offer, an in-person address and a required phone.
One service, stacked
With a single service and no staff the flow starts at the times; the summary is a header row.
Loading availability
getTimeSlots returns a promise, so each day shows skeleton slots first. Brand tone.
When booking fails
The first onBook rejects: the error shows above the actions and the details stay. Try again to book.
Controlled step, custom color
step and onStepChange in your state, a violet color, 24 hour times in London and prices in pounds.
Romanian locale
ro-RO dates and RON prices, a Romanian phone by default and a short zone list.
Dependencies
Files
- components/blocks/booking-widget.tsx
FAQ
Is the React Booking Widget block free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Booking Widget?
Run npx wingo-ui@latest add booking-widget. It also adds the Wingo UI items it builds on (Avatar, Button, Date Time Picker, Input, Phone Input and 9 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/booking-widget.
How does Booking Widget behave on phones?
Below 768px the card stacks: the summary becomes a header row with the avatar, service, length and the picked time, the times drop under the calendar, and the Back / Continue bar sticks to the bottom of the screen above the safe area.