Card to Modal
Expand a compact card into an accessible modal using shared-element motion and FLIP measurements.
Interactive demo
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
- Measure the trigger — capture the card's bounding client rect before any layout change.
- Create the modal shell — insert a fixed-position element directly over the card.
- Set initial styles — size and position the shell to match the card exactly.
- Calculate the target rect — decide the final modal position and size, usually centered with max dimensions.
- Animate — use the FLIP technique or WAAPI to interpolate width, height, x and y.
- Reveal content — fade in the modal body once the expansion is complete.
- Manage close and focus — trap focus, close on Escape and return focus to the trigger.
- Respect reduced motion — skip the interpolation and show the final state immediately.
Source code
<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.htmlstyles.csscard-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"andaria-labelledbyon the modal root. - Move focus to the modal title when the animation ends.
- Close on
Escapeand 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/leftwithout 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.
- 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.
- 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.
| 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. |