Retour au catalogue

Contact Booking

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

contactmedium Both Responsive a11y
corporateminimalsaasagencymedicalsplit
Theme

Cómo crear una sección de reservas de contacto en React

Una sección de reservas React combina un calendario de marcador de posición a la izquierda con una lista desplazable de botones de franjas horarias a la derecha. Seleccionar una franja activa el botón de confirmar; al hacer clic, transiciona a un estado de éxito mediante una animación scale-in de Framer Motion.

  • Stack: React 18 + Framer Motion 11 + Tailwind v4 + lucide-react (iconos Calendar, Clock, Check), ~167 líneas.
  • Estado: dos hooks useState, selectedSlot (string | null) y booked (boolean). No se necesita ninguna librería de formularios externa.
  • Animaciones: el header aparece con y:20 al hacer scroll (whileInView, once:true); los paneles de calendario y franjas se deslizan desde lados opuestos con retrasos escalonados.
  • Accesible: el botón de confirmar usa el atributo disabled cuando no hay ninguna franja seleccionada, impidiendo el envío sin una elección válida.
  • Responsive: se apila verticalmente en móvil (una sola columna), en paralelo a partir del breakpoint lg.

Contact Booking es una sección React con layout dividido diseñada para convertir a los visitantes en llamadas programadas. Un calendario de marcador de posición ocupa la izquierda; una lista de franjas horarias seleccionables, la derecha. Una vez elegida y confirmada una franja, todo el panel se reemplaza por una pantalla de éxito animada. Cubre todo el flujo, explorar, elegir, confirmar, sin recurrir a un SDK de calendario de terceros.

Anatomía

La sección es un contenedor centrado max-w-5xl estructurado en tres capas. Arriba, un header motion.div contiene un badge, un h2 y un subtítulo, todos centrados. Debajo, una cuadrícula de dos columnas (lg:grid-cols-2) separa el panel del calendario del panel de franjas. El panel del calendario es una tarjeta redondeada con un header de 7 columnas para los días y una cuadrícula de 28 celdas de fechas; la 15.ª celda se resalta en color de acento como marcador de posición estático. El panel de franjas lista los botones de franjas horarias como tarjetas a todo el ancho, seguidos del CTA de confirmación. Cuando booked es true, la cuadrícula se reemplaza por una tarjeta de éxito centrada con un icono Check, un título y un texto de ayuda.

Cómo funciona

Cada motion.div usa el patrón `whileInView` + `viewport={{ once: true }}` para que las animaciones se disparen una sola vez cuando la sección entra en el viewport. El header entra con `{ opacity: 0, y: 20 }`, estabilizándose en 0.6s. Los paneles de calendario y franjas se reflejan mutuamente: el calendario se desliza desde x:-20 con un retraso de 0.05s, el panel de franjas desde x:20 con 0.15s, creando una sutil convergencia. Todas las transiciones comparten el mismo cubic-bezier `[0.16, 1, 0.3, 1]`. La selección de franja es estado React puro: hacer clic en un botón asigna su id a `selectedSlot`; el estilo del botón cambia a fondo de acento y texto contrastado. Hacer clic en confirmar asigna true a `booked`, lo que desmonta la cuadrícula y monta la tarjeta de éxito mediante `{ opacity: 0, scale: 0.95 }` animado hacia `{ opacity: 1, scale: 1 }`.

Cómo crearlo en React

  1. Inicializar el estado y la constante ease

    Declara dos valores de estado: `selectedSlot` para rastrear qué franja horaria está activa, y `booked` para controlar la pantalla de confirmación. Define la tupla ease una sola vez a nivel de módulo para que todas las transiciones compartan la misma curva sin repetirla.

    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. Construir el header animado

    Envuelve el badge, el h2 y el subtítulo en un único motion.div con `initial={{ opacity: 0, y: 20 }}` y `whileInView={{ opacity: 1, y: 0 }}`. Pasa `viewport={{ once: true }}` para que la animación solo se reproduzca una vez al hacer scroll.

    <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. Deslizar los dos paneles desde lados opuestos

    Da al panel del calendario `initial={{ x: -20 }}` y al panel de franjas `initial={{ x: 20 }}`, ambos animados hacia `x: 0`. Escalona ligeramente los retrasos (0.05s frente a 0.15s) para que converjan en lugar de aparecer simultáneamente.

    // 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. Gestionar la confirmación y el estado de éxito

    El botón de confirmar llama a `setBooked(true)` solo cuando `selectedSlot` no es null; de lo contrario, el atributo `disabled` bloquea la llamada. Cuando `booked` es true, renderiza la tarjeta de éxito en lugar de la cuadrícula, montándola con una animación scale-in para una transición suave.

    {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>
    )}

Cuándo usarlo

Usa esta sección al cierre de página en sitios de agencia, SaaS, consultoría o servicios médicos cuyo objetivo es agendar una llamada de descubrimiento. Funciona bien después de una sección de precios o testimonios. Evítala cuando necesites un calendario real (Google Calendar, Calendly): este componente tiene intencionadamente una cuadrícula de marcador de posición y ninguna lógica de selección de fecha. En flujos de reserva totalmente automatizados, integra un SDK de planificación real y conserva solo el shell del layout.

Usado por

  • Calendly, La interfaz de reservas con layout dividido de referencia: calendario a la izquierda, franjas horarias a la derecha, paso de confirmación tras la selección.
  • Cal.com, Planificación open-source con el mismo layout de dos paneles; la lista de franjas y el botón de confirmar reproducen de cerca el mismo patrón de este componente.
  • Doctolib, Reserva de citas médicas basada en un calendario en cuadrícula combinado con botones de franjas disponibles y una pantalla de confirmación clara.
  • HubSpot Meetings, Páginas de reserva integrables utilizadas por equipos SaaS B2B; mismo patrón de selección de franja y finalización de pasos para llamadas comerciales.

FAQ

¿Cómo conecto esto a un backend de calendario real?

Reemplaza la prop `slots` con datos obtenidos desde tu API (Calendly, Cal.com o tu propio endpoint de disponibilidad). Al confirmar, lanza una petición POST con el id de la franja seleccionada antes de llamar a `setBooked(true)`.

¿Puedo usar un selector de fecha real en lugar de la cuadrícula de marcador de posición?

Sí. Sustituye el contenido del panel del calendario por react-day-picker o un <input type='date'> nativo, y luego deriva las franjas disponibles a partir de la fecha elegida. El shell del layout y las animaciones permanecen intactos.

¿Por qué el botón de confirmar permanece desactivado hasta que se selecciona una franja?

El botón lee `disabled={!selectedSlot}` y el handler de clic comprueba `selectedSlot &&` antes de actualizar el estado. Esto evita que la pantalla de confirmación aparezca sin datos de reserva reales, evitando un estado de éxito vacío y confuso.

¿Cómo agrego un reinicio para que los usuarios puedan reservar otra franja?

En la tarjeta de éxito, agrega un botón que llame a `setBooked(false)` y `setSelectedSlot(null)`. La cuadrícula se volverá a montar y Framer Motion volverá a ejecutar las animaciones de entrada ya que `whileInView` ya se disparó; establece `once: false` en la prop viewport si quieres que se reproduzcan de nuevo.

"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

Reseñas

Sección de reservas React con selector de franjas horarias,