webdevpuneet.com

Flip Card to Modal: GSAP Flip Shared-Element Animation

Flip Card to Modal · Modals · Plain HTML, CSS & JS · Live preview

CategoryModals

Copy & paste

Source Code

<div class="fcm-wrap">
  <header class="fcm-head">
    <span class="fcm-eyebrow">New arrivals</span>
    <h2>Pick your gear</h2>
    <p class="fcm-hint">Click a card: it grows into its own modal.</p>
  </header>

  <div class="fcm-grid">
    <div class="fcm-slot">
      <article class="fcm-card" style="--cc:#6366f1" role="button" tabindex="0" aria-expanded="false" data-label="Studio Pods, $249. Open details">
        <span class="fcm-badge">Best seller</span>
        <span class="fcm-emoji" aria-hidden="true">🎧</span>
        <h3 id="fcm-t1">Studio Pods</h3>
        <p class="fcm-tag">Noise-cancelling earbuds</p>
        <span class="fcm-price-tag">$249</span>
        <div class="fcm-more">
          <p>Adaptive ANC, 40-hour battery, and a case that doubles as a Bluetooth transmitter for flights.</p>
          <ul class="fcm-specs"><li>Adaptive ANC</li><li>40 h battery</li><li>USB-C</li></ul>
          <div class="fcm-actions"><b class="fcm-price">$249</b><button class="fcm-buy" type="button">Add to cart</button></div>
        </div>
        <button class="fcm-close" type="button" aria-label="Close details">&times;</button>
      </article>
    </div>

    <div class="fcm-slot">
      <article class="fcm-card" style="--cc:#0ea5e9" role="button" tabindex="0" aria-expanded="false" data-label="Pulse Watch, $329. Open details">
        <span class="fcm-badge">New</span>
        <span class="fcm-emoji" aria-hidden="true">⌚</span>
        <h3 id="fcm-t2">Pulse Watch</h3>
        <p class="fcm-tag">GPS training watch</p>
        <span class="fcm-price-tag">$329</span>
        <div class="fcm-more">
          <p>Dual-band GPS, 10-day battery, and recovery scores that actually change how you train.</p>
          <ul class="fcm-specs"><li>Dual-band GPS</li><li>10-day battery</li><li>5 ATM</li></ul>
          <div class="fcm-actions"><b class="fcm-price">$329</b><button class="fcm-buy" type="button">Add to cart</button></div>
        </div>
        <button class="fcm-close" type="button" aria-label="Close details">&times;</button>
      </article>
    </div>

    <div class="fcm-slot">
      <article class="fcm-card" style="--cc:#a855f7" role="button" tabindex="0" aria-expanded="false" data-label="Drift Pad, $89. Open details">
        <span class="fcm-badge">Value</span>
        <span class="fcm-emoji" aria-hidden="true">🎮</span>
        <h3 id="fcm-t3">Drift Pad</h3>
        <p class="fcm-tag">Wireless game controller</p>
        <span class="fcm-price-tag">$89</span>
        <div class="fcm-more">
          <p>Hall-effect sticks that never drift, 1000Hz polling, and remappable paddles.</p>
          <ul class="fcm-specs"><li>Hall-effect sticks</li><li>1000Hz polling</li><li>Back paddles</li></ul>
          <div class="fcm-actions"><b class="fcm-price">$89</b><button class="fcm-buy" type="button">Add to cart</button></div>
        </div>
        <button class="fcm-close" type="button" aria-label="Close details">&times;</button>
      </article>
    </div>
  </div>

  <div class="fcm-backdrop" id="fcmBackdrop"></div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

True shared element: one DOM node is both the grid card and the modal, with no clones to keep in sync
The modal layout is a single .is-open class; no pixel coordinates are computed in JavaScript
A slot wrapper holds each card's place in the grid, so nothing shifts or reflows when a card opens
GSAP Flip animates position, size and border radius between the two layouts
Fade-through choreography: content fades out, the empty container flips, new content fades up in a stagger, so text never jumps mid-flight
Card becomes role="dialog" with aria-modal, inert siblings, a focus trap and focus return
Keyboard support: Enter or Space opens a card, Escape closes it, Tab stays inside the dialog
Respects prefers-reduced-motion by skipping the flip and the entrance animation
A temporary .is-flying class keeps the card above the backdrop in both directions
Mid-flight guard ignores extra clicks while a flip is running

About this UI Snippet

Flip Card to Modal: A Shared-Element Transition Built on One DOM Node

Screenshot of the Flip Card to Modal snippet rendered live

The card-to-modal morph is the shared-element transition that app stores and galleries made familiar: tap a card and it *becomes* the detail view, growing from its place in the grid to the centre of the screen with no break in continuity. Most implementations fake it with a cloned overlay. This snippet does it for real: the same DOM node is both the card and the modal, and GSAP's Flip plugin animates between two CSS layouts. It also handles the parts that tutorials skip: the grid does not jump, keyboard and screen-reader users can use it, and it respects reduced-motion settings.

One element, two layouts, no coordinates in JavaScript

The modal state is just a class. .is-open switches the card to position: fixed, centres it with inset: 0 and margin: auto (no transform, so Flip has a plain target), widens it and reveals the hidden detail block. The animation is the standard FLIP sequence (First, Last, Invert, Play): Flip.getState(card) records where the card is before the class flips, the class flips, and Flip.from(state) animates from the recorded position and size to wherever CSS just put the card. JavaScript never computes a position, so redesigning the modal or the grid means editing only CSS.

A slot keeps the grid steady

A fixed-position card leaves the document flow, which would make the other cards jump into its place the moment it opens. To avoid that, every card lives inside a .fcm-slot wrapper that owns the grid cell and its size (aspect-ratio: 3/4), while the card inside is absolutely positioned to fill it. When the card flies out to the centre, the slot stays exactly where it was, so the grid does not reflow, and the card has a clear place to fly back to. It is also why the Flip call does not need absolute: true: nothing in the grid reflows, so the card can simply animate with transforms.

Why the real node beats a clone

A clone-based modal has to copy the content, mirror any state between the two copies and hide the original, and those three jobs are where the bugs live. With a single node, event listeners, form values and media playback carry straight through the transition. The only extra work is the slot placeholder described above.

Layering during the flight

The backdrop sits at z-index: 10 and the open card at 20, but the card needs to stay above the backdrop on the way back as well, when the .is-open class has already been removed. A temporary .is-flying class (z-index 30) is added before every flip and removed in onComplete, so the card never dips behind the dimmed page in either direction. The props: 'borderRadius' option shows the pattern for style changes: any property the class swap changes can be recorded and tweened instead of snapping.

Fade-through keeps the motion smooth

Because one node plays both roles, its text changes size the instant the class flips (the title goes from 15px to 26px, the emoji from 40px to 66px). If that happened while the container was still small, the content would visibly jump and re-wrap. So the content is choreographed in three steps: the current content fades out in 0.14 seconds, the empty container flips to its new layout, and the new content fades up in a staggered tween that starts about 0.3 seconds into the morph, once the container has mostly landed. Splitting the container's motion from the content's change is what makes shared-element interfaces feel polished (it is the "fade through" pattern from Material Design's container transform).

Accessible by default

While closed, each card is a role="button" with a label and a tab stop, and Enter or Space opens it. While open, the same element becomes a role="dialog" with aria-modal="true" and aria-labelledby pointing at its heading. The other cards are made inert, so neither Tab nor a screen reader can reach them, and Tab is trapped between the close button and the buy button. Focus moves to the close button when the dialog opens and returns to the card when it closes, and Escape, the close button and a click on the backdrop all close it. If the visitor prefers reduced motion, the flip and the content entrance are skipped.

Interaction guards

An animating flag ignores clicks while a flip is in flight, because capturing a moving element as the "first" state is the classic way to strand cards between layouts. Clicks on the open card are ignored so text can be selected, and clicks inside it, such as the buy button, never reopen or close it.

Customising it

Swap the emoji for a product image that scales with the card (give the image object-fit: cover so its crop transitions during the morph), replace the demo copy with real content, or flip the card back to a different grid position after a sort. Related patterns: reordering in flip grid shuffle, the CSS-only expandable card, a classic modal, and a product quick view.

Step by step

How to Use

  1. 1
    Add GSAP and the Flip pluginLoad gsap.min.js and Flip.min.js from the CDN panel (or install the gsap package), then call gsap.registerPlugin(Flip).
  2. 2
    Paste the HTML, CSS and JSEach card sits in a .fcm-slot inside the grid. Add as many slot and card pairs as you need.
  3. 3
    Click or press Enter on a cardIt flies from its grid slot to the centre of the screen and becomes a modal dialog, with the rest of the page dimmed.
  4. 4
    Watch the details arriveThe old content fades out, the empty card flies to the centre, and the description, spec chips and buy row fade up one after another once it has landed.
  5. 5
    Close it three waysClick the close button, click the backdrop or press Escape, and the card flies back into its slot.
  6. 6
    Restyle either stateBoth layouts are plain CSS (the card and the .is-open rule), and Flip adapts to whatever you change.

Real-world uses

Common Use Cases

Product quick views
Expand catalogue cards into detail sheets as the same DOM node, with an is-open class swap for the layout and GSAP Flip animating the change. See also the product quick view.
Portfolio case studies
Grow a project thumbnail into its story, with Flip tracking the card so even the border radius tweens through the props option.
App-store style pages
Recreate the iOS card-opens-into-a-page feel with no clones to keep in sync, since listeners and state ride along with the element.
Dashboard drill-downs
Expand a KPI tile into its full chart, for example from a metric card grid, and close it with the backdrop or Escape.
Team bios and sortable grids
Open headshot cards into full profiles like a team card, or combine with flip grid shuffle for a grid that reorders and expands.
Related: Fullscreen Search Overlay
See the Fullscreen Search Overlay for a related modals pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

The modal is just another CSS state: .is-open makes the same element position: fixed, centred and wider. Flip.getState records the card's geometry in the grid before the class changes, and Flip.from animates from that record to wherever CSS has put the card. One element, two layouts, and GSAP animates the difference, so no clone ever exists.

A position: fixed card leaves the normal flow, so without a wrapper the other cards would jump into its grid cell as soon as it opens, and the opening card would have nowhere to fly back to. The slot owns the cell and its aspect ratio while the card inside is absolutely positioned, so the grid stays still while the card is out.

Clones must duplicate content, mirror any state and hide the original, and they still go out of sync if the content updates during the transition. With one node, click handlers, media playback and form values persist through the morph. The only price is the slot placeholder, which is a few lines of CSS.

The card is one node, so its text changes size the moment the class flips (the title jumps from 15px to 26px), and flipping that while the container is small would make it jump and re-wrap. The script fades the content out first, flips the empty container, and fades the new content up about 0.3 seconds into the morph, once the container has mostly landed. Splitting container motion from content change is what makes shared-element UIs feel polished.

While open, the card switches to role="dialog" with aria-modal="true" and aria-labelledby pointing at its heading. The other cards are set inert so they cannot be tabbed to or read, Tab is trapped between the close and buy buttons, focus moves to the close button on open and back to the card on close, and Escape closes it. While closed, each card is a role="button" with a label that opens with Enter or Space.

An animating boolean is set before Flip.from and cleared in its onComplete. Without it, a second click in mid-flight would record a moving element as the starting state and stack a conflicting animation, which is the usual way FLIP interfaces strand elements between layouts. Clicks on the open card are also ignored so visitors can select text.

Yes. The script reads prefers-reduced-motion once. If it is set, the Flip duration is 0, so the card simply appears in its new place, and the staggered content entrance is skipped. The backdrop fade is also switched off in a CSS media query.

Yes. GSAP, including the Flip plugin, is free to use, including in commercial projects, under GSAP's standard licence. Load gsap.min.js and Flip.min.js from a CDN or install the gsap package from npm, then call gsap.registerPlugin(Flip) once.

Drive the is-open class from state, but record the Flip state before the state change: call Flip.getState in the click handler, then call Flip.from in useLayoutEffect (React), nextTick (Vue) or afterNextRender (Angular) once the class has been applied. Register Flip at module scope, keep an animating ref as the guard, and clean up with gsap.context on unmount. Both layouts translate directly to conditional Tailwind classes.

Design your website