Skip to content
Motion
FreeIn reviewv1.0.0

Booking Request

Pick a service, a day and a time of day, leave a name and email: a real request the business answers.

npx shadcn@latest add @vibebox/booking-cta-request

Controls

Variant
<BookingCtaRequest />

About

The Launchpad site kit put a working widget on every site: the one thing a visitor comes to do (decision 0016). Its booking flow ended in an on-page confirmation with invented "taken" slots, which a live site cannot honestly show until a booking module exists (Phase 7). This block keeps the part that works today: the business's own services, a preferred day and time of day, and a way to reply, sent as an inquiry through the app's inquiry route. It differs from booking-cta-band (a heading and a link to book) by doing the asking on the page.

Use it when

  • The business takes visits or appointments and answers requests by message or email.
  • There is no booking system yet, or the client prefers to confirm times by hand.

Skip it when

  • Bookings run through a booking module: the module's own block replaces this (Phase 7).
  • The site only needs a link to an outside booking page: use booking-cta-band.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the block with the shadcn CLI:
    npx shadcn@latest add @vibebox/booking-cta-request
  3. 3Use it:
    import { BookingCtaRequest } from "@/components/vibebox/booking-cta-request";
    import { home } from "@/content/pages/home";
    
    <BookingCtaRequest content={home.book} variant="split" id="book" />

Code

2 files
components/vibebox/booking-cta-request/index.tsx
/**
 * VibeBox Motion · Booking Request
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { ACTION_STYLES, buttonClass, Container, headingId, Icon, REVEAL, Section, SectionHead, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";

/*
 * The booking a visitor came for, as a request: the business's own services, a preferred day and
 * time of day, and a way to reply. A plain HTML form that posts to the app's inquiry route (which
 * folds the choices into the message), so it works without JavaScript and nothing is confirmed
 * that has not happened. Chips are radio inputs; a three-step strip lights as the request takes
 * shape (CSS :has). In preview it never submits.
 */

const STYLES = `
[data-vb="booking-cta-request"] [data-vb-step] > span { transform-origin: left; transform: scaleX(0); transition: transform var(--vb-duration-reveal) var(--ease-expo-out); }
[data-vb="booking-cta-request"] form:has([name="service"]:checked) [data-vb-step="1"] > span,
[data-vb="booking-cta-request"] form:has(:is([name="day"], [name="time"]):checked) [data-vb-step="2"] > span,
[data-vb="booking-cta-request"] form:has([name="name"]:valid):has([name="email"]:valid) [data-vb-step="3"] > span { transform: scaleX(1); }
@media (prefers-reduced-motion: reduce) { [data-vb="booking-cta-request"] :is([data-vb-step] > span, [data-vb-chip]) { transition: none; } }
[data-motion="0"] [data-vb="booking-cta-request"] :is([data-vb-step] > span, [data-vb-chip]) { transition: none; }
`;

const fieldClass =
  "h-12 w-full rounded-md border border-input bg-background px-3.5 text-body text-foreground transition-colors duration-(--vb-duration-micro) placeholder:text-subtle-foreground hover:border-border-strong focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-ring/40";

function Chips({ name, legend, legendPath, options, optionsPath, checked }: { name: string; legend: string; legendPath: string; options: string[]; optionsPath: string; checked?: number }) {
  return (
    <fieldset className="grid min-w-0 content-start gap-2.5">
      <legend className="mb-2.5 text-small font-medium text-muted-foreground">
        <span data-slot={legendPath}>{legend}</span>
      </legend>
      <div className="flex flex-wrap items-start gap-2">
        {options.map((option, index) => (
          <label
            key={index}
            data-vb-chip=""
            className={cn(
              "relative inline-flex min-h-11 cursor-pointer items-center rounded-md px-3.5 text-small font-medium ring-1 ring-border-strong ring-inset select-none",
              "transition-[background-color,color,transform] duration-(--vb-duration-micro) ease-expo-out hover:bg-accent active:scale-[0.97]",
              "has-[:checked]:bg-primary has-[:checked]:text-primary-foreground has-[:checked]:ring-transparent",
              "has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-ring",
            )}
          >
            <input type="radio" name={name} value={option} defaultChecked={checked === index} className="sr-only" />
            <span data-slot={`${optionsPath}.${index}`}>{option}</span>
          </label>
        ))}
      </div>
    </fieldset>
  );
}

export type BookingCtaRequestProps = BlockProps<Content, Variant> & {
  /** Where the form posts. Assembled apps handle /api/inquiries. */
  action?: string;
  /** Launchpad preview: the button does nothing. */
  preview?: boolean;
};

export function BookingCtaRequest({ content, variant = "split", id, tone, className, action = "/api/inquiries", preview = false }: BookingCtaRequestProps) {
  const { request } = content;
  const stacked = variant === "stacked";
  const steps = [request.serviceLabel, request.dayLabel, request.nameLabel];
  return (
    <Section slug="booking-cta-request" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
      <style href="vb-booking-cta-request" precedence="vb">
        {STYLES}
      </style>
      <Container className={cn("grid gap-[clamp(32px,4vw,56px)]", !stacked && "lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:items-start lg:gap-16")}>
        <div className={cn("grid content-start gap-6", !stacked && "lg:sticky lg:top-24")}>
          <SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} />
          {content.details.length > 0 && (
            <dl className="flex flex-wrap gap-x-10 gap-y-4 border-t border-border pt-6">
              {content.details.map((detail, index) => (
                <div key={index} className="grid min-w-0 gap-1">
                  <SlotText as="dt" value={detail.label} path={`details.${index}.label`} className="text-small text-muted-foreground" />
                  <SlotText as="dd" value={detail.value} path={`details.${index}.value`} className="font-medium" />
                </div>
              ))}
            </dl>
          )}
        </div>

        <form
          action={preview ? undefined : action}
          method={preview ? undefined : "post"}
          className={cn("relative grid gap-7 rounded-xl bg-card p-5 text-card-foreground shadow-sm ring-1 ring-border sm:p-8", REVEAL)}
        >
          <input type="hidden" name="source" value="booking" />
          {/* A field people never see: bots fill it in, and the inquiry route drops those posts. */}
          <div aria-hidden="true" className="absolute -left-[10000px] h-px w-px overflow-hidden">
            <label>
              Company
              <input type="text" name="company" tabIndex={-1} autoComplete="off" />
            </label>
          </div>
          <ol aria-hidden="true" className="grid grid-cols-3 gap-2">
            {steps.map((label, index) => (
              <li key={index} className="grid gap-1.5">
                <span data-vb-step={index + 1} className="relative block h-0.5 overflow-hidden rounded-full bg-border">
                  <span className="absolute inset-0 rounded-full bg-primary" />
                </span>
                <span className="truncate font-mono text-caption text-subtle-foreground">
                  {String(index + 1).padStart(2, "0")} {label}
                </span>
              </li>
            ))}
          </ol>
          <Chips name="service" legend={request.serviceLabel} legendPath="request.serviceLabel" options={request.services} optionsPath="request.services" checked={0} />
          <div className="grid gap-7 sm:grid-cols-2">
            <Chips name="day" legend={request.dayLabel} legendPath="request.dayLabel" options={request.days} optionsPath="request.days" />
            <Chips name="time" legend={request.timeLabel} legendPath="request.timeLabel" options={request.times} optionsPath="request.times" />
          </div>
          <div className={cn("grid gap-4", request.phoneLabel ? "sm:grid-cols-3" : "sm:grid-cols-2")}>
            <label className="grid gap-1.5">
              <span data-slot="request.nameLabel" className="text-small font-medium">
                {request.nameLabel}
              </span>
              <input type="text" name="name" required autoComplete="name" maxLength={120} className={fieldClass} />
            </label>
            <label className="grid gap-1.5">
              <span data-slot="request.emailLabel" className="text-small font-medium">
                {request.emailLabel}
              </span>
              <input type="email" name="email" required autoComplete="email" maxLength={200} className={fieldClass} />
            </label>
            {request.phoneLabel && (
              <label className="grid gap-1.5">
                <span data-slot="request.phoneLabel" className="text-small font-medium">
                  {request.phoneLabel}
                </span>
                <input type="tel" name="phone" autoComplete="tel" inputMode="tel" maxLength={40} className={fieldClass} />
              </label>
            )}
          </div>
          <div className="flex flex-col gap-4 border-t border-border pt-6 sm:flex-row sm:items-center sm:justify-between">
            <SlotText as="p" value={request.note} path="request.note" className="max-w-[40ch] text-small text-pretty text-muted-foreground" />
            <button type={preview ? "button" : "submit"} className={cn(buttonClass("primary", "lg"), "vb-action-primary w-full shrink-0 sm:w-auto")}>
              <style href="vb-action" precedence="vb">
                {ACTION_STYLES}
              </style>
              <span aria-hidden="true" className="vb-action-fx" />
              <span data-slot="primaryAction.label" className="vb-action-label truncate">
                {content.primaryAction.label}
              </span>
              <span aria-hidden="true" className="vb-action-go inline-grid">
                <Icon name="arrow-right" className="size-4 transition-transform duration-(--vb-duration-micro) group-hover/action:translate-x-0.5" />
                <Icon name="arrow-right" className="size-4" />
              </span>
            </button>
          </div>
        </form>
      </Container>
    </Section>
  );
}
components/vibebox/booking-cta-request/schema.ts
/**
 * VibeBox Motion · Booking Request
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { list, sections, text, z } from "@/lib/vibebox/content";

export const variants = ["split", "stacked"] as const;

export type Variant = (typeof variants)[number];

/** The booking section's base, plus the request form: what, which day, what time, and who. */
export const content = sections["booking-cta"].extend({
  request: z.strictObject({
    serviceLabel: text({ role: "Service question", max: 32 }),
    services: list(text({ role: "Service", max: 28 }), { role: "Services", min: 1, max: 6 }),
    dayLabel: text({ role: "Day question", max: 32 }),
    days: list(text({ role: "Day", max: 14 }), { role: "Days", min: 2, max: 4 }),
    timeLabel: text({ role: "Time question", max: 32 }),
    times: list(text({ role: "Time", max: 14 }), { role: "Times", min: 2, max: 4 }),
    nameLabel: text({ role: "Name label", max: 24 }),
    emailLabel: text({ role: "Email label", max: 24 }),
    phoneLabel: text({ role: "Phone label", max: 24 }).optional(),
    note: text({ role: "Note", max: 120, fillIn: true }).optional(),
  }),
});

export type Content = z.infer<typeof content>;

Content

Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
leadIntrotext160 chars—
primaryActionMain buttonaction24 chars—
secondaryActionSecond buttonaction24 chars—
detailsDetailslistup to 3—
details[].labelLabeltext24 chars—
details[].valueValuetext60 charsCan be a fill-in
request.serviceLabelService questiontext32 chars—
request.servicesServiceslistup to 6—
request.services[]Servicetext28 chars—
request.dayLabelDay questiontext32 chars—
request.daysDayslistup to 4—
request.days[]Daytext14 chars—
request.timeLabelTime questiontext32 chars—
request.timesTimeslistup to 4—
request.times[]Timetext14 chars—
request.nameLabelName labeltext24 chars—
request.emailLabelEmail labeltext24 chars—
request.phoneLabelPhone labeltext24 chars—
request.noteNotetext120 charsCan be a fill-in

Variants

  • Splitdefault

    split

    Heading, details and note on the left; the request card on the right on desktop.

  • Stacked

    stacked

    Heading above a wide request card, for a booking page.

Accessibility and motion

Keyboard
Each question is a radio group (arrow keys move within it), then the fields and the send button.
Screen readers
Each question is a fieldset with a legend; the fields have labels; the note is read before the button.
Touch
Chips are at least 44 px tall; nothing depends on hover.
Reduced motion
The card appears without its reveal; chips change state at once.

Edge cases and limits

  • Six long service names wrap onto several rows of chips.
  • Without :has support the step strip stays empty; everything else works.
  • The main action's href is not used: the button sends the form, and its label is the button's.
  • A tinted booking band with a heading, visit details and a Book online button

    When, how long and one clear booking action, on a tinted band or a centred card.

    Booking block · Server

  • Contact channels with icons and a map scene beside an inquiry form

    Every way to reach the business beside an inquiry form that works without JavaScript; facts are fill-ins until given.

    Contact block · Server

  • A split hero: a headline and two buttons beside arches with a booking card and a treatment list

    The promise beside a stage: your photo or a scene with two floating cards that show what booking or ordering looks like.

    Heroes block · three.js

↑ ↓ to move↵ to openesc to close