Card That Morphs into a Detail Dialog

Three listing cards for a cabin rental site. Clicking one does not pop a separate modal over the page: the card itself grows into the dialog, with its picture, title and subtitle travelling to their new places while the description and buttons fade in underneath. Closing reverses the move, so the visitor never loses track of which card they opened. On phones the cards become compact rows and the dialog fills the width with its own scroll.

Code

// Adapted from Motion Primitives (https://motion-primitives.com/docs/morphing-dialog)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";

const stays = [
  {
    id: "larch",
    name: "Larch Hollow",
    place: "Glen Affric, sleeps 2",
    price: "$184",
    blurb: "A one-room cabin above the river with a wood stove, a cedar tub on the deck and no neighbours for three kilometres.",
    perks: ["Wood-fired tub", "Breakfast hamper", "Dogs welcome"],
    scene: { sky: ["#fecdd3", "#fde68a"], sun: "#fff7ed", far: "#9f1239", near: "#4c0519" },
  },
  {
    id: "fern",
    name: "Fern Lookout",
    place: "Kielder Forest, sleeps 4",
    price: "$212",
    blurb: "Two bedrooms on stilts in the canopy. Wake up level with the treetops, then walk ten minutes to the lake for a swim.",
    perks: ["Lake access", "Fire pit and logs", "Star-gazing deck"],
    scene: { sky: ["#bbf7d0", "#fef9c3"], sun: "#fefce8", far: "#15803d", near: "#052e16" },
  },
  {
    id: "night",
    name: "North Shore Hut",
    place: "Isle of Harris, sleeps 2",
    price: "$156",
    blurb: "A stone bothy rebuilt with a glass gable facing the Atlantic. On clear winter nights the aurora shows from the bed.",
    perks: ["Sea view bed", "Underfloor heating", "Kayaks included"],
    scene: { sky: ["#1e1b4b", "#0e7490"], sun: "#e0f2fe", far: "#164e63", near: "#020617" },
  },
];
type Stay = (typeof stays)[number];

const SPRING = { type: "spring" as const, bounce: 0.12, duration: 0.55 };

function Scene({ stay }: { stay: Stay }) {
  const { sky, sun, far, near } = stay.scene;
  return (
    <svg viewBox="0 0 400 260" preserveAspectRatio="xMidYMid slice" aria-hidden="true" className="h-full w-full">
      <defs>
        <linearGradient id={`morph-dialog-sky-${stay.id}`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor={sky[0]} />
          <stop offset="1" stopColor={sky[1]} />
        </linearGradient>
      </defs>
      <rect width="400" height="260" fill={`url(#morph-dialog-sky-${stay.id})`} />
      <circle cx="292" cy="84" r="30" fill={sun} />
      <circle cx="292" cy="84" r="52" fill={sun} opacity="0.25" />
      <path d="M0 176 70 118l58 46 76-88 84 92 112-66v158H0Z" fill={far} opacity="0.85" />
      <path d="M0 214c60-30 122-30 192 0s148 20 208-10v56H0Z" fill={near} />
      <path d="m150 204 32-28 32 28v30h-64Z" fill={near} stroke={sun} strokeWidth="2" />
      <rect x="175" y="212" width="14" height="22" fill={sun} />
      <path d="m60 232 14-40 14 40Zm34 0 10-28 10 28Zm190 0 14-44 14 44Zm36 0 10-30 10 30Z" fill={near} />
    </svg>
  );
}

export default function CardThatMorphsIntoADetailDialog() {
  const reduceMotion = useReducedMotion();
  const [openId, setOpenId] = useState<string | null>(null);
  const [reserved, setReserved] = useState<string[]>([]);
  const touched = useRef(false);
  const byUser = useRef(false);
  const closeRef = useRef<HTMLButtonElement>(null);
  const dialogRef = useRef<HTMLDivElement>(null);
  const triggerRefs = useRef<Record<string, HTMLButtonElement | null>>({});
  const lastOpen = useRef<string | null>(null);
  const open = stays.find((stay) => stay.id === openId) ?? null;

  // Demo: open the middle card shortly after load so the morph is visible without a click.
  useEffect(() => {
    const timer = setTimeout(() => {
      if (!touched.current) setOpenId("fern");
    }, reduceMotion ? 0 : 750);
    return () => clearTimeout(timer);
  }, [reduceMotion]);

  // Keyboard: Escape closes, Tab stays inside the dialog. Focus moves in and back out for real users only.
  useEffect(() => {
    if (!openId) {
      if (byUser.current && lastOpen.current) triggerRefs.current[lastOpen.current]?.focus();
      return;
    }
    lastOpen.current = openId;
    if (byUser.current) closeRef.current?.focus();
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") setOpenId(null);
      if (event.key !== "Tab" || !dialogRef.current) return;
      const items = dialogRef.current.querySelectorAll<HTMLElement>("button, a[href]");
      if (!items.length) return;
      const first = items[0];
      const last = items[items.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [openId]);

  const show = (id: string | null) => {
    touched.current = true;
    byUser.current = true;
    setOpenId(id);
  };

  return (
    <MotionConfig transition={SPRING} reducedMotion="user">
      <section className="relative min-h-[660px] overflow-hidden bg-stone-100 px-5 py-12 text-stone-900 sm:px-8 sm:py-16">
        <div className="mx-auto max-w-5xl">
          <p className="text-xs font-semibold uppercase tracking-widest text-emerald-800">Slow Stays, winter collection</p>
          <div className="mt-3 flex flex-wrap items-end justify-between gap-4">
            <h2 className="max-w-xl text-3xl font-bold tracking-tight sm:text-5xl">Three cabins worth the long drive.</h2>
            <p className="max-w-xs text-sm leading-6 text-stone-600">Pick a card. It grows into the full listing and folds back into place when you close it.</p>
          </div>

          <ul className="mt-9 grid gap-4 sm:grid-cols-3 sm:gap-5">
            {stays.map((stay) => (
              <li key={stay.id}>
                <motion.button
                  type="button"
                  layoutId={`morph-dialog-card-${stay.id}`}
                  ref={(node: HTMLButtonElement | null) => {
                    triggerRefs.current[stay.id] = node;
                  }}
                  onClick={() => show(stay.id)}
                  aria-haspopup="dialog"
                  aria-expanded={openId === stay.id}
                  style={{ borderRadius: 24 }}
                  className="group flex w-full items-stretch overflow-hidden bg-white text-left shadow-sm ring-1 ring-stone-900/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 sm:flex-col"
                >
                  <motion.div layoutId={`morph-dialog-art-${stay.id}`} className="h-24 w-28 shrink-0 overflow-hidden sm:h-44 sm:w-full">
                    <Scene stay={stay} />
                  </motion.div>
                  <div className="flex flex-1 items-center justify-between gap-3 p-4 sm:items-end sm:p-5">
                    <div>
                      <motion.h3 layoutId={`morph-dialog-title-${stay.id}`} className="text-base font-semibold sm:text-lg">
                        {stay.name}
                      </motion.h3>
                      <motion.p layoutId={`morph-dialog-place-${stay.id}`} className="mt-0.5 text-sm text-stone-600">
                        {stay.place}
                      </motion.p>
                    </div>
                    <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-stone-300 text-stone-700 transition-colors group-hover:bg-stone-900 group-hover:text-white">
                      <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
                        <path d="M10 4.5v11M4.5 10h11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
                      </svg>
                    </span>
                  </div>
                </motion.button>
              </li>
            ))}
          </ul>
        </div>

        {/* The dialog lives inside the section so it works in a preview box. Use `fixed` to cover a whole page. */}
        <AnimatePresence>
          {open && (
            <div className="absolute inset-0 z-20 flex items-center justify-center p-4 sm:p-8">
              <motion.div
                aria-hidden="true"
                className="absolute inset-0 bg-stone-950/55 backdrop-blur-sm"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                onClick={() => show(null)}
              />
              <motion.div
                ref={dialogRef}
                role="dialog"
                aria-modal="true"
                aria-labelledby="morph-dialog-heading"
                layoutId={`morph-dialog-card-${open.id}`}
                style={{ borderRadius: 28 }}
                className="relative flex max-h-full w-full max-w-lg flex-col overflow-hidden bg-white shadow-2xl"
              >
                <motion.div layoutId={`morph-dialog-art-${open.id}`} className="h-44 w-full shrink-0 overflow-hidden sm:h-60">
                  <Scene stay={open} />
                </motion.div>
                <div className="overflow-y-auto p-6 sm:p-7">
                  <div className="flex items-start justify-between gap-4">
                    <div>
                      <motion.h3 id="morph-dialog-heading" layoutId={`morph-dialog-title-${open.id}`} className="text-2xl font-bold tracking-tight">
                        {open.name}
                      </motion.h3>
                      <motion.p layoutId={`morph-dialog-place-${open.id}`} className="mt-1 text-sm text-stone-600">
                        {open.place}
                      </motion.p>
                    </div>
                    <p className="shrink-0 text-right text-sm text-stone-600">
                      <span className="block text-2xl font-bold text-stone-900">{open.price}</span>a night
                    </p>
                  </div>
                  <motion.div
                    initial={{ opacity: 0, y: 14 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, transition: { duration: 0.1 } }}
                    transition={{ ...SPRING, delay: 0.14 }}
                  >
                    <p className="mt-4 text-sm leading-6 text-stone-700">{open.blurb}</p>
                    <ul className="mt-4 flex flex-wrap gap-2">
                      {open.perks.map((perk) => (
                        <li key={perk} className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-900">
                          {perk}
                        </li>
                      ))}
                    </ul>
                    <div className="mt-6 flex gap-3">
                      <button
                        type="button"
                        aria-pressed={reserved.includes(open.id)}
                        onClick={() => setReserved((list) => (list.includes(open.id) ? list.filter((id) => id !== open.id) : [...list, open.id]))}
                        className="inline-flex h-11 flex-1 items-center justify-center rounded-full bg-emerald-800 px-5 text-sm font-semibold text-white hover:bg-emerald-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 focus-visible:ring-offset-2"
                      >
                        {reserved.includes(open.id) ? "Dates held for 24 hours" : "Hold these dates"}
                      </button>
                      <button
                        type="button"
                        onClick={() => show(null)}
                        className="inline-flex h-11 items-center justify-center rounded-full border border-stone-300 px-5 text-sm font-semibold text-stone-800 hover:bg-stone-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700"
                      >
                        Back
                      </button>
                    </div>
                  </motion.div>
                </div>
                <motion.button
                  ref={closeRef}
                  type="button"
                  aria-label="Close listing"
                  onClick={() => show(null)}
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  className="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-stone-900 shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700"
                >
                  <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
                    <path d="m5 5 10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
                  </svg>
                </motion.button>
              </motion.div>
            </div>
          )}
        </AnimatePresence>
      </section>
    </MotionConfig>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/card-that-morphs-into-a-detail-dialog.json

When to use it

How to customize it

Accessibility

Questions

How does a card morph into a modal?

The card and the dialog share the same `layoutId`. When the dialog mounts, framer-motion measures both boxes and animates from the card's position and size to the dialog's. Giving the picture and title their own `layoutId` keeps them from stretching during the move.

Why do the corners stay round while it grows?

The radius is set through the `style` prop (`borderRadius: 24` and `28`), which lets framer-motion correct it during the scale. A radius set only by a class would look squashed mid-animation.

Why is the dialog inside the section and not in a portal?

So it can be shown inside a preview frame. In a real page, switch the overlay to `fixed inset-0`. If a parent has a transform or `overflow: hidden`, render the overlay in a portal at the end of `body`.

Can several cards use this at once?

Yes. Every `layoutId` includes the item's `id`, so each card morphs into its own dialog. Keep the ids unique across the page.

Does the text blur or stretch while it animates?

The title and subtitle are separate layout elements, so they move and resize on their own. Text that only exists in the dialog fades in after a short delay instead of scaling.

Adapted from Morphing Dialog in Motion Primitives, used under its open-source licence. Licences and credits

Related: Delete Confirmation Dialog with Summary, Flip Cards with Details on the Back, 3D Tilt Card with Parallax Layers and Glare.

More modals and dialogs or all free UI components. Start for free to have a full site built around it.

More in Modals

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap