Escapadas de otoño
Elige tu próximo viaje
Toca una carta para abrirla
Karte wird zum Modal
Ein Fächer aus Reisekarten: Die angetippte Karte wächst zum Modal und rutscht beim Schließen zurück an ihren Platz.
Einstellungen
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: "Norwegen",
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: "Fischerdörfer unter Bergen, die steil ins Meer abfallen. Im Winter leuchtet der Himmel fast jede Nacht grün.",
},
{
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: "Die trockenste Wüste der Erde und einer der klarsten Himmel der Welt. Rosa Lagunen und Geysire im Morgengrauen.",
},
{
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: "Über tausend Tempel, Moosgärten und Holzgassen, die bei Sonnenuntergang nach geröstetem Tee duften.",
},
];
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]">Herbst-Kurztrips</p>
<h2 className="mt-2 font-serif text-[28px] leading-none tracking-[-0.01em]">Wähle deine nächste Reise</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={"Ansehen " + 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]">Tippe auf eine Karte, um sie zu öffnen</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="Schließen"
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"
>
Buchen <ArrowUpRight className="h-4 w-4" strokeWidth={1.5} />
</button>
</div>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}
Prompt für KI
Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.
Mehr aus Overlays
Wofür du ihn nutzen kannst
So ein Modal in React passt überall dort, wo eine Karte nur ein Vorgeschmack ist: Reiseziele auf der Seite eines Reisebüros, Gerichte auf der Speisekarte eines Restaurants oder Projekte in deinem Portfolio. Weil sich die Karte selbst in das Fenster verwandelt, weiß man immer, woher die Details kommen.
Im kopierten Code ist das Modal fixed und deckt das ganze Fenster ab. Soll es in einem Abschnitt bleiben, stell es auf absolute um. Reisen und Farbverläufe sind nur Beispielinhalte, also tausch sie gegen echte Fotos und halte den Text kurz.