Escapadas de otoño
Elige tu próximo viaje
Toca una carta para abrirla
Expanding card modal
Tap a card from the fanned deck and it grows into a full modal, then slides back when you close it.
Settings
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: "Norway",
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: "Fishing villages beneath mountains that plunge into the sea. In winter, the sky glows green almost every night.",
},
{
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: "The driest desert on Earth and some of the clearest skies anywhere. Pink lagoons and geysers at sunrise.",
},
{
id: "kioto",
title: "Kioto",
place: "Japan",
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: "Over a thousand temples, moss gardens, and wooden alleys that smell of roasted tea at dusk.",
},
];
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]">Fall getaways</p>
<h2 className="mt-2 font-serif text-[28px] leading-none tracking-[-0.01em]">Choose your next trip</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={"View " + 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]">Tap a card to open it</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="Close"
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"
>
Book <ArrowUpRight className="h-4 w-4" strokeWidth={1.5} />
</button>
</div>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}
Prompt for AI
Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.
More in Overlays
Where to use it
This expanding card modal fits anywhere a card is only a preview of something bigger: trips on a travel agency site, dishes on a restaurant menu, projects in a portfolio. Because the card itself turns into the dialog, people never lose track of where the details came from. It's built in React with Motion.
In the copied code the modal is fixed to the whole window; switch it to absolute if it should stay inside one section. The trips and gradients are sample content, so drop in your own photos and keep the text short, or the modal starts to scroll.