Escapadas de otoño
Elige tu próximo viaje
Toca una carta para abrirla
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
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.