Booking dialog with shadcn

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, drop that in instead; the dialog part doesn't change.

Add a Book a call dialog to my headerPaste into Claude Code

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 with your publishable key, and these components:

npx shadcn@latest add dialog drawer button input label textarea
  1. 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 explains each key.

    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',
          }}
        />
      );
    }
  2. 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:

    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,
      );
    }
  3. The button, the dialog and the drawer

    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.
  4. Put it in the header

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

    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.