# Booking Widget

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

- Type: React block (Forms)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add booking-widget`
- Docs: https://wingo-ui.com/components/booking-widget

## Interaction

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.

## Installation

```bash
npx wingo-ui@latest add booking-widget
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/booking-widget` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/blocks/booking-widget.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Date Time Picker](https://wingo-ui.com/components/date-time-picker) ([Markdown](https://wingo-ui.com/components/date-time-picker.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Phone Input](https://wingo-ui.com/components/phone-input) ([Markdown](https://wingo-ui.com/components/phone-input.md))
- [Radio Group](https://wingo-ui.com/components/radio-group) ([Markdown](https://wingo-ui.com/components/radio-group.md))
- [Steps](https://wingo-ui.com/components/steps) ([Markdown](https://wingo-ui.com/components/steps.md))
- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `services?` | `BookingService[]` | `"DEFAULT_BOOKING_SERVICES"` | 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. |
| `staff?` | `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. |
| `host?` | `BookingHost` | `"DEFAULT_BOOKING_HOST"` | The person the summary shows while no team member is picked: { name, role?, avatar? }. |
| `defaultService?` | `string` | `""` | The service id picked at first. Empty: the first enabled service. |
| `defaultStaff?` | `string` | `""` | The team member id picked at first. Empty: "any" when anyStaff is on. |
| `anyStaff?` | `boolean` | `true` | Offers 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. |
| `startTime?` | `string` | `"09:00"` | The first slot of a day when there is no getTimeSlots. |
| `endTime?` | `string` | `"17:00"` | When the day ends without getTimeSlots; the last slot still finishes by then. |
| `minuteStep?` | `number` | `30` | Minutes between slots without getTimeSlots. |
| `disabledDays?` | `DateMatcher or DateMatcher[] (DateMatcher: Date, Date[], { from, to }, { before }, { after }, { dayOfWeek } or (date) => boolean)` | `"DEFAULT_BOOKING_DISABLED_DAYS"` | Days that cannot be booked, as calendar matchers. Weekends by default; past days are always off. |
| `bookingWindow?` | `number` | `60` | How many days ahead can be booked. 0 means no limit. |
| `defaultValue?` | `Date \| null` | `"null"` | The start picked at first, e.g. the booking being moved. The details step opens with it filled in. |
| `defaultDetails?` | `Partial<BookingDetails>` |  | Prefilled name, email, phone (E.164) and notes, for a signed-in visitor. |
| `defaultDay?` | `string` | `""` | The 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. |
| `timeZone?` | `string` |  | The IANA zone the times are shown in, controlled. Pair it with onTimeZoneChange. |
| `defaultTimeZone?` | `string` | `""` | The starting zone while uncontrolled. Empty: the visitor's device zone. |
| `onTimeZoneChange?` | `(zone: string) => void` |  | Fires when the visitor switches the zone in the picker footer. |
| `timeZones?` | `string[]` | `"[\"America/New_York\", \"America/Chicago\", \"America/Denver\", \"America/Los_Angeles\", \"Europe/London\", \"Europe/Berlin\", \"Asia/Tokyo\"]"` | The zones the switch offers (the current one is always included). [] shows the zone as plain text. |
| `hourCycle?` | `"auto" \| "h12" \| "h23"` | `"auto"` | 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. |
| `defaultStep?` | `"service" \| "time" \| "details" \| "confirmation"` | `"service"` | The starting step while uncontrolled. |
| `onStepChange?` | `(step: BookingStep) => void` |  | Fires on Continue, Back, a step click and after booking. |
| `layout?` | `"split" \| "stacked"` | `"split"` | 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. |
| `showPhone?` | `boolean` | `true` | Shows the phone field (with a country picker) in the details step. |
| `requirePhone?` | `boolean` | `false` | Makes the phone required. Optional phones are still checked when filled in. |
| `showNotes?` | `boolean` | `true` | Shows the optional notes field. |
| `defaultCountry?` | `string` | `"US"` | The phone field's starting country, as an ISO code ("US", "GB"). |
| `location?` | `string` | `"Video call"` | Where the meeting happens, for every service that has no location of its own. |
| `locationType?` | `"video" \| "phone" \| "in-person"` | `"video"` | The icon next to the location. |
| `currency?` | `string` | `"USD"` | The ISO currency of the prices. A price of 0 shows the free label. |
| `locale?` | `string` | `""` | Formats 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. |
| `rescheduleHref?` | `string \| ((booking: Booking) => string)` |  | Turns Reschedule into a link (your reschedule page). Without it Reschedule goes back to the times here. |
| `icsFileName?` | `string` | `"booking.ics"` | The file name of the Add to calendar download. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Paints the main button, the selected cards, the steps, the picked day and time. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone everywhere. Leave empty to use the tone. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg"` | `"lg"` | The card's corners: 0, 12, 16 or 24px. |
| `now?` | `Date \| number` |  | What now means for past days and the booking window. Default: the clock. |
| `labels?` | `Partial<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. |
| `classNames?` | `Partial<Record<"summary" \| "header" \| "host" \| "main" \| "steps" \| "body" \| "title" \| "services" \| "staff" \| "picker" \| "fields" \| "error" \| "actions" \| "confirmation" \| "recap", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the card, merged last (max-w-[960px] for split, max-w-xl for stacked). |

## Parts

### buildBookingIcs (function)

Turns a Booking into an iCalendar file (the Add to calendar download). Use it to attach the same file to your confirmation email.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `booking` | `Booking` |  | What 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 (constant)

The three sample services: Intro call (free), Consultation and Strategy session.

### DEFAULT_BOOKING_DISABLED_DAYS (constant)

The default disabledDays: { dayOfWeek: [0, 6] }, weekends off.

### DEFAULT_BOOKING_HOST (constant)

The sample host, Emma Carter.

### DEFAULT_BOOKING_TIME_ZONES (constant)

The zones the switch offers by default: four US zones, London, Berlin and Tokyo.

### DEFAULT_BOOKING_WIDGET_LABELS (constant)

The English labels; spread it to change a few.

### Booking (type)

What onBook gets: service, staff, start, end, timeZone, details, bookedAt and previous after a reschedule.

### BookingService (type)

One bookable service.

### BookingStaff (type)

One team member and the services they offer.

### BookingHost (type)

The person in the summary.

### BookingDetails (type)

The visitor's name, email, phone (E.164) and notes.

### BookingSlotContext (type)

The second argument of getTimeSlots: service, staff and timeZone.

### BookingTimeSlots (type)

The getTimeSlots signature.

### BookingStep (type)

"service" | "time" | "details" | "confirmation".

### BookingWidgetLabels (type)

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

## See also

- [Date Time Picker](https://wingo-ui.com/components/date-time-picker) ([Markdown](https://wingo-ui.com/components/date-time-picker.md))
- [Timezone Select](https://wingo-ui.com/components/timezone-select) ([Markdown](https://wingo-ui.com/components/timezone-select.md))
- [Steps](https://wingo-ui.com/components/steps) ([Markdown](https://wingo-ui.com/components/steps.md))
- [Demo Booking Page](https://wingo-ui.com/templates/demo-booking-page) ([Markdown](https://wingo-ui.com/templates/demo-booking-page.md))
- [Checkout steps](https://wingo-ui.com/components/checkout-steps) ([Markdown](https://wingo-ui.com/components/checkout-steps.md))

---

Canonical: https://wingo-ui.com/components/booking-widget
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
