# Expanding card modal

> Tap a card from the fanned deck and it grows into a full modal, then slides back when you close it.

- Category: [Overlays](https://library.aimran.es/components/overlays)
- Built with: React + Motion
- Live editor: https://library.aimran.es/blocks/card-expand-modal
- Library: AIMRAN Library (https://library.aimran.es), free, no sign-up, by Aimran Oukham

## 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.

## Code

### React (Motion)

```tsx
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: "Kyoto",
    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 assistants

Paste this into ChatGPT, Claude, Cursor, v0 or Lovable to adapt the block to your stack (Tailwind, Vue, Svelte…):

````text
I want to add the “Expanding card modal” block (Overlays) from AIMRAN Library to my project.

## What it is
Tap a card from the fanned deck and it grows into a full modal, then slides back when you close it.

## Chosen settings
- Modal radius: 28px (from 12 to 36px)
- Background blur: 8px (from 0 to 16px)

## What I need
1. Add it to my project with the same look and animation as the reference code: same sizes, colours, radii, shadows, timings and easing.
2. It is built with React + TypeScript, with framer-motion for the animation and lucide-react for the icons. If my project uses something else (Vue, Svelte, Astro, plain HTML…), translate it without losing any behaviour. If I use Tailwind, turn the styles into classes; if not, keep the CSS in its own file.
3. Expose the settings above as props (or CSS variables) with those values as defaults.
4. Make it work well on phones: nothing that depends only on hover, touch targets of at least 44 px and no overflow on 320 px screens.
5. Make it look right in light and dark mode.
6. Keep it accessible: semantic HTML, roles and aria-* where needed, visible focus and keyboard support.
7. Respect prefers-reduced-motion: with reduced motion, no big animations but the same end result.

## Reference code
```tsx
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: "Kyoto",
    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>
  );
}
```

Give me the complete final code, ready to paste, and tell me in one line where to put it in my project.
````
