Home Recipes Card to Modal Shared Element

Card to Modal

Expand a compact card into an accessible modal using shared-element motion and FLIP measurements.

Interactive demo

Case study

Aurora Dashboard

Click this card to see a shared-element expansion into a modal.

What it does

This pattern expands a compact card into a larger modal while preserving visual continuity between the trigger and the detail view.

When to use it

  • Portfolio project cards that open into case studies.
  • Product cards that reveal details, specs or purchase options.
  • Image galleries that zoom into a focused view.
  • Dashboard widgets that expand into configuration panels.

When to avoid it

  • Long forms that need a stable layout and scrolling.
  • Destructive or confirmation actions where motion adds friction.
  • Content that must load instantly on slow networks.
  • When the user has requested reduced motion and no instant fallback is provided.

How it works

  1. Measure the trigger — capture the card's bounding client rect before any layout change.
  2. Create the modal shell — insert a fixed-position element directly over the card.
  3. Set initial styles — size and position the shell to match the card exactly.
  4. Calculate the target rect — decide the final modal position and size, usually centered with max dimensions.
  5. Animate — use the FLIP technique or WAAPI to interpolate width, height, x and y.
  6. Reveal content — fade in the modal body once the expansion is complete.
  7. Manage close and focus — trap focus, close on Escape and return focus to the trigger.
  8. Respect reduced motion — skip the interpolation and show the final state immediately.

Source code

HTMLCSSTypeScript
<button type="button" class="card" data-card-trigger aria-haspopup="dialog">
  <div class="card__image" aria-hidden="true"></div>
  <div class="card__body">
    <span class="card__tag">Case study</span>
    <h2 class="card__title">Aurora Dashboard</h2>
    <p class="card__text">Click to expand into a modal.</p>
  </div>
</button>
:root {
  color-scheme: dark;
  --bg: #0a0a0b;
  --surface: #16161a;
  --border: rgba(255, 255, 255, 0.1);
  --text: #f4f4f5;
  --muted: #a1a1aa;
  --accent: #8b5cf6;
}

body {
  margin: 0;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  font-family: system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
}

.card {
  width: 280px;
  text-align: left;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 1rem;
  overflow: hidden;
  cursor: pointer;
}

.card__image {
  height: 140px;
  background: linear-gradient(135deg, #7c3aed, #3b82f6);
}

.card__body {
  padding: 1.25rem;
}

.card__tag {
  font-size: 0.75rem;
  color: var(--accent);
}

.card__title {
  margin: 0.5rem 0 0.25rem;
  font-size: 1.25rem;
}

.card__text {
  margin: 0;
  font-size: 0.875rem;
  color: var(--muted);
}

.card-modal-root {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: grid;
  place-items: center;
}

.card-modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.72);
}

.card-modal-shell {
  position: fixed;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 1rem;
  overflow: hidden;
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
  will-change: left, top, width, height;
}

.card-modal-close {
  position: absolute;
  top: 0.75rem;
  right: 0.75rem;
  width: 2rem;
  height: 2rem;
  border-radius: 9999px;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--text);
  cursor: pointer;
}

.card-modal-content {
  padding: 2rem;
}

@media (prefers-reduced-motion: reduce) {
  .card-modal-shell {
    transition: none !important;
  }
}
function prefersReducedMotion(): boolean {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function openCardModal(trigger: HTMLElement): void {
  const initialRect = trigger.getBoundingClientRect();

  const root = document.createElement("div");
  root.className = "card-modal-root";
  root.setAttribute("role", "dialog");
  root.setAttribute("aria-modal", "true");
  root.setAttribute("aria-labelledby", "modal-title");
  root.innerHTML = `
    <div class="card-modal-backdrop" data-backdrop></div>
    <div class="card-modal-shell" data-shell>
      <button class="card-modal-close" aria-label="Close" type="button">×</button>
      <div class="card-modal-content">
        <h2 id="modal-title" tabindex="-1">Aurora Dashboard</h2>
        <p>Expanded modal content goes here.</p>
      </div>
    </div>
  `;
  document.body.appendChild(root);

  const shell = root.querySelector<HTMLElement>("[data-shell]")!;
  const backdrop = root.querySelector<HTMLElement>("[data-backdrop]")!;

  shell.style.left = `${initialRect.left}px`;
  shell.style.top = `${initialRect.top}px`;
  shell.style.width = `${initialRect.width}px`;
  shell.style.height = `${initialRect.height}px`;

  void shell.offsetWidth;

  const viewportW = window.innerWidth;
  const viewportH = window.innerHeight;
  const finalW = Math.min(640, viewportW - 48);
  const finalH = Math.min(420, viewportH - 48);
  const finalLeft = (viewportW - finalW) / 2;
  const finalTop = (viewportH - finalH) / 2;

  const reduced = prefersReducedMotion();
  const duration = reduced ? 0 : 400;

  const animation = shell.animate(
    [
      { left: `${initialRect.left}px`, top: `${initialRect.top}px`, width: `${initialRect.width}px`, height: `${initialRect.height}px` },
      { left: `${finalLeft}px`, top: `${finalTop}px`, width: `${finalW}px`, height: `${finalH}px` },
    ],
    { duration, easing: "cubic-bezier(0.16, 1, 0.3, 1)", fill: "both" }
  );

  if (!reduced) {
    backdrop.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, fill: "both" });
  } else {
    backdrop.style.opacity = "1";
  }

  animation.onfinish = () => {
    shell.style.left = `${finalLeft}px`;
    shell.style.top = `${finalTop}px`;
    shell.style.width = `${finalW}px`;
    shell.style.height = `${finalH}px`;
    root.querySelector<HTMLElement>("#modal-title")?.focus();
  };

  function close(): void {
    const currentRect = shell.getBoundingClientRect();
    const targetRect = trigger.getBoundingClientRect();
    const closeDuration = reduced ? 0 : 350;

    const closeAnimation = shell.animate(
      [
        { left: `${currentRect.left}px`, top: `${currentRect.top}px`, width: `${currentRect.width}px`, height: `${currentRect.height}px` },
        { left: `${targetRect.left}px`, top: `${targetRect.top}px`, width: `${targetRect.width}px`, height: `${targetRect.height}px` },
      ],
      { duration: closeDuration, easing: "cubic-bezier(0.16, 1, 0.3, 1)", fill: "both" }
    );

    if (!reduced) {
      backdrop.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300, fill: "both" });
    }

    closeAnimation.onfinish = () => {
      root.remove();
      trigger.focus();
    };
  }

  root.querySelector(".card-modal-close")?.addEventListener("click", close);
  backdrop.addEventListener("click", close);
  document.addEventListener("keydown", (event) => {
    if (event.key === "Escape") close();
  }, { once: true });
}

const trigger = document.querySelector<HTMLElement>("[data-card-trigger]");
trigger?.addEventListener("click", () => openCardModal(trigger));

export {};

Implementation recipe

Card to Modal Implementation Recipe

Goal

Build a shared-element transition that expands a card into a centered modal while preserving visual continuity and remaining accessible.

Requirements

  • A trigger card with a known bounding rectangle.
  • A modal shell that starts exactly over the card.
  • Smooth interpolation of position and size.
  • Keyboard support (Enter, Space, Escape, focus management).
  • Reduced-motion fallback.

Files to create

  • index.html
  • styles.css
  • card-to-modal.ts

Step 1: Build the static markup

Create a card element and a separate modal template you can inject dynamically. Keep the modal content simple: a close button, a title and a content area.

Step 2: Add base styles

Style the card and the modal shell. The shell must use position: fixed so it can be placed anywhere on screen without affecting layout. Add a semi-transparent backdrop.

Step 3: Capture the trigger rect

On trigger click, call trigger.getBoundingClientRect(). This gives you the exact pixel position and size to use as the animation start state.

Step 4: Create and position the shell

Inject the modal into document.body. Set the shell's left, top, width and height to match the trigger rect. Force a layout read (offsetWidth) so the browser records the initial state.

Step 5: Calculate the final state

Compute a centered rectangle that fits the viewport with comfortable padding:

const finalW = Math.min(640, window.innerWidth - 48);
const finalH = Math.min(420, window.innerHeight - 48);
const finalLeft = (window.innerWidth - finalW) / 2;
const finalTop = (window.innerHeight - finalH) / 2;

Step 6: Animate with WAAPI

Use element.animate to interpolate the shell geometry. WAAPI keeps the animation off the main thread when possible and gives precise timing.

shell.animate(
  [
    { left: `${initialRect.left}px`, top: `${initialRect.top}px`, width: `${initialRect.width}px`, height: `${initialRect.height}px` },
    { left: `${finalLeft}px`, top: `${finalTop}px`, width: `${finalW}px`, height: `${finalH}px` },
  ],
  { duration: 400, easing: "cubic-bezier(0.16, 1, 0.3, 1)", fill: "both" }
);

Step 7: Fade the backdrop and content

Animate the backdrop opacity from 0 to 1. Fade the modal content slightly after the expansion begins so it does not feel cramped inside the small card.

Step 8: Add accessibility

  • Set role="dialog", aria-modal="true" and aria-labelledby on the modal root.
  • Move focus to the modal title when the animation ends.
  • Close on Escape and on backdrop click.
  • Return focus to the trigger when closing.

Step 9: Add reduced motion

If prefers-reduced-motion is active, set animation durations to 0 and apply the final state immediately.

Step 10: Test manually

  • Open and close with mouse.
  • Open with keyboard (Enter/Space) and close with Escape.
  • Enable reduced motion in your OS and verify the modal appears instantly.
  • Resize the browser and confirm the modal still fits.

Common pitfalls

  • Animating width/height/top/left without a FLIP setup causes layout thrashing.
  • Forgetting to force layout before starting the animation makes the shell jump to the final state.
  • Not returning focus leaves keyboard users stranded.
  • Creating the modal inside the card's layout context breaks the fixed positioning.

Verification checklist

  • Card expands smoothly to the modal position.
  • Backdrop fades in.
  • Escape closes the modal.
  • Focus moves into the modal and returns to the trigger.
  • Reduced motion disables the animation.
  • The effect works at different viewport sizes.
Accessibility notes
  • Move focus to the modal container when it opens.
  • Use role='dialog' and aria-modal='true' on the modal root.
  • Label the modal with aria-labelledby pointing to the title.
  • Close the modal when the user presses Escape.
  • Return focus to the trigger card when the modal closes.
  • Disable pointer events on the rest of the page while the modal is open.
  • Honor prefers-reduced-motion by disabling the expansion animation.
Performance notes
  • Animate only transform and opacity whenever possible.
  • Capture getBoundingClientRect once per transition, not per frame.
  • Use requestAnimationFrame only for the FLIP inversion step if needed.
  • Avoid animating width, height, top and left on the compositor-critical path.
  • Batch DOM reads and writes to prevent layout thrashing.
  • Remove the cloned modal element from the DOM after the close animation.
Browser support & fallbacks
API / Feature Support Fallback strategy
getBoundingClientRect All modern browsers Not required; universally supported.
Web Animations API All modern browsers Use CSS transitions on transform/opacity.
prefers-reduced-motion All modern browsers Instant state change with no animation.