Shared-Element Flip Transition

Clicking one of six thumbnails records its position with Flip.getState, reparents it into a full-screen detail layout and morphs it there in 0.9s power3.inOut while the other tiles scale away; Close or Escape morphs it back.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1532 words of exact values — rewritten live as you tune the Playground.

prompt.md · Shared-Element Flip Transition
### EFFECT: Shared-Element Flip Transition
**What it does (visual):** A dark page shows a small serif headline and a 3×2 grid of six 4:3 thumbnails — each a rich gradient (a deep base colour with a bright highlight orb and fine grain) with a serif title and a mono index in its bottom corners. Clicking a thumbnail does not open a new box: the thumbnail's artwork itself grows and travels. Over 0.9s (`power3.inOut`), it slides and scales from its grid slot into the left ~57% of the viewport, its 14px corners squaring off as it goes, until it fills the left column of a full-screen detail layout edge to edge. While it travels, the five other thumbnails fade to 0 and shrink to 0.92× (0.5s, slightly staggered), the page background fades in behind to hide the grid, and in the right column a mono category line in the accent colour (`#d4ff3a`), a large serif title, a paragraph and a small "Press Esc or Close" hint rise 40px into place and fade in, staggered 80ms, starting halfway through the morph. A pill "Close" button sits top-right and receives focus. Pressing Escape or Close reverses it: the text drops away in 0.3s, the artwork shrinks and travels back to its exact grid slot (0.9s, corners rounding back to 14px) while the backdrop fades and the other thumbnails scale back to full; focus returns to the thumbnail that was opened. About 1.2s after the fonts load, the demo opens and closes the third tile once by itself so the morph can be seen immediately.

**Tech stack:**
- GSAP 3.13+ with the Flip plugin (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/Flip.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap"; import { Flip } from "gsap/Flip"; gsap.registerPlugin(Flip);`
- Fonts: variable serif for titles (demo uses Fraunces 300), sans for copy, mono for meta.

**HTML structure:**
```html
<section class="sef">
  <header class="sef__head">
    <p class="sef__eyebrow">Page transition · GSAP Flip</p>
    <h1 class="sef__title">Open a frame, stay in the room.</h1>
  </header>
  <ul class="sef__grid" role="list">
    <li class="sef__cell">
      <button class="sef__tile" type="button" data-title="Tide Tables" data-meta="Photography · 2026" data-copy="Forty mornings on the same estuary…">
        <div class="sef__art" style="--a:#1c3b5a;--b:#0a0a0a;--c:#e0b07a"></div>   <!-- the shared element -->
        <span class="sef__label"><span>Tide Tables</span><span>01</span></span>
      </button>
    </li>
    <!-- five more .sef__cell with their own --a/--c colours -->
  </ul>
  <div class="sef__detail" role="dialog" aria-modal="true" aria-labelledby="sef-detail-title" hidden>
    <div class="sef__detail-bg" aria-hidden="true"></div>   <!-- the fading backdrop, separate from the dialog -->
    <div class="sef__detail-media"></div>          <!-- .sef__art is moved in here -->
    <div class="sef__detail-body">
      <p class="sef__detail-meta sef__detail-rise"></p>
      <h2 class="sef__detail-title sef__detail-rise" id="sef-detail-title"></h2>
      <p class="sef__detail-copy sef__detail-rise"></p>
      <p class="sef__detail-hint sef__detail-rise">Press <kbd>Esc</kbd> or Close to return</p>
    </div>
    <button class="sef__close" type="button">Close</button>
  </div>
</section>
```

**CSS requirements:**
- `.sef__grid`: `display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; max-width: 1100px; margin: 0 auto;`
- `.sef__tile`: `position: relative; display: block; width: 100%; aspect-ratio: 4 / 3; border-radius: 14px; background: var(--surface, #121212);` — the `aspect-ratio` keeps the slot open after the art leaves.
- `.sef__art` (shared element, identical positioning in both layouts): `position: absolute; inset: 0; border-radius: 14px; background: radial-gradient(90% 70% at 70% 25%, var(--c) 0%, transparent 55%), radial-gradient(120% 90% at 20% 90%, var(--a) 0%, transparent 65%), linear-gradient(160deg, color-mix(in srgb, var(--a) 70%, #000) 0%, var(--b) 100%);` + an `::after` grain layer (inline SVG `feTurbulence` data-URI, `opacity: .32`, no blend mode — blending would force a re-raster of the huge layer every frame). **No CSS `transition` on `transform`** — Flip owns the transform; a transition would smooth and lag every frame. `.sef__art.is-flipping { will-change: transform; }` is toggled by JS only while travelling.
- `.sef__detail-media .sef__art { border-radius: 0; }` — this is the value Flip animates *to*.
- `.sef__label`: absolute bottom corners, serif `clamp(1.1rem, 1.8vw, 1.5rem)`, `pointer-events: none`.
- `.sef__detail`: `position: fixed; inset: 0; z-index: 20; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr);` with `[hidden] { display: none; }`. `.sef__detail-bg`: `position: absolute; inset: 0; background: var(--bg, #0a0a0a); opacity: 0;` — the backdrop is a separate element because fading the dialog itself would fade the travelling artwork too. `.sef__detail-media { position: relative; min-height: 0; }`, `.sef__detail-body { position: relative; }`. `.sef__detail-body { display: flex; flex-direction: column; justify-content: center; gap: 1.25rem; padding: 4.5rem; }`.
- `.sef__detail-meta`: mono 11px uppercase, `color: var(--accent, #d4ff3a)`. `.sef__detail-title`: serif 300, `clamp(2.4rem, 5vw, 5rem); letter-spacing: -0.04em;`. `.sef__detail-copy`: `max-width: 40ch; color: var(--muted);`.
- `.sef__close`: `position: absolute; top: 1.25rem; right: 1.25rem;` outlined pill, inverts on hover, `2px solid var(--accent)` focus ring.
- `.sef__cell { will-change: transform, opacity; }` (they scale/fade).

**Animation spec (exact values):**
1. `gsap.registerPlugin(Flip); gsap.ticker.lagSmoothing(0);` (the morph stays on wall time even when the first paint is heavy). Shared constants: `DURATION = 0.9`, `EASE = 'power3.inOut'`, `SCALE = true` (Flip's `scale` option). A `busy` flag and an `open` reference (the tile currently expanded).
2. **Open** `openTile(tile)`:
   - Fill `.sef__detail-title/meta/copy` from `data-title/meta/copy`.
   - `const state = Flip.getState(art, { props: 'borderRadius' })` — recorded **before** anything moves.
   - `detail.hidden = false; media.appendChild(art); art.classList.add('is-flipping');` — the element is now in the detail layout (`inset: 0` of the media column, radius 0) and promoted to its own layer.
   - Timeline (`onComplete`: `art.classList.remove('is-flipping'); busy = false; closeBtn.focus()`):
     - `.add(Flip.from(state, { duration: 0.9, ease: 'power3.inOut', absolute: true, scale: true, props: 'borderRadius' }), 0)` — position, size (as scale when `scale: true`, else real width/height) and border-radius 14px → 0 morph from the recorded grid slot to the detail column.
     - `.to('.sef__detail-bg', { opacity: 1, duration: 0.9, ease: 'power2.inOut' }, 0)` — backdrop fades in over the grid.
     - `.to(tile.querySelector('.sef__label'), { opacity: 0, duration: 0.2 }, 0)`.
     - `.to(otherCells, { opacity: 0, scale: 0.92, duration: 0.5, ease: 'power2.out', stagger: 0.03 }, 0)` — never touch the clicked tile's own cell (an opacity/transform there would create a stacking context).
     - `.fromTo('.sef__detail-rise', { y: 40, opacity: 0 }, { y: 0, opacity: 1, duration: 0.8, ease: 'power3.out', stagger: 0.08 }, 0.9 * 0.5)`.
3. **Close** `closeDetail()`:
   - `const state = Flip.getState(art, { props: 'borderRadius' })`; `tile.prepend(art)`; add `is-flipping`; `gsap.set(art, { zIndex: 30 })` so it travels above the still-fading backdrop.
   - Timeline (`onComplete`: `detail.hidden = true; gsap.set(art, { clearProps: 'zIndex' }); open = null; busy = false; tile.focus()`):
     - `.to('.sef__detail-rise', { y: 40 * 0.5, opacity: 0, duration: 0.3, ease: 'power2.in', stagger: 0.03 }, 0)`
     - `.add(Flip.from(state, { duration: 0.9, ease: 'power3.inOut', absolute: true, scale: true, props: 'borderRadius' }), 0)` — back to the slot, radius 0 → 14px.
     - `.to('.sef__detail-bg', { opacity: 0, duration: 0.7, ease: 'power2.inOut' }, 0.2)`
     - `.to(otherCells, { opacity: 1, scale: 1, duration: 0.6, ease: 'power3.out', stagger: 0.02, clearProps: 'transform,opacity' }, 0.3)`
     - `.to(label, { opacity: 1, duration: 0.3 }, 0.6)`
4. Entrance (decorative): `gsap.from('.sef__eyebrow, .sef__title', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08 })` and `gsap.from('.sef__cell', { y: 30, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.05, delay: 0.1, clearProps: 'all' })`.
5. Demo autoplay: after `document.fonts.ready`, `setTimeout(() => openTile(tiles[2]), 1200)` and `setTimeout(closeDetail, 4200)`, both cancelled on the first pointerdown/click/keydown.

**Interaction:** Click a tile → open (0.9s). Click Close or press Escape → close (0.9s). Clicks during an animation are ignored (`busy`). Hovering a tile dims its label to 70% over 0.3s. Focus moves to Close on open and back to the originating tile on close.

**Responsive behavior:** No `gsap.matchMedia()` branches — Flip measures the live layout, so the same code morphs into whatever the detail grid is. Under 768px the thumbnail grid is 2 columns and the detail layout becomes `grid-template-columns: 1fr; grid-template-rows: 46vh 1fr; overflow-y: auto`, so the artwork morphs into a 46vh banner with the copy below it.

**Accessibility:** `prefers-reduced-motion: reduce` → every duration is multiplied by 0 (`D = 0`), so the element is simply reparented and the detail appears/disappears instantly with no motion; the autoplay is skipped. Tiles are `<button>`s with visible focus rings; the detail is `role="dialog" aria-modal="true" aria-labelledby` the title; Escape closes; focus is managed both ways. Decorative art is unlabeled; the title text is real text, not an image.

**Performance rules:** Flip runs with `absolute: true` (no reflow of either layout during the morph) and `scale: true` (`true` = transforms only, never width/height). Only `transform`, `opacity` and `border-radius` change. `will-change: transform` is on the cells and, via `.is-flipping`, on the shared element only while it travels; `clearProps` removes inline styles after the close so the grid returns to pure CSS. Everything is inside `gsap.context()` and reverted on unmount; timers and listeners are cleared.

**Customization knobs:**
- `DURATION` (0.9) / `EASE` (`'power3.inOut'`) — the morph; try `'expo.inOut'` for a punchier arrival.
- `scale: true` — `false` animates real width/height (crisper for text inside the shared element, costlier).
- Sibling fade `scale: 0.92` and `stagger: 0.03`.
- Copy rise distance (40px) and accent colour (`#d4ff3a`) for the category line and focus rings.
- Detail column ratio `1.15fr / .85fr` and the `border-radius` pair (14px → 0).
- `AUTOPLAY` — set `false` in production.

**Acceptance criteria:**
- [ ] The clicked artwork itself travels from its slot to the detail column; nothing fades out and re-appears.
- [ ] Corner radius animates 14px → 0 on open and back on close.
- [ ] Other tiles fade/scale away during the open and return on close; the empty slot keeps its size.
- [ ] Escape and Close both reverse the morph; focus returns to the originating tile.
- [ ] Reduced-motion users see instant open/close; no layout shift; no console errors.

Source

Full source code

<section class="sef">
  <header class="sef__head">
    <p class="sef__eyebrow">Page transition · GSAP Flip</p>
    <h1 class="sef__title">Open a frame, stay in the room.</h1>
  </header>

  <ul class="sef__grid" role="list">
    <li class="sef__cell"><button class="sef__tile" type="button" data-title="Tide Tables" data-meta="Photography · 2026" data-copy="Forty mornings on the same estuary, printed at the scale of the tide itself."><div class="sef__art" style="--a:#1c3b5a;--b:#0a0a0a;--c:#e0b07a"></div><span class="sef__label"><span>Tide Tables</span><span>01</span></span></button></li>
    <li class="sef__cell"><button class="sef__tile" type="button" data-title="Salt Chapel" data-meta="Architecture · 2026" data-copy="A single room of white concrete that turns pink for twenty minutes every evening."><div class="sef__art" style="--a:#5a2b4a;--b:#0a0a0a;--c:#ff9f7a"></div><span class="sef__label"><span>Salt Chapel</span><span>02</span></span></button></li>
    <li class="sef__cell"><button class="sef__tile" type="button" data-title="North Light" data-meta="Interiors · 2025" data-copy="An attic studio reorganised around one window and the four hours of light it allows."><div class="sef__art" style="--a:#2a5c48;--b:#0a0a0a;--c:#d4ff3a"></div><span class="sef__label"><span>North Light</span><span>03</span></span></button></li>
    <li class="sef__cell"><button class="sef__tile" type="button" data-title="Quiet Machines" data-meta="Product · 2025" data-copy="Kitchen tools with no logos, no lights and nothing that beeps."><div class="sef__art" style="--a:#5c3a2a;--b:#0a0a0a;--c:#ffd27a"></div><span class="sef__label"><span>Quiet Machines</span><span>04</span></span></button></li>
    <li class="sef__cell"><button class="sef__tile" type="button" data-title="Ferric" data-meta="Identity · 2025" data-copy="A type system for a foundry, drawn from the rust patterns on its own doors."><div class="sef__art" style="--a:#4a2a5c;--b:#0a0a0a;--c:#9ac4ff"></div><span class="sef__label"><span>Ferric</span><span>05</span></span></button></li>
    <li class="sef__cell"><button class="sef__tile" type="button" data-title="Low Sun" data-meta="Editorial · 2024" data-copy="A winter issue photographed entirely between three and four in the afternoon."><div class="sef__art" style="--a:#5c5a2a;--b:#0a0a0a;--c:#ff7a5a"></div><span class="sef__label"><span>Low Sun</span><span>06</span></span></button></li>
  </ul>

  <div class="sef__detail" role="dialog" aria-modal="true" aria-labelledby="sef-detail-title" hidden>
    <div class="sef__detail-bg" aria-hidden="true"></div>
    <div class="sef__detail-media"></div>
    <div class="sef__detail-body">
      <p class="sef__detail-meta sef__detail-rise"></p>
      <h2 class="sef__detail-title sef__detail-rise" id="sef-detail-title"></h2>
      <p class="sef__detail-copy sef__detail-rise"></p>
      <p class="sef__detail-hint sef__detail-rise">Press <kbd>Esc</kbd> or Close to return</p>
    </div>
    <button class="sef__close" type="button">Close</button>
  </div>
</section>