Retour au catalogue

Contact Calendar Embed

Section contact avec selecteur de creneaux horaires style calendrier. L'utilisateur choisit un jour et un horaire pour un rendez-vous.

contactcomplex Both Responsive a11y
corporateelegantminimalsaasagencymedicallegaluniversalsplit
Theme

Cómo crear un selector de fecha y franja horaria en React

Un calendario de reservas React renderiza una cuadrícula mensual construida con aritmética nativa de Date, mantiene el día y la franja horaria seleccionados en estado local, y anima las transiciones de franjas con AnimatePresence de Framer Motion. No se necesita ninguna librería de calendario externa.

  • Stack: React 18, Framer Motion 11, Lucide React, Tailwind v4, ~230 líneas, sin dependencia de librería de calendario.
  • Cuadrícula mensual calculada con getDaysInMonth y getFirstDayOfWeek usando Date nativo, con desplazamiento de semana que empieza en lunes.
  • Los fines de semana se desactivan automáticamente mediante comprobaciones getDay(); el estado deshabilitado se estiliza con opacity-30.
  • Accesible: los botones llevan el atributo nativo disabled; la navegación por teclado sigue el orden de tabulación entre el calendario y las franjas.
  • El panel de franjas aparece y desaparece con AnimatePresence mode='wait'. Cambiar key={selectedDay} dispara una animación de reentrada en cada nuevo día seleccionado.

Contact Calendar Embed es una sección React autónoma que permite a los visitantes elegir un día y una franja horaria de reunión sin salir de la página ni cargar un widget de terceros. Una cuadrícula mensual a la izquierda y un panel de franjas animado a la derecha hacen que el flujo de dos pasos resulte evidente de un vistazo. Encaja en los flujos de onboarding SaaS, las páginas de contacto de agencias y cualquier servicio profesional que necesite una superficie de reserva integrada.

Anatomía

La sección contiene una cabecera centrada (etiqueta eyebrow, h2, descripción opcional) y una cuadrícula CSS de 5 columnas debajo. El panel del calendario ocupa 3 columnas: una fila de navegación mensual arriba, una fila de cabecera de 7 días, y luego los botones de día en una subcuadrícula de 7 columnas con celdas vacías iniciales para alinear correctamente el primer día de la semana. El panel de franjas (2 columnas) muestra una barra de contexto con la fecha seleccionada y la duración de la sesión, y después un estado vacío o una cuadrícula de 2 columnas de botones de horario con un CTA de confirmación una vez elegida una franja.

Cómo funciona

Toda la lógica del calendario se apoya en useState y dos funciones puras. getDaysInMonth(year, month) llama a new Date(year, month+1, 0).getDate() para obtener el último día. getFirstDayOfWeek devuelve un desplazamiento con lunes en cero (0–6) reasignando el domingo de 0 a 6. Seleccionar un día escribe en el estado selectedDay y reinicia selectedSlot; el bloque AnimatePresence del panel de franjas recibe key={selectedDay}, por lo que React desmonta la lista antigua y monta la nueva, disparando la transición de fundido. Cuando se confirma una franja, un botón de Framer Motion se desliza desde y:8 con la constante de spring E=[0.16,1,0.3,1]. Todos los colores provienen de propiedades CSS personalizadas (--color-accent, --color-background-alt, etc.) para que el componente se adapte a cualquier preset de tema sin cambiar el código.

Cómo crearlo en React

  1. Calcular la cuadrícula mensual

    Dos funciones puras se encargan del cálculo del calendario. getDaysInMonth usa un truco de desbordamiento de Date: pasar el día 0 del mes+1 devuelve el último día del mes actual. getFirstDayOfWeek reasigna el domingo (día JS 0) a la posición 6 para que las semanas empiecen en lunes. Construye dos arrays, los días reales y un array de prefijo en blanco, para alimentar la cuadrícula de 7 columnas.

    function getDaysInMonth(year: number, month: number) {
      return new Date(year, month + 1, 0).getDate();
    }
    function getFirstDayOfWeek(year: number, month: number) {
      const day = new Date(year, month, 1).getDay();
      return day === 0 ? 6 : day - 1;
    }
  2. Conectar la navegación mensual y la selección de día

    Guarda month, year, selectedDay y selectedSlot en cuatro llamadas a useState. Los handlers prev/next gestionan el paso de diciembre a enero y reinician la selección. Los botones de día llaman a isWeekend(day) para deshabilitar sábado y domingo, manteniendo la prop disabled en el botón nativo para que los usuarios de teclado no puedan alcanzar esas celdas.

    const [month, setMonth] = useState(today.getMonth());
    const [selectedDay, setSelectedDay] = useState<number | null>(null);
    const isWeekend = (day: number) => {
      const d = new Date(year, month, day).getDay();
      return d === 0 || d === 6;
    };
  3. Animar el panel de franjas con AnimatePresence

    Envuelve la lista de franjas en un bloque AnimatePresence con mode='wait'. Dale a la motion.div una key={selectedDay}: cuando cambia el día, React destruye los hijos antiguos y monta los nuevos, ejecutando primero la animación de salida y luego la de entrada. Una simple transición de opacity:0 a opacity:1 evita los saltos de maquetación a la vez que ofrece una respuesta visible.

    <AnimatePresence mode="wait">
      {selectedDay ? (
        <motion.div
          key={selectedDay}
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          className="grid grid-cols-2 gap-2"
        >
          {timeSlots.map((slot) => (
            <button key={slot} onClick={() => setSelectedSlot(slot)}>
              {slot}
            </button>
          ))}
        </motion.div>
      ) : <EmptyState />}
    </AnimatePresence>
  4. Estilizar con propiedades CSS personalizadas para la portabilidad de tema

    Nunca codifiques los colores en duro. Usa --color-accent para los fondos del día y la franja seleccionados, --color-background-alt para las superficies de las tarjetas, y --color-border para los contornos. Los siete presets de tema del registry (lime-light, violet-dark, etc.) definen todos estos tokens, por lo que el calendario se renderiza correctamente sin ningún override de estilo.

    style={{
      background: selected ? "var(--color-accent)" : "transparent",
      color: selected ? "var(--color-background)" : "var(--color-foreground)",
    }}

Cuándo usarlo

Usa esta sección cuando quieras que los visitantes se agenden ellos mismos sin navegar a otra página ni cargar Calendly en un iframe. Encaja bien en las páginas de contacto de agencias, las demos de producto SaaS, las consultas jurídicas y médicas, y cualquier servicio facturado por hora. Evítala en las páginas de e-commerce transaccionales donde un calendario genera fricción, y descártala si tus datos de disponibilidad son dinámicos: este componente acepta un array timeSlots estático y no se conecta a un backend por defecto. Tendrás que conectar tú mismo el botón de confirmación a una API.

Usado por

  • Calendly, El producto de referencia para la programación de citas integrada: cuadrícula mensual, selección de día, lista de franjas horarias y paso de confirmación en un único panel.
  • Cal.com, Plataforma de reservas open-source cuyo widget embebible usa la misma maquetación de calendario/franjas en dos columnas, totalmente personalizable mediante variables CSS.
  • Stripe, La programación de demos para llamadas comerciales en stripe.com usa un selector de fecha y hora mínimo antes de redirigir a un enlace de videollamada.
  • Notion, El editor de propiedad de fecha nativo de Notion renderiza una cuadrícula mensual con botones de día y entrada de hora que sigue el mismo modelo de interacción: elegir el día y luego la hora.

FAQ

¿Este componente se conecta a Google Calendar o Calendly?

No. Es un componente puramente UI. La prop timeSlots acepta un array de strings estático y el botón de confirmación no dispara ninguna petición por defecto. Conecta un handler onClick a tu propia API o a un endpoint REST de Calendly/Cal.com para ponerlo en funcionamiento.

¿Cómo deshabilito días concretos además de los fines de semana?

Pasa una prop disabledDates como array de objetos Date o de strings ISO, y luego amplía la comprobación disabled en el botón de día: `disabled={weekend || isDisabled(day)}`. El estilo opacity-30 ya se aplica sobre disabled, así que no hace falta CSS adicional.

¿Por qué cambiar de mes reinicia la franja seleccionada?

Los handlers prevMonth y nextMonth llaman explícitamente a setSelectedDay(null) y setSelectedSlot(null). El 15 de marzo y el 15 de abril son citas distintas, así que reiniciar al navegar evita errores silenciosos en los que el usuario acaba en una pantalla de confirmación del mes equivocado.

¿Puedo usarlo en móvil?

La maquetación pasa a una sola columna por debajo del breakpoint lg, así que el calendario y las franjas aparecen uno encima del otro. Los eventos táctiles funcionan bien porque la interacción usa handlers de click. Los botones de día usan aspect-square para zonas de tap cómodas en táctil. Prueba en pantallas pequeñas para asegurarte de que tu array timeSlots no desborda la cuadrícula de 2 columnas.

"use client";

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

interface ContactCalendarEmbedProps {
  title?: string;
  subtitle?: string;
  description?: string;
  timeSlots?: string[];
  daysOfWeek?: string[];
  confirmLabel?: string;
  duration?: string;
}

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

function getDaysInMonth(year: number, month: number) {
  return new Date(year, month + 1, 0).getDate();
}

Code complet réservé à Pro

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

Passer en Pro, 9,99€/mois

Reseñas

Calendario de reservas React con franjas horarias, código +