Three intake questions that land on a real appointment type instead of a drop-down: the answers pick a route with its length, its facts, and the clinician you will sit with, and the image tile beside them swaps its floated availability card to match.
Three intake questions that land on a real appointment type instead of a drop-down. The answers pick a route with its length, its facts, and the clinician you will sit with, and the image tile beside them swaps its floated availability card to match.
Free block
This block is free. No license or account is required: install it with the CLI and use it in unlimited projects.
Radix flavor
npx shadcn add "https://ui.beste.co/r/booking29"Base UI flavor
npx shadcn add "https://ui.beste.co/r-base/booking29"That installs the block file, the badge23 eyebrow and button21 action button it is built from, the card31 piece it floats over the image, and the questionnaire primitive.
import { Booking29, booking29Demo } from "@/components/beste/block/booking29";
export default function Page() {
return <Booking29 {...booking29Demo} />;
}import { Booking29 } from "@/components/beste/block/booking29";
import { Card31 } from "@/components/beste/piece/card31";
export default function Page() {
return (
<Booking29
badge={{ label: "Before you book" }}
heading="Three questions decide the appointment, not a drop-down"
description="Every combination lands on a real appointment type."
shortcuts="numbers"
questions={[
{
name: "reason",
title: "What brings you in?",
choices: [
{ value: "assessment", label: "Something new" },
{ value: "review", label: "A follow-up" },
],
},
{
name: "history",
title: "Have you been seen here before?",
choices: [
{ value: "new", label: "First time" },
{ value: "returning", label: "I am already registered" },
],
},
]}
routes={[
{
// both answers have to fit, so this sits above the looser route
match: { reason: "assessment", history: "new" },
title: "First assessment, 60 minutes",
summary: "A full first appointment with the assessment lead.",
facts: [
{ label: "With", value: "Dr Amelia Frost" },
{ label: "Length", value: "60 minutes" },
],
media: <Card31 name="Dr Amelia Frost" role="Assessment lead" availability="soon" />,
},
{
// no match: the catch-all, and it has to be last
title: "Advice call, 15 minutes",
summary: "A short call with whoever is on duty.",
media: <Card31 name="Sara Okonjo" role="Duty practitioner" availability="free" />,
},
]}
image={{ src: "https://images.unsplash.com/photo-1750918619871-dc74c9a57394", alt: "Backdrop" }}
media={<Card31 name="Sara Okonjo" role="Duty practitioner" availability="free" />}
waitingBody="Whoever you end up with, the notes follow you."
button={{ label: "Choose a time", href: "https://beste.co" }}
/>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
badge | Badge | – | Eyebrow above the rule, rendered with Badge23. |
heading | string | – | Section heading. |
description | string | – | Lead paragraph, set opposite the heading. |
questions | IntakeQuestion[] | [] | The intake questions, in the order they are asked. |
shortcuts | "letters" | "numbers" | – | Prints a shortcut on every choice of the active question. |
routes | Route[] | [] | Checked in order; the first one whose match fits wins. |
image | TileImage | – | Backdrop behind the floated piece. |
media | ReactNode | – | Piece shown on the tile before anything is answered. |
waitingBody | string | – | Line under the tile, kept in both states. |
button | ActionLink | – | Button21 beside the matched appointment. |
labels | Booking29Labels | {} | Overrides for the navigation buttons and the two panel headings. |
className | string | – | Merged onto the section element. |
type Badge = { label: string };
type ActionLink = { label: string; href: string };
type TileImage = { src: string; alt: string };
type Fact = { label: string; value: string };
type IntakeQuestion = {
name: string;
title: string;
description?: string;
choices?: IntakeChoice[];
};
type IntakeChoice = { value: string; label: string; description?: string };
type Route = {
match?: Record<string, string>;
title: string;
summary: string;
facts?: Fact[];
media?: ReactNode;
};
type Booking29Labels = {
previous?: string;
next?: string;
submit?: string;
restart?: string;
matchTitle?: string;
waitingTitle?: string;
};match maps a question's name to the value it needs, and every pair has to fit. Narrow routes belong above loose ones, because the list is read top to bottom and the first fit wins.match and it reads as a deliberate catch-all.media, pre-configured in the demo data rather than assembled by the block. Give every embedded Card31 its full props: a bare one renders the empty defaults and the tile looks broken.media.booking27
A clinician picker whose hairline list of practitioners, each showing their next opening, swaps an image tile floating a live availability card alongside their focus, background, and sites.
booking28
A no-availability state that answers with three hairline figures and a numbered explanation of how offers are made, beside a sticky square tile floating a live panel of the waiting list itself and an urgent fallback.
booking24
An interactive appointment picker: a hairline panel pairing a selectable service list with a scrollable day strip and a time slot grid that marks unavailable times, beside a full-height provider photo with a gradient name card, closing on a live summary bar and confirm action.
booking4
Service selection grid with category filter tabs, service cards showing icon, name, description, duration, price, and selected state. Ideal for spa, salon, clinic, and fitness booking flows.