# Booking dialog with shadcn

Add a "Book a call" button to your site's header that opens the clndr.pro booking form in a shadcn Dialog on desktop and a Drawer on phones, and closes itself after the booking.

A booking form doesn't need its own page. This tutorial puts a **Book a call** button in your site header: on a laptop it opens a shadcn `Dialog`, on a phone a bottom `Drawer`, and after the guest books it shows the confirmation for a couple of seconds and closes.

The form inside is the SDK's `BookingForm` drawn with your shadcn components, so there's no booking logic to write. If you built the [custom widget](/tutorials/shadcn-booking-widget), drop that in instead; the dialog part doesn't change.

**Add a Book a call dialog to my header**

```text
Add a "Book a call" button to this site's header that opens a clndr.pro booking form, following https://docs.clndr.pro/tutorials/shadcn-booking-dialog.md (read it first).

- Use @clndr-pro/react 0.2.1+ and BookingForm with this project's Button/Input/Label/Textarea components. If there's no ClndrProvider yet, add one as in https://docs.clndr.pro/nextjs.md.
- Desktop (min-width 768px): shadcn Dialog. Phones: shadcn Drawer. Add whichever of dialog, drawer, button, input, label, textarea the project is missing.
- Keep an accessible title and description on the dialog (visually hidden is fine).
- Close it 2.5 seconds after onBooked fires.
- Ask me for the booking page slug and the button label. Run the type checker and the build when you're done.
```

## Before you start

You need shadcn/ui, a [`ClndrProvider`](/nextjs#the-browser-side) with your publishable key, and these components:

```bash
npx shadcn@latest add dialog drawer button input label textarea
```

**Draw the form with shadcn**

Wrap `BookingForm` once with your components and classes, so the dialog and the drawer render the same thing. [Styling and slots](/react/styling) explains each key.

```tsx title="components/booking/booking-form-shadcn.tsx"
'use client';

import { BookingForm, type BookingFormProps } from '@clndr-pro/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';

/** The SDK's booking flow, drawn with your shadcn components. */
export function ShadcnBookingForm(props: Omit<BookingFormProps, 'components' | 'classNames'>) {
  return (
    <BookingForm
      {...props}
      components={{ Button, Input, Label, Textarea }}
      classNames={{
        root: 'space-y-5',
        heading: 'text-lg font-semibold',
        muted: 'text-sm text-muted-foreground',
        label: 'mb-2 block',
        dateInput: 'w-full',
        slotGrid: 'mt-4 grid grid-cols-3 gap-2',
        slotButton: 'bg-background text-foreground border hover:bg-accent hover:text-accent-foreground',
        card: 'space-y-4',
        buttonSecondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        error: 'text-sm text-destructive',
        description: 'text-sm text-muted-foreground',
      }}
    />
  );
}
```

**Know when you're on a phone**

A dialog centred on a 390px screen leaves the date input and the keyboard fighting for space; a drawer from the bottom is the native pattern there. This hook reports whether a media query matches, and returns `false` on the server so the first render is stable:

```ts title="hooks/use-media-query.ts"
import { useSyncExternalStore } from 'react';

/** `true` while the media query matches. `false` during server rendering. */
export function useMediaQuery(query: string) {
  return useSyncExternalStore(
    (onChange) => {
      const list = window.matchMedia(query);
      list.addEventListener('change', onChange);
      return () => list.removeEventListener('change', onChange);
    },
    () => window.matchMedia(query).matches,
    () => false,
  );
}
```

**The button, the dialog and the drawer**

```tsx title="components/booking/book-call-button.tsx"
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import {
  Drawer,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from '@/components/ui/drawer';
import { useMediaQuery } from '@/hooks/use-media-query';
import { ShadcnBookingForm } from './booking-form-shadcn';

export function BookCallButton({ slug, label = 'Book a call' }: { slug: string; label?: string }) {
  const [open, setOpen] = useState(false);
  const isDesktop = useMediaQuery('(min-width: 768px)');

  // Leave the confirmation on screen for a moment, then close.
  const onBooked = () => setTimeout(() => setOpen(false), 2500);

  if (isDesktop) {
    return (
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogTrigger asChild>
          <Button>{label}</Button>
        </DialogTrigger>
        <DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
          <DialogHeader className="sr-only">
            <DialogTitle>{label}</DialogTitle>
            <DialogDescription>Pick a time that works for you.</DialogDescription>
          </DialogHeader>
          <ShadcnBookingForm slug={slug} onBooked={onBooked} />
        </DialogContent>
      </Dialog>
    );
  }

  return (
    <Drawer open={open} onOpenChange={setOpen}>
      <DrawerTrigger asChild>
        <Button>{label}</Button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader className="sr-only">
          <DrawerTitle>{label}</DrawerTitle>
          <DrawerDescription>Pick a time that works for you.</DrawerDescription>
        </DrawerHeader>
        <div className="max-h-[80dvh] overflow-y-auto px-4 pb-6">
          <ShadcnBookingForm slug={slug} onBooked={onBooked} />
        </div>
      </DrawerContent>
    </Drawer>
  );
}
```

Things worth knowing about this component:

* **Radix needs a title.** `Dialog` and `Drawer` warn without a `DialogTitle` and `DialogDescription`. `BookingForm` already shows the page title as a heading, so the dialog's own header is `sr-only`: screen readers announce it, nobody sees it twice.
* **Closing resets the form.** shadcn's `DialogContent` and `DrawerContent` unmount when closed, so the next open starts at the date picker again. That also means the booking page is fetched again on each open.
* **The delay is for the guest.** `onBooked` fires as the success message appears. Closing at once would hide it; 2.5 seconds is long enough to read "Booking confirmed" or "Request sent".
* **Long forms scroll inside.** `max-h-[90dvh] overflow-y-auto` keeps the **Confirm** button reachable when a page asks several questions.

**Put it in the header**

`BookCallButton` is a client component; your header can stay a Server Component and render it like any other element:

```tsx title="components/site/site-header.tsx"
import Link from 'next/link';
import { BookCallButton } from '@/components/booking/book-call-button';

export function SiteHeader() {
  return (
    <header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur">
      <div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
        <Link href="/" className="font-semibold">
          Acme Studio
        </Link>
        <nav className="flex items-center gap-4 text-sm">
          <Link href="/work">Work</Link>
          <Link href="/pricing">Pricing</Link>
          <BookCallButton slug="intro-call" />
        </nav>
      </div>
    </header>
  );
}
```

## Variations

* **Several pages, one button.** Show a short list first (`useClndr().bookingPages.list()`, filtered on `is_active` and `visibility === 'public'`), then render the form for the chosen slug in the same dialog.
* **Prefill.** Pass `prefill={{ name, email }}` through `ShadcnBookingForm` when the visitor is signed in.
* **No shadcn Drawer.** Drop the `isDesktop` branch and keep the `Dialog`: on phones it still works, just less comfortably.
* **No design system at all.** `BookingModal` is the built-in version of this: a fixed overlay with Escape, outside-click and auto-close after booking. See [Components](/react/components#bookingmodal).
