Escapadas de otoño
Elige tu próximo viaje
Toca una carta para abrirla
Modal desde una tarjeta
Un abanico de cartas de viaje: la que tocas crece hasta ser un modal y vuelve a su sitio al cerrarla.
Ajustes
Código
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: "Noruega",
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: "Pueblos de pescadores bajo montañas que caen al mar. En invierno el cielo se enciende de verde casi cada noche.",
},
{
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: "El desierto más seco del planeta y uno de los mejores cielos del mundo. Lagunas rosas y géiseres al amanecer.",
},
{
id: "kioto",
title: "Kioto",
place: "Japón",
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: "Más de mil templos, jardines de musgo y callejones de madera que al atardecer huelen a té tostado.",
},
];
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]">Escapadas de otoño</p>
<h2 className="mt-2 font-serif text-[28px] leading-none tracking-[-0.01em]">Elige tu próximo viaje</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={"Ver " + 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]">Toca una carta para abrirla</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="Cerrar"
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"
>
Reservar <ArrowUpRight className="h-4 w-4" strokeWidth={1.5} />
</button>
</div>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}
Prompt para IA
Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.
Más en Overlays
Dónde usarlo
Este modal en React encaja donde una tarjeta es solo el adelanto de algo más grande: los viajes de una agencia, los platos de la carta de un restaurante o los proyectos de un portfolio. Como la propia tarjeta se convierte en la ventana, nadie pierde de vista de dónde ha salido la información.
En el código copiable el modal va fixed y ocupa toda la ventana; cámbialo a absolute si quieres que se quede dentro de una sección. Los viajes y los degradados son de ejemplo: pon tus fotos y textos cortos, que si no el modal acaba con scroll.