Escapadas de otoño

Elige tu próximo viaje

Toca una carta para abrirla

React + MotionOverlays

Carte qui devient modale

Un éventail de cartes de voyage : celle que vous touchez s'agrandit en fenêtre modale, puis reprend sa place à la fermeture.

Réglages

28px
8px

Code

import { useEffect, useId, useRef, useState, type RefObject } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowUpRight, X } from "lucide-react";

type Trip = { id: string; title: string; place: string; nights: string; price: string; art: string; text: string };

const TRIPS: Trip[] = [
  {
    id: "lofoten",
    title: "Lofoten",
    place: "Norvège",
    nights: "6 noches",
    price: "1.240 €",
    art: "radial-gradient(90% 60% at 75% 10%, #8af5d0 0%, transparent 55%), radial-gradient(80% 70% at 10% 40%, #1d7a8c 0%, transparent 65%), linear-gradient(175deg, #06142e 0%, #0d3355 55%, #0f4c4a 100%)",
    text: "Des villages de pêcheurs au pied de montagnes qui plongent dans la mer. L’hiver, le ciel s’illumine de vert presque chaque nuit.",
  },
  {
    id: "atacama",
    title: "Atacama",
    place: "Chile",
    nights: "8 noches",
    price: "1.580 €",
    art: "radial-gradient(70% 55% at 70% 20%, #ffe0a3 0%, transparent 60%), radial-gradient(110% 80% at 0% 100%, #8f1d3a 0%, transparent 62%), linear-gradient(165deg, #f5a524 0%, #e0533d 52%, #6b1d2a 100%)",
    text: "Le désert le plus aride de la planète et l’un des plus beaux ciels du monde. Lagunes roses et geysers au lever du soleil.",
  },
  {
    id: "kioto",
    title: "Kioto",
    place: "Japon",
    nights: "7 noches",
    price: "1.390 €",
    art: "radial-gradient(70% 55% at 30% 15%, #ffe8f0 0%, transparent 60%), radial-gradient(100% 80% at 100% 100%, #5b2bb5 0%, transparent 62%), linear-gradient(165deg, #f7a8c8 0%, #d9467e 55%, #3d1a6e 100%)",
    text: "Plus de mille temples, des jardins de mousse et des ruelles en bois qui sentent le thé torréfié au coucher du soleil.",
  },
];

const FAN = [
  { rotate: -6, y: 12 },
  { rotate: 0, y: 0 },
  { rotate: 6, y: 12 },
];

const GRAIN = "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\")";

const morph = { type: "spring" as const, stiffness: 320, damping: 32, mass: 0.9 };

const FOCUSABLE =
  'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

// Modal accesible: enfoca el primer control (o el marcado con data-autofocus), atrapa Tab, cierra con Esc y devuelve el foco
function useDialog(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    if (!open) return;
    const previous = document.activeElement as HTMLElement | null;
    const node = ref.current;
    const first =
      node?.querySelector<HTMLElement>("[data-autofocus]") ?? node?.querySelector<HTMLElement>(FOCUSABLE);
    first?.focus({ preventScroll: true });
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close.current();
        return;
      }
      if (e.key !== "Tab" || !node) return;
      const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
      if (!items.length) return;
      const firstItem = items[0];
      const lastItem = items[items.length - 1];
      if (!firstItem || !lastItem) return;
      if (!node.contains(document.activeElement)) {
        e.preventDefault();
        firstItem.focus();
      } else if (e.shiftKey && document.activeElement === firstItem) {
        e.preventDefault();
        lastItem.focus();
      } else if (!e.shiftKey && document.activeElement === lastItem) {
        e.preventDefault();
        firstItem.focus();
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      previous?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

interface CardExpandModalProps {
  radius?: number;
  blur?: number;
}

export default function CardExpandModal({ radius = 28, blur = 8 }: CardExpandModalProps) {
  const [openId, setOpenId] = useState<string | null>(null);
  const panel = useRef<HTMLDivElement>(null);
  const titleId = useId();
  const reduce = useReducedMotion();
  const trip = TRIPS.find((t) => t.id === openId);
  const transition = reduce ? { duration: 0 } : morph;
  useDialog(panel, !!trip, () => setOpenId(null));

  return (
    <section className="relative flex min-h-[520px] w-full flex-col items-center justify-center gap-8 overflow-hidden bg-[#f6f4f0] px-5 text-[#1d1d1f] dark:bg-[#0c0b0a] dark:text-[#f5f5f7]">
      <header className="text-center">
        <p className="text-[11px] font-medium uppercase tracking-[0.24em] text-[#a39a8c]">Escapades d’automne</p>
        <h2 className="mt-2 font-serif text-[28px] leading-none tracking-[-0.01em]">Choisissez votre prochain voyage</h2>
      </header>
      <div className="flex w-full max-w-[440px] items-start justify-center pb-4">
        {TRIPS.map((t, i) => {
          const fan = FAN[i] ?? { rotate: 0, y: 0 };
          return (
            <motion.button
              key={t.id}
              type="button"
              layoutId={"trip-" + t.id}
              transition={transition}
              onClick={() => setOpenId(t.id)}
              aria-haspopup="dialog"
              aria-label={"Voir " + t.title + ", " + t.place}
              initial={false}
              animate={{ rotate: fan.rotate, y: fan.y }}
              whileHover={{ y: fan.y - 12, rotate: fan.rotate / 2 }}
              whileTap={{ scale: 0.97 }}
              className="relative -mx-0.5 aspect-[3/4] w-[34%] max-w-[148px] shrink-0 overflow-hidden text-left text-white outline-none shadow-[0_1px_2px_rgba(0,0,0,0.12),0_12px_28px_-8px_rgba(0,0,0,0.35),0_30px_60px_-24px_rgba(0,0,0,0.35)] focus-visible:ring-2 focus-visible:ring-[#1d1d1f] focus-visible:ring-offset-2 focus-visible:ring-offset-[#f6f4f0] dark:focus-visible:ring-white dark:focus-visible:ring-offset-[#0c0b0a]"
              style={{ borderRadius: 22, background: t.art, zIndex: i === 1 ? 2 : 1 }}
            >
              <span aria-hidden className="pointer-events-none absolute inset-0 opacity-[0.22] mix-blend-overlay" style={{ backgroundImage: GRAIN }} />
              <span className="absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/15" />
              <span className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/45 to-transparent" />
              <span className="absolute inset-x-3 bottom-3 block">
                <span className="block text-[9px] font-medium uppercase tracking-[0.2em] text-white/75">{t.place}</span>
                <span className="mt-0.5 block font-serif text-[19px] leading-none">{t.title}</span>
              </span>
            </motion.button>
          );
        })}
      </div>
      <p className="text-[12px] text-[#a39a8c]">Touchez une carte pour l’ouvrir</p>

      <AnimatePresence>
        {trip && (
          // fixed: el modal cubre toda la ventana (usa absolute si lo quieres dentro de un contenedor relative)
          <motion.div key="modal" className="fixed inset-0 z-50 grid place-items-center p-5">
            <motion.div
              aria-hidden
              className="absolute inset-0 bg-[#14110d]/30 dark:bg-black/60"
              style={{ backdropFilter: "blur(" + blur + "px)" }}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              onClick={() => setOpenId(null)}
            />
            <motion.div
              ref={panel}
              role="dialog"
              aria-modal="true"
              aria-labelledby={titleId}
              layoutId={"trip-" + trip.id}
              transition={transition}
              className="relative flex max-h-full w-full max-w-[380px] flex-col overflow-hidden bg-white text-[#1d1d1f] shadow-[0_10px_30px_rgba(0,0,0,0.15),0_50px_100px_-20px_rgba(0,0,0,0.45)] dark:bg-[#171615] dark:text-[#f5f5f7]"
              style={{ borderRadius: radius }}
            >
              <div className="relative h-36 shrink-0 text-white sm:h-44" style={{ background: trip.art }}>
                <span aria-hidden className="pointer-events-none absolute inset-0 opacity-[0.22] mix-blend-overlay" style={{ backgroundImage: GRAIN }} />
                <span className="absolute inset-x-0 bottom-0 h-2/3 bg-gradient-to-t from-black/45 to-transparent" />
                <div className="absolute inset-x-6 bottom-5">
                  <p className="text-[10px] font-medium uppercase tracking-[0.22em] text-white/75">{trip.place}</p>
                  <h3 id={titleId} className="mt-1 font-serif text-[34px] leading-none">
                    {trip.title}
                  </h3>
                </div>
                <button
                  type="button"
                  aria-label="Fermer"
                  onClick={() => setOpenId(null)}
                  className="absolute right-3 top-3 grid h-9 w-9 place-items-center rounded-full bg-white/15 text-white ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-white/25"
                >
                  <X className="h-4 w-4" strokeWidth={1.5} />
                </button>
              </div>
              <motion.div
                initial={{ opacity: 0, y: 10 }}
                animate={{ opacity: 1, y: 0, transition: { delay: reduce ? 0 : 0.14 } }}
                exit={{ opacity: 0, transition: { duration: 0.08 } }}
                className="overflow-y-auto px-6 pb-6 pt-5"
              >
                <p className="text-[14px] leading-relaxed text-[#6e6e73] dark:text-[#a1a1a6]">{trip.text}</p>
                <div className="mt-6 flex items-end justify-between gap-3">
                  <div>
                    <p className="text-[12px] text-[#86868b]">{trip.nights} · desde</p>
                    <p className="text-[22px] font-semibold leading-tight tracking-[-0.02em]">{trip.price}</p>
                  </div>
                  <button
                    type="button"
                    className="inline-flex h-11 items-center gap-1.5 rounded-full bg-[#1d1d1f] px-5 text-[14px] font-medium text-white transition hover:bg-black active:scale-[0.97] dark:bg-white dark:text-black dark:hover:bg-white/90"
                  >
                    Réserver <ArrowUpRight className="h-4 w-4" strokeWidth={1.5} />
                  </button>
                </div>
              </motion.div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </section>
  );
}

Prompt pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Overlays

Où l’utiliser

Cette modale en React convient dès qu'une carte n'est qu'un aperçu : les destinations d'une agence de voyage, les plats d'un restaurant ou les projets d'un portfolio. Comme la carte elle-même se transforme en fenêtre, on voit tout de suite d'où viennent les détails, sans perdre le fil.

Dans le code à copier, la modale est en position fixed et couvre toute la fenêtre ; passez-la en absolute si elle doit rester dans une section. Les voyages et les dégradés servent d'exemple : remplacez-les par vos photos et gardez des textes courts.