Retour au catalogue

Contact Booking

Section reservation d'appel avec calendrier placeholder et choix de creneaux horaires.

contactmedium Both Responsive a11y
corporateminimalsaasagencymedicalsplit
Theme

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

  1. 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);
  2. 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"
    >
  3. 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 }}
    >
  4. 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.

"use client";

import { useState } from "react";
import { motion } from "framer-motion";
import { Calendar, Clock, Check } from "lucide-react";

interface TimeSlot {
  id: string;
  label: string;
}

interface ContactBookingProps {
  badge?: string;
  title?: string;
  subtitle?: string;
  slots?: TimeSlot[];
  ctaLabel?: string;
  calendarLabel?: string;
}

const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Booking Section with Time Slot Picker, Tutorial