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
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);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" >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 }} >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.