How to build a booking contact section in React
A React booking section pairs a calendar grid placeholder on the left with a scrollable list of time slot buttons on the right. Selecting a slot enables the confirm button; clicking it transitions to a success state via a Framer Motion scale-in animation.
- Stack: React 18 + Framer Motion 11 + Tailwind v4 + lucide-react (Calendar, Clock, Check icons), ~167 lines.
- State: two useState hooks, selectedSlot (string | null) and booked (boolean). No external form library needed.
- Animations: header fades in with y:20 on scroll (whileInView, once:true); calendar and slots panels slide in from opposite sides with staggered delays.
- Accessible: confirm button uses disabled attribute when no slot is selected, preventing submission without a valid choice.
- Responsive: stacks vertically on mobile (single column), side-by-side from lg breakpoint.
Contact Booking is a split-layout React section designed to convert page visitors into scheduled calls. A calendar placeholder sits on the left; a list of selectable time slots on the right. Once a slot is chosen and confirmed, the entire panel swaps out for an animated success screen. It covers the full flow, browse, select, confirm, without reaching for a third-party calendar SDK.
Anatomy
The section is a centered max-w-5xl container with three layers. At the top, a motion.div header holds a badge, an h2 title, and a subtitle, all center-aligned. Below it, a two-column grid (lg:grid-cols-2) splits the calendar panel from the slots panel. The calendar panel is a rounded card with a 7-column day header and a 28-cell date grid; the 15th cell is highlighted in accent color as a static placeholder. The slots panel lists time slot buttons as full-width cards, followed by the confirm CTA. When booked is true, the grid is replaced by a centered success card with a Check icon, a heading, and a helper text.
How it works
Every motion.div uses the `whileInView` + `viewport={{ once: true }}` pattern so animations fire once when the section scrolls into view. The header enters with `{ opacity: 0, y: 20 }`, easing to rest over 0.6s. The calendar and slots panels mirror each other: the calendar slides in from x:-20 with a 0.05s delay, the slots panel from x:20 with a 0.15s delay, creating a subtle convergence. All transitions share the same cubic-bezier spring `[0.16, 1, 0.3, 1]`. Slot selection is pure React state: clicking a button sets `selectedSlot` to its id; the button style switches to accent background and contrasting text. Clicking confirm sets `booked` to true, which unmounts the grid and mounts the success card via `{ opacity: 0, scale: 0.95 }` animate to `{ opacity: 1, scale: 1 }`.
How to build it in React
Set up state and the ease constant
Declare two state values: `selectedSlot` to track which time slot is active, and `booked` to control the confirmation screen. Define the ease tuple once at module level so every transition shares the same curve without repeating it.
const ease: [number, number, number, number] = [0.16, 1, 0.3, 1]; const [selectedSlot, setSelectedSlot] = useState<string | null>(null); const [booked, setBooked] = useState(false);Build the animated header
Wrap the badge, h2, and subtitle in a single motion.div with `initial={{ opacity: 0, y: 20 }}` and `whileInView={{ opacity: 1, y: 0 }}`. Pass `viewport={{ once: true }}` so the animation only plays once as the user scrolls down.
<motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease }} viewport={{ once: true }} className="text-center mb-14" >Slide in the two panels from opposite sides
Give the calendar panel `initial={{ x: -20 }}` and the slots panel `initial={{ x: 20 }}`, both animating to `x: 0`. Stagger the delays slightly (0.05s vs 0.15s) so they converge rather than appearing simultaneously.
// Calendar panel <motion.div initial={{ opacity: 0, x: -20 }} whileInView={{ opacity: 1, x: 0 }} transition={{ duration: 0.5, ease, delay: 0.05 }} viewport={{ once: true }} > // Slots panel <motion.div initial={{ opacity: 0, x: 20 }} whileInView={{ opacity: 1, x: 0 }} transition={{ duration: 0.5, ease, delay: 0.15 }} viewport={{ once: true }} >Handle confirmation and the success state
The confirm button calls `setBooked(true)` only when `selectedSlot` is not null; the `disabled` attribute blocks the call otherwise. When `booked` is true, render the success card instead of the grid, mounting it with a scale-in animation for a smooth reveal.
{booked ? ( <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.4, ease }} className="rounded-xl p-12 text-center mx-auto max-w-md" > <Check size={40} style={{ color: "var(--color-accent)" }} /> </motion.div> ) : ( <div className="grid lg:grid-cols-2 gap-8">...</div> )}
When to use it
Use this section as a page closer on agency, SaaS, consulting, or medical service sites where the goal is to schedule a discovery call. It works well after a pricing or testimonials section. Skip it when you need a real calendar (Google Calendar, Calendly), this component intentionally has a placeholder grid and no date-picker logic. On fully automated booking flows, integrate a real scheduling SDK and keep only the layout shell.
Used by
- Calendly, The canonical split-layout booking UI: calendar on the left, time slots on the right, confirmation step after selection.
- Cal.com, Open-source scheduling with the same two-panel layout; the slot list and confirm button pattern mirrors this component closely.
- Doctolib, Medical appointment booking relying on a grid calendar paired with available slot buttons and a clear confirmation screen.
- HubSpot Meetings, Embeddable booking pages used by B2B SaaS teams; same slot-picker and step-completion pattern for sales calls.
FAQ
How do I connect this to a real calendar backend?
Replace the `slots` prop with data fetched from your API (Calendly, Cal.com, or your own availability endpoint). On confirm, fire a POST request with the selected slot id before calling `setBooked(true)`.
Can I use a real date picker instead of the placeholder grid?
Yes. Swap the calendar panel content for react-day-picker or a native <input type='date'>, then derive available slots from the chosen date. The layout shell and animations remain untouched.
Why does the confirm button stay disabled until a slot is selected?
The button reads `disabled={!selectedSlot}` and the click handler checks `selectedSlot &&` before updating state. This prevents the confirmation screen from appearing with no actual booking data, avoiding a confusing empty success state.
How do I add a reset so users can book another slot?
On the success card, add a button that calls `setBooked(false)` and `setSelectedSlot(null)`. The grid will re-mount and Framer Motion will re-run the entrance animations since `whileInView` has already fired; set `once: false` on the viewport prop if you want them to replay.