# Booking

> Book a call on one card: the time you pick prints onto the host’s ticket, then the times turn into your details and the confirmation.

Most booking widgets are an iframe from someone else’s scheduler, or a calendar that opens a form in a modal and ends on a page that just says thanks. Here the host’s ticket is the lit surface, a stub punched off with two notches, and the time you pick prints onto it: the hours and minutes roll the way the time moved, the date morphs, and a ring is thrown to the time you tapped. Continue turns the times into your name, email and a note on the same surface; Book goes Booking → Booked, a check stamps itself onto the stub and the surface becomes the confirmation, with a real calendar file, a Google Calendar link, the meeting link and Cancel. The calendar stays yours: getSlots returns the free start times of the week on screen, onBook makes the booking against Cal.com, Google Calendar or your own table, and if the time was taken while you typed, the message shows where you are and the week is asked for again.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/booking.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `input`, `number-roll`, `status-button`, `text-morph`, `textarea`
- Docs and live preview: https://hairlineui.com/ui/booking
- Registry item (source): https://hairlineui.com/r/booking.json

## Usage

```tsx
import { Booking } from "@/components/booking";

<Booking
  host={{ name: "Maya Lindqvist", role: "Founder, Relay" }}
  title="Relay walkthrough"
  duration={30}
  location="Google Meet"
  getSlots={async ({ from, to }, signal) => fetchFreeTimes(from, to, signal)}
  onBook={async (booking) => createBooking(booking)}
/>
```

## With Cal.com

Read free times from Cal.com’s v2 slots API and create the booking in a route handler, so your API key stays on the server. A refusal comes back as its message and shows on the card.

```tsx
// app/api/booking/route.ts
const CAL = "https://api.cal.com/v2";
const auth = { Authorization: `Bearer ${process.env.CAL_API_KEY}` };
const EVENT_TYPE_ID = 123456;

// Free times for the week on screen.
export async function GET(req: Request) {
  const url = new URL(req.url);
  const q = new URLSearchParams({ eventTypeId: String(EVENT_TYPE_ID), start: url.searchParams.get("from")!, end: url.searchParams.get("to")! });
  const res = await fetch(`${CAL}/slots?${q}`, { headers: { ...auth, "cal-api-version": "2024-09-04" } });
  if (!res.ok) return Response.json({ message: "Couldn’t load times." }, { status: 502 });
  const { data } = await res.json(); // { "2026-10-28": [{ start: "2026-10-28T09:00:00.000Z" }, …], … }
  return Response.json(Object.values(data).flat().map((s: any) => (typeof s === "string" ? s : s.start)));
}

// Make the booking; a refusal (the time was taken) comes back as its message.
export async function POST(req: Request) {
  const b = await req.json();
  const res = await fetch(`${CAL}/bookings`, {
    method: "POST",
    headers: { ...auth, "cal-api-version": "2026-02-25", "Content-Type": "application/json" },
    body: JSON.stringify({
      eventTypeId: EVENT_TYPE_ID,
      start: b.start,
      attendee: { name: b.name, email: b.email, timeZone: b.timeZone },
      bookingFieldsResponses: b.note ? { notes: b.note } : undefined, // Cal.com's "Additional notes" field
    }),
  });
  const json = await res.json();
  if (!res.ok) return Response.json({ message: json.error?.message ?? "That time was just taken." }, { status: 409 });
  return Response.json({ id: json.data.uid, meetingUrl: json.data.location });
}

// app/book/page.tsx
"use client";
import { Booking } from "@/components/booking";

export default function Book() {
  return (
    <Booking
      host={{ name: "Maya Lindqvist", role: "Founder, Relay" }}
      title="Relay walkthrough"
      duration={30}
      location="Google Meet"
      getSlots={async ({ from, to }, signal) => {
        const q = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() });
        const res = await fetch(`/api/booking?${q}`, { signal });
        if (!res.ok) throw new Error("Couldn’t load times.");
        return res.json();
      }}
      onBook={async (booking) => {
        const res = await fetch("/api/booking", { method: "POST", body: JSON.stringify(booking) });
        const json = await res.json();
        if (!res.ok) throw new Error(json.message);
        return json; // { id, meetingUrl }
      }}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `host` | `{ name, role?, src? }` | – | Who the call is with, on the ticket. Initials when there’s no photo. |
| `title / description` | `string` | – | What’s being booked, on the ticket. |
| `duration` | `number` | – | Minutes; sets the end time on the stub and in the booking. |
| `location` | `string` | – | Where it happens, e.g. "Google Meet"; goes into the calendar file too. |
| `getSlots` | `({ from, to, timeZone }, signal) => Promise<(Date \| string \| number)[]>` | – | Free start times from from up to to (a week). Called once per week shown and aborted if the visitor moves on; throw to show Try again. |
| `onBook` | `(request: BookingRequest) => Promise<BookingConfirmation \| void>` | – | Gets { start, end, timeZone, name, email, note }. Make the booking; resolve with { id?, meetingUrl? }, throw an Error to show its message. |
| `onCancel` | `(booking: BookedCall) => Promise<unknown>` | – | Shows Cancel booking once booked; gets the request with your id. Throw to keep the booking and say why. |
| `timeZone` | `string` | `the visitor’s` | IANA zone to show and group times in. |
| `locale / hour12` | `string / boolean` | `browser / the locale’s` | Formats days and times with Intl. |
| `weekStartsOn` | `0 \| 1` | `1` | Sunday or Monday. |
| `defaultDate` | `Date \| string \| number` | `today` | A day in the first week shown. |

## States

- **pick**: The host’s ticket, lit from its top corner, holds the name, the call, its length and place; under a perforation with two punched notches its stub says Pick a time. Beside it a week of days (days with no times are struck through), the day’s free times and Continue.
- **loading**: While getSlots answers, the times are quiet placeholder pills and the days wait; a week that fails says so with Try again.
- **printed**: Picking a time prints it on the stub: the first one fades up out of a 6px blur, later ones roll their hours and minutes the way the time moved (900ms), the date morphs, and a ring is thrown to the time you picked.
- **week / day**: The arrows page by week; the new week and a new day’s times come in from the side you moved to (420ms), and the day highlight is thrown across.
- **details**: Continue turns the times into Name, Email and a note in 460ms (the times slide left through a blur, the form comes in from the right, the height eases).
- **booking**: Book morphs to Booking with a spinner; fields and Back are locked until it settles.
- **booked**: A check draws itself in the button, the surface turns to the confirmation, the stub reads Confirmed and a check stamps onto it (a 0.6 → 1 throw, then the stroke draws).
- **taken**: If onBook throws, the button shakes into Try again, your message replaces the hint, and the week is asked for again so going back shows what is really free.
- **cancel**: Cancel booking asks once (Yes, cancel it, which goes back after 4s), then Cancelling → Cancelled, and the surface turns back to the times.

## What it handles

A booking page is usually a scheduler’s embed or a date picker, a time list, a form and a thank-you page stitched together. This block does the whole round trip on one card, against whatever holds your calendar.

- **Times in the visitor’s zone**: Days and times are worked out in their time zone (or yours, with timeZone), clock changes included, and the zone is printed on the ticket.
- **One request per week**: getSlots gets the week on screen, is aborted if you move on first, and the week is kept until a booking or cancel changes it.
- **The time prints on the ticket**: Picking a time rolls it onto the stub and the date morphs, so what you are booking is never in doubt.
- **Details on the same surface**: The times turn into name, email and a note; missing or wrong fields shake and say why.
- **Taken while you typed**: If onBook throws, its message shows under Try again and the week is fetched again, so the taken time is gone when you go back.
- **A confirmation that does things**: Add to calendar downloads a real .ics, Google Calendar opens prefilled, the meeting link is there, and onCancel gives a two-step Cancel.

## Replaces

a scheduler embed, a week picker, a time slot list, a booking form, a confirmation page, an .ics download, a cancel link

## Where it fits

- **Book a demo**: A sales or founder call on the pricing page or after sign-up, without sending people to another site.
- **Consultations**: Coaches, clinics and agencies taking sessions from their own site.
- **Appointments**: Salons, studios and repair shops showing the week’s free times.
- **Onboarding calls**: A setup call offered inside the product, booked into your team’s calendar.

## Notes

- Days and times are radio groups with arrow keys (up and down move a row of times), Home and End; each face is a labelled group that takes focus when the surface turns to it, and Escape goes back from your details.
- The printed time is read as a whole ("Tuesday, October 28, 10:30 AM") from sr-only text, never digit by digit; Booked and Cancelled are announced from a status region beside the card.
- Field errors are tied to their fields and shake once; a refused booking is an alert under the button and keeps everything typed.
- Today and the visitor’s time zone are read once mounted, so the server render never shows a wrong day.
- Its light comes from your theme: the ticket mixes --primary-foreground into --primary for the lit corner, 1px light edges and the perforation, and the notches are cut by a mask, so it follows your brand in light and dark. No drop shadows, glow or blur.
- Built from Input, Textarea, Number roll, Status button and Text morph, which the CLI installs with it. No animation library and no date library; with reduced motion the faces crossfade in place and nothing slides.

License: MIT (https://hairlineui.com/license#components)
