# 3D Card Flip Gallery

> Tactile 3D cards with deep perspective, reactive edge-light, shadow inversion, hover/focus parity, and tap auto-close.

Canonical: https://gsapvault.com/effects/3d-card-flip
Live demo: https://gsapvault.com/demos/3d-card-flip/index.html

| Property | Value |
|----------|-------|
| Type | effect |
| Tier | free |
| Price | Free |
| Difficulty | intermediate |
| Plugins | ScrollTrigger |
| Techniques | 3d-transforms, hover-effect, click-toggle, stagger, scroll-reveal, matchMedia |
| Uses Lenis | No |

## Overview

A premium 3D card flip effect that turns portfolio, team, or product cards into tactile two-sided objects. GSAP coordinates the deep rotateY movement with edge-light, face illumination, scale, and directional shadow changes while surrounding cards subtly yield.

## Features

- Deep perspective flip with exact 0° and 180° resting states
- Layered front and back faces with reactive directional lighting
- Independent edge-light and shadow inversion during rotation
- Hover, focus, tap, and programmatic class-based controls
- Auto-close groups that keep touch layouts controlled
- Subtle neighbouring-card scale and position response
- Optional staggered ScrollTrigger entrance via a data attribute
- Reduced-motion instant switching and full keyboard support

## Use Cases

- Creative rosters with project dossiers
- Portfolio cards with concise case-study details
- Team profiles with role and biography reveals
- Product cards with specifications on the reverse
- Editorial collections with tactile browsing

## Vibe-Code Ready Setup

This effect includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

Each card holds two absolutely positioned faces inside a preserve-3d wrapper. GSAP drives rotateY between exact front and back states using a confident power4.inOut ease, while separate tweens invert the shadow, sweep the face lighting, brighten the card edge, and ask neighbouring cards to yield. Data attributes select hover or click behavior, enable grouped auto-close, and opt a container into a staggered ScrollTrigger entrance.

## Documentation

A tactile, accessible two-sided card effect with deep perspective, directional face light, edge glow, shadow inversion, and a restrained response from neighbouring cards.

## Quick Start

**1. Add to your HTML `<head>`:**

```html
<link rel="stylesheet" href="path/to/style.css">
```

**2. Add a card to your HTML `<body>`:**

```html
<div data-flip-group="auto-close" data-flip-stagger>
  <button class="flip-card" type="button" data-flip="hover" aria-pressed="false">
    <span class="card-shadow" aria-hidden="true"></span>
    <span class="flip-card-inner">
      <span class="card-edge" aria-hidden="true"></span>
      <span class="flip-card-front">
        <span class="face-light" aria-hidden="true"></span>
        <strong>Front content</strong>
      </span>
      <span class="flip-card-back">
        <span class="face-light" aria-hidden="true"></span>
        <strong>Back content</strong>
      </span>
    </span>
  </button>
</div>
```

**3. Add before the closing `</body>` tag:**

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
<script src="path/to/script.js"></script>
```

## Options

| Attribute / class | Values | Default | Description |
|---|---|---|---|
| `data-flip` | `hover`, `click` | `hover` | Hover/focus on fine pointers, or click/tap toggling. Hover cards automatically use tap on coarse pointers. |
| `data-flip-group` | `auto-close` | none | Put on a shared ancestor to close other open cards when one opens. |
| `data-flip-stagger` | present | absent | Adds the optional staggered ScrollTrigger entrance to cards inside the container. |
| `.flipped` | present / absent | absent | Public state class for programmatic control. |
| `--card-accent` | any CSS color | cyan | Per-card face, edge, and focus color. |

## Trigger Examples

### Hover, focus, and touch fallback

**Add to your HTML `<body>`:**

```html
<button class="flip-card" type="button" data-flip="hover" aria-pressed="false">
  <!-- card-shadow, flip-card-inner, edge, and both faces -->
</button>
```

A fine pointer opens this card on hover and keyboard focus. On a coarse pointer, tapping toggles it.

### Click/tap toggle

**Add to your HTML `<body>`:**

```html
<button class="flip-card" type="button" data-flip="click" aria-pressed="false">
  <!-- card content -->
</button>
```

Click, Enter, or Space toggles this card on every device.

### Auto-close roster with entrance

**Add to your HTML `<body>`:**

```html
<div class="roster" data-flip-group="auto-close" data-flip-stagger>
  <button class="flip-card" data-flip="hover" type="button">...</button>
  <button class="flip-card" data-flip="hover" type="button">...</button>
  <button class="flip-card" data-flip="hover" type="button">...</button>
</div>
```

## Programmatic API

The `.flipped` class remains the public state API. A `MutationObserver` synchronizes class changes with the full GSAP lighting, shadow, and grid response.

**Add to your JavaScript:**

```javascript
const card = document.querySelector('.flip-card');

card.classList.add('flipped');    // open
card.classList.remove('flipped'); // close
card.classList.toggle('flipped'); // toggle
```

Pressing Escape closes every card initialized by the effect. The script also keeps `aria-pressed` synchronized with the open state.

## Customization

**Add to your stylesheet after `style.css`:**

```css
.flip-card {
  --card-accent: #c7f36b;
  max-width: 320px;
  aspect-ratio: 0.76;
}

.roster {
  perspective: 1800px;
}
```

The core classes are `.flip-card`, `.flip-card-inner`, `.flip-card-front`, and `.flip-card-back`. `.card-edge`, `.card-shadow`, and each `.face-light` provide the layered secondary response and should be retained for the complete effect.

## Accessibility

- Use a semantic `<button>` for each card. Non-semantic `.flip-card` elements receive `role="button"` and `tabindex="0"` as a compatibility fallback.
- Hover cards mirror their open state on keyboard focus; click cards support Enter and Space.
- Escape closes open cards.
- `aria-pressed` is updated whenever the card state changes.
- `prefers-reduced-motion: reduce` removes interpolation and switches faces instantly while preserving every interaction.
- The front remains readable without JavaScript. Adding `.flipped` still switches faces through the CSS fallback.

## Cleanup

All GSAP work is wrapped in `gsap.context()` and responsive behavior uses `gsap.matchMedia()`. Event listeners, the class observer, and effect-owned ScrollTriggers are removed when `window.gsapContext.kill()` runs or the page unloads.

## Dependencies

- GSAP 3.12+ (demo uses 3.14.2)
- ScrollTrigger (only required when keeping the `data-flip-stagger` entrance capability)
- No smooth-scroll library required

## Source Code

This effect is free. The complete source is included below.

### index.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>3D Card Flip Demo | GSAP Vault</title>
	<link rel="stylesheet" href="assets/style.css">
	<link rel="preconnect" href="https://fonts.googleapis.com">
	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
	<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;600&family=Space+Grotesk:wght@400;500;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
</head>
<body>
	<main class="showcase-shell">
		<header class="showcase-toolbar">
			<p class="showcase-prompt">
				<span class="showcase-prompt__dot" aria-hidden="true"></span>
				<strong>Open a dossier</strong>
				<span>Hover, focus or tap</span>
			</p>
			<span class="showcase-index" aria-hidden="true">NORTH/03</span>
		</header>

		<section class="showcase-stage" aria-label="Creative collective project roster">
			<div class="stage-heading" aria-hidden="true">
				<span>Selected collective</span>
				<span>2026 roster</span>
			</div>

			<div class="roster" data-flip-group="auto-close" data-flip-stagger data-thumbnail-target>
				<button class="flip-card flip-card--cyan" type="button" data-flip="hover" aria-label="Open Aya Ito's Lumen House project dossier" aria-pressed="false">
					<span class="card-shadow" aria-hidden="true"></span>
					<span class="flip-card-inner">
						<span class="card-edge" aria-hidden="true"></span>
						<span class="flip-card-front">
							<span class="face-light" aria-hidden="true"></span>
							<span class="card-topline"><span>01</span><span>Tokyo · Light</span></span>
							<span class="card-monogram">AI</span>
							<span class="card-identity"><strong>Aya Ito</strong><span>Spatial artist</span></span>
						</span>
						<span class="flip-card-back">
							<span class="face-light" aria-hidden="true"></span>
							<span class="card-topline"><span>Dossier 01</span><span>2026</span></span>
							<span class="project-type">Immersive installation</span>
							<strong class="project-title">Lumen<br>House</strong>
							<span class="project-meta"><span>Osaka</span><span>12 rooms</span><span>4.8k m²</span></span>
						</span>
					</span>
				</button>

				<button class="flip-card flip-card--violet" type="button" data-flip="hover" aria-label="Open Niko Vale's Afterimage project dossier" aria-pressed="false">
					<span class="card-shadow" aria-hidden="true"></span>
					<span class="flip-card-inner">
						<span class="card-edge" aria-hidden="true"></span>
						<span class="flip-card-front">
							<span class="face-light" aria-hidden="true"></span>
							<span class="card-topline"><span>02</span><span>Berlin · Motion</span></span>
							<span class="card-monogram">NV</span>
							<span class="card-identity"><strong>Niko Vale</strong><span>Motion director</span></span>
						</span>
						<span class="flip-card-back">
							<span class="face-light" aria-hidden="true"></span>
							<span class="card-topline"><span>Dossier 02</span><span>2026</span></span>
							<span class="project-type">Kinetic identity</span>
							<strong class="project-title">After&shy;image</strong>
							<span class="project-meta"><span>Global</span><span>36 films</span><span>12 markets</span></span>
						</span>
					</span>
				</button>

				<button class="flip-card flip-card--lime" type="button" data-flip="hover" aria-label="Open Sol Mercer’s Wild Signal project dossier" aria-pressed="false">
					<span class="card-shadow" aria-hidden="true"></span>
					<span class="flip-card-inner">
						<span class="card-edge" aria-hidden="true"></span>
						<span class="flip-card-front">
							<span class="face-light" aria-hidden="true"></span>
							<span class="card-topline"><span>03</span><span>London · Sound</span></span>
							<span class="card-monogram">SM</span>
							<span class="card-identity"><strong>Sol Mercer</strong><span>Sonic architect</span></span>
						</span>
						<span class="flip-card-back">
							<span class="face-light" aria-hidden="true"></span>
							<span class="card-topline"><span>Dossier 03</span><span>2026</span></span>
							<span class="project-type">Generative soundscape</span>
							<strong class="project-title">Wild<br>Signal</strong>
							<span class="project-meta"><span>Barbican</span><span>8 channels</span><span>Live</span></span>
						</span>
					</span>
				</button>
			</div>
		</section>
	</main>

	<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
	<script src="assets/script.js"></script>
</body>
</html>
```

### assets/style.css

```css
* { box-sizing: border-box; margin: 0; padding: 0; }

:root {
	--black: #070808;
	--surface: #111313;
	--paper: #e9ece7;
	--muted: #8b918e;
	--cyan: #67e8f9;
	--violet: #a78bfa;
	--lime: #c7f36b;
	--accent: var(--cyan);
	color-scheme: dark;
}

html, body { min-height: 100%; }
html { background: var(--black); }
body {
	background:
		radial-gradient(circle at 50% 118%, rgba(103, 232, 249, 0.08), transparent 42%),
		linear-gradient(rgba(255,255,255,0.018) 1px, transparent 1px),
		var(--black);
	background-size: auto, 100% 44px, auto;
	color: var(--paper);
	font-family: 'Space Grotesk', system-ui, sans-serif;
	overflow: hidden;
	-webkit-font-smoothing: antialiased;
}

body::before {
	content: '';
	position: fixed;
	inset: 0;
	pointer-events: none;
	background: linear-gradient(90deg, transparent 0 7%, rgba(255,255,255,.025) 7% 7.08%, transparent 7.08% 92.92%, rgba(255,255,255,.025) 92.92% 93%, transparent 93%);
}

::selection { background: var(--cyan); color: var(--black); }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--black); }
::-webkit-scrollbar-thumb { background: #343838; border-radius: 10px; }

.showcase-shell {
	display: grid;
	grid-template-rows: 58px minmax(0, 1fr);
	width: 100%;
	height: 100svh;
	min-height: 520px;
	padding: 0 clamp(22px, 4vw, 58px) clamp(22px, 4vh, 34px);
}

.showcase-toolbar {
	display: flex;
	align-items: center;
	justify-content: space-between;
	border-bottom: 1px solid rgba(255,255,255,.1);
	font-family: 'JetBrains Mono', monospace;
	font-size: 10px;
	letter-spacing: .08em;
	text-transform: uppercase;
}

.showcase-prompt { display: flex; align-items: center; gap: 10px; }
.showcase-prompt strong { color: var(--paper); font-weight: 600; }
.showcase-prompt span:not(.showcase-prompt__dot), .showcase-index { color: var(--muted); }
.showcase-prompt__dot {
	width: 6px;
	height: 6px;
	border-radius: 50%;
	background: var(--lime);
	box-shadow: 0 0 12px rgba(199,243,107,.65);
}

.showcase-stage {
	min-height: 0;
	display: grid;
	grid-template-rows: auto minmax(0, 1fr);
	padding-top: clamp(16px, 3vh, 28px);
}

.stage-heading {
	display: flex;
	justify-content: space-between;
	color: #616764;
	font-family: 'JetBrains Mono', monospace;
	font-size: 9px;
	letter-spacing: .13em;
	text-transform: uppercase;
}

.roster {
	display: grid;
	grid-template-columns: repeat(3, minmax(0, 1fr));
	align-items: center;
	gap: clamp(14px, 2.6vw, 34px);
	width: min(100%, 1040px);
	margin: 0 auto;
	perspective: 1800px;
}

.flip-card {
	--card-accent: var(--cyan);
	position: relative;
	width: 100%;
	max-width: 326px;
	aspect-ratio: .76;
	padding: 0;
	border: 0;
	border-radius: 18px;
	background: transparent;
	color: #080909;
	font: inherit;
	text-align: left;
	cursor: pointer;
	perspective: 1400px;
	transform-origin: center bottom;
	-webkit-tap-highlight-color: transparent;
}
.flip-card--violet { --card-accent: var(--violet); }
.flip-card--lime { --card-accent: var(--lime); }

.card-shadow {
	position: absolute;
	z-index: -1;
	inset: 8% 6% -5%;
	border-radius: 18px;
	background: #000;
	filter: blur(24px);
	opacity: .34;
	transform: translateX(18px) scale(.95);
}

.flip-card-inner {
	position: absolute;
	inset: 0;
	transform-style: preserve-3d;
	will-change: transform;
}

.card-edge {
	position: absolute;
	z-index: 1;
	inset: 7px -3px;
	border: 1px solid color-mix(in srgb, var(--card-accent) 80%, white);
	border-radius: 17px;
	opacity: .3;
	transform: translateZ(-9px) translateX(5px);
	box-shadow: 0 0 16px color-mix(in srgb, var(--card-accent) 32%, transparent);
}

.flip-card-front,
.flip-card-back {
	position: absolute;
	inset: 0;
	display: flex;
	flex-direction: column;
	justify-content: space-between;
	overflow: hidden;
	padding: clamp(18px, 2.2vw, 28px);
	border: 1px solid rgba(255,255,255,.3);
	border-radius: 18px;
	background: var(--card-accent);
	backface-visibility: hidden;
	-webkit-backface-visibility: hidden;
	box-shadow: inset 0 1px rgba(255,255,255,.54), inset 0 -18px 38px rgba(0,0,0,.11);
	transform: rotateY(0deg) translateZ(1px);
}

.flip-card-back {
	background: #111414;
	border-color: color-mix(in srgb, var(--card-accent) 46%, transparent);
	color: var(--paper);
	transform: rotateY(180deg) translateZ(1px);
	box-shadow: inset 0 0 0 1px rgba(255,255,255,.025), inset 26px 0 50px rgba(0,0,0,.55);
}

.face-light {
	position: absolute;
	inset: -25%;
	pointer-events: none;
	background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,.7) 49%, transparent 67%);
	mix-blend-mode: soft-light;
	opacity: .42;
	transform: translateX(25%);
}
.flip-card-back .face-light { opacity: .08; }

.card-topline,
.project-meta {
	position: relative;
	z-index: 1;
	display: flex;
	justify-content: space-between;
	font-family: 'JetBrains Mono', monospace;
	font-size: clamp(8px, .82vw, 10px);
	font-weight: 600;
	letter-spacing: .08em;
	text-transform: uppercase;
}

.card-monogram {
	position: relative;
	z-index: 1;
	align-self: center;
	font-family: 'Syne', sans-serif;
	font-size: clamp(64px, 8vw, 112px);
	font-weight: 800;
	letter-spacing: -.1em;
	line-height: .75;
	transform: translateX(-.05em);
}

.card-identity { position: relative; z-index: 1; display: grid; gap: 2px; }
.card-identity strong {
	font-family: 'Syne', sans-serif;
	font-size: clamp(22px, 2.35vw, 34px);
	line-height: 1;
	letter-spacing: -.055em;
}
.card-identity span, .project-type {
	font-family: 'JetBrains Mono', monospace;
	font-size: clamp(8px, .8vw, 10px);
	letter-spacing: .09em;
	text-transform: uppercase;
}

.project-type { position: relative; z-index: 1; color: var(--card-accent); }
.project-title {
	position: relative;
	z-index: 1;
	font-family: 'Syne', sans-serif;
	font-size: clamp(38px, 4.3vw, 60px);
	font-weight: 700;
	line-height: .86;
	letter-spacing: -.07em;
}
.project-meta { gap: 8px; color: #8d9692; font-size: clamp(7px, .68vw, 9px); }

.flip-card:focus-visible { outline: 2px solid var(--card-accent); outline-offset: 6px; }

/* No-JS and programmatic class fallback; GSAP takes over these transforms when available. */
.flip-card-inner { transition: transform .82s cubic-bezier(.77, 0, .175, 1); }
.has-js .flip-card-inner { transition: none; }
.flip-card.flipped .flip-card-inner { transform: rotateY(180deg); }

@media (max-width: 640px) {
	body { overflow-y: auto; }
	.showcase-shell {
		height: auto;
		min-height: 100svh;
		grid-template-rows: 54px auto;
		padding: 0 18px 22px;
	}
	.showcase-index { display: none; }
	.showcase-stage { padding-top: 14px; }
	.roster {
		align-self: start;
		align-content: start;
		grid-template-columns: 1fr;
		gap: 11px;
		padding: 16px 0 2px;
	}
	.flip-card {
		max-width: none;
		aspect-ratio: 2.15;
		min-height: 142px;
	}
	.flip-card-front, .flip-card-back { padding: 16px 18px; border-radius: 14px; }
	.card-edge { border-radius: 14px; }
	.card-monogram { position: absolute; right: 22px; top: 50%; font-size: 62px; transform: translateY(-50%); }
	.card-identity strong { font-size: 24px; }
	.project-title { font-size: 36px; }
	.project-title br { display: none; }
	.project-type { position: absolute; top: 48%; transform: translateY(-50%); }
	.project-meta { font-size: 7px; }
}

@media (max-width: 640px) and (max-height: 700px) {
	body { overflow-y: hidden; }
	.showcase-shell { height: 100svh; min-height: 0; padding-bottom: 10px; }
	.showcase-stage { padding-top: 9px; min-height: 0; }
	.roster { gap: 8px; padding-top: 9px; min-height: 0; }
	.flip-card { height: 132px; min-height: 0; aspect-ratio: auto; }
	.flip-card-front, .flip-card-back { padding: 12px 16px; }
	.card-identity strong { font-size: 21px; }
	.project-title { font-size: 30px; }
}

@media (max-height: 700px) and (min-width: 641px) {
	.showcase-shell { grid-template-rows: 52px minmax(0, 1fr); padding-bottom: 18px; }
	.showcase-stage { padding-top: 14px; }
	.roster { width: min(86%, 920px); gap: 24px; }
}

@media (prefers-reduced-motion: reduce) {
	*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
	.flip-card-inner { transition: none; }
	.flip-card.flipped .flip-card-inner { transform: rotateY(180deg); }
}
```

### assets/script.js

```js
/**
 * 3D Card Flip
 * Tactile front/back cards with hover, focus, tap, grouped auto-close,
 * programmatic class control, and an optional ScrollTrigger entrance.
 *
 * @plugins ScrollTrigger
 * @techniques 3d-transforms, hover-effect, click-toggle, scroll-reveal
 */

gsap.registerPlugin(ScrollTrigger);

(function onReady(init) {
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', init);
	} else {
		init();
	}
})(function initCardFlip() {
	document.documentElement.classList.add('has-js');

	const ctx = gsap.context(function cardFlipContext() {
		const mm = gsap.matchMedia();

		mm.add({
			isFine: '(pointer: fine)',
			isMotionSafe: '(prefers-reduced-motion: no-preference)',
			isReduced: '(prefers-reduced-motion: reduce)'
		}, function setupCards(context) {
			const { isFine, isReduced } = context.conditions;
			const cards = Array.from(document.querySelectorAll('.flip-card'));
			const handlers = new Map();
			const states = new WeakMap();
			const entranceTriggers = [];
			let activeCard = null;

			function updateGrid(nextActive, instant) {
				activeCard = nextActive && nextActive.isConnected ? nextActive : null;
				cards.forEach(function yieldCard(card) {
					const isActive = card === activeCard;
					const shouldYield = Boolean(activeCard && !isActive);
					if (isReduced || instant) {
						gsap.set(card, { scale: isActive ? 1.025 : shouldYield ? 0.965 : 1, y: shouldYield ? 8 : 0 });
						return;
					}
					gsap.to(card, {
						scale: isActive ? 1.025 : shouldYield ? 0.965 : 1,
						y: shouldYield ? 8 : 0,
						duration: 0.55,
						ease: 'power3.out',
						overwrite: 'auto'
					});
				});
		}

			function renderCard(card, open, instant) {
				if (!card.isConnected) return;
				const state = states.get(card);
				if (!state || (state.open === open && !instant)) return;
				state.open = open;
				card.setAttribute('aria-pressed', String(open));

				const inner = card.querySelector('.flip-card-inner');
				const shadow = card.querySelector('.card-shadow');
				const edge = card.querySelector('.card-edge');
				const frontLight = card.querySelector('.flip-card-front .face-light');
				const backLight = card.querySelector('.flip-card-back .face-light');
				const duration = instant || isReduced ? 0 : 0.82;
				const ease = 'power4.inOut';

				gsap.to(inner, {
					rotateY: open ? 180 : 0,
					duration,
					ease,
					overwrite: true
				});
				gsap.to(edge, {
					opacity: open ? 0.82 : 0.3,
					x: open ? -5 : 5,
					duration: duration * 0.72,
					ease: 'power3.inOut',
					overwrite: true
				});
				gsap.to(frontLight, {
					opacity: open ? 0.05 : 0.42,
					xPercent: open ? -35 : 25,
					duration,
					ease,
					overwrite: true
				});
				gsap.to(backLight, {
					opacity: open ? 0.48 : 0.08,
					xPercent: open ? -18 : 35,
					duration,
					ease,
					overwrite: true
				});
				gsap.to(shadow, {
					x: open ? -18 : 18,
					scaleX: open ? 0.86 : 1,
					opacity: open ? 0.58 : 0.34,
					duration,
					ease,
					overwrite: true
				});
			}

			function closeGroup(card, instant) {
				const group = card.closest('[data-flip-group="auto-close"]');
				if (!group) return;
				group.querySelectorAll('.flip-card.flipped').forEach(function closeSibling(sibling) {
					if (sibling !== card) setFlipped(sibling, false, instant);
				});
			}

			function setFlipped(card, open, instant) {
				if (!card || !card.isConnected) return;
				if (open) closeGroup(card, instant);
				card.classList.toggle('flipped', open);
				renderCard(card, open, instant);
				updateGrid(open ? card : activeCard === card ? null : activeCard, instant);
			}

			cards.forEach(function setupCard(card) {
				const flipMode = card.dataset.flip || 'hover';
				if (!card.hasAttribute('tabindex') && !/^(BUTTON|A)$/.test(card.tagName)) card.tabIndex = 0;
				if (!card.hasAttribute('role') && !/^(BUTTON|A)$/.test(card.tagName)) card.setAttribute('role', 'button');
				states.set(card, { open: !card.classList.contains('flipped') });
				renderCard(card, card.classList.contains('flipped'), true);

				function toggle() {
					setFlipped(card, !card.classList.contains('flipped'), isReduced);
				}
				function enter() {
					setFlipped(card, true, isReduced);
				}
				function leave() {
					if (!card.matches(':hover') && document.activeElement !== card) setFlipped(card, false, isReduced);
				}
				function click() {
					if (isReduced || !(flipMode === 'hover' && isFine)) toggle();
				}
				function keydown(event) {
					/* Buttons and links already convert Enter/Space to click. This fallback
					   is only for legacy non-semantic card markup. */
					if (/^(BUTTON|A)$/.test(card.tagName)) return;
					if (event.key === 'Enter' || event.key === ' ') {
						event.preventDefault();
						if (flipMode === 'click' || !isFine || isReduced) toggle();
					}
				}

				card.addEventListener('click', click);
				card.addEventListener('keydown', keydown);
				if (flipMode === 'hover' && isFine && !isReduced) {
					card.addEventListener('mouseenter', enter);
					card.addEventListener('mouseleave', leave);
					card.addEventListener('focus', enter);
					card.addEventListener('blur', leave);
				}
				handlers.set(card, { click, keydown, enter, leave, hoverBound: flipMode === 'hover' && isFine && !isReduced });
			});

			function handleEscape(event) {
				if (event.key !== 'Escape') return;
				cards.forEach(function closeCard(card) { setFlipped(card, false, isReduced); });
			}
			document.addEventListener('keydown', handleEscape);

			const observer = new MutationObserver(function syncClass(mutations) {
				mutations.forEach(function syncCard(mutation) {
					const card = mutation.target;
					renderCard(card, card.classList.contains('flipped'), isReduced);
					updateGrid(card.classList.contains('flipped') ? card : activeCard === card ? null : activeCard, isReduced);
				});
			});
			cards.forEach(function observeCard(card) { observer.observe(card, { attributes: true, attributeFilter: ['class'] }); });

			if (!isReduced) {
				document.querySelectorAll('[data-flip-stagger]').forEach(function setupEntrance(container) {
					const tween = gsap.from(container.querySelectorAll('.flip-card'), {
						y: 52,
						rotationX: -8,
						opacity: 0,
						duration: 0.85,
						ease: 'power3.out',
						stagger: 0.1,
						scrollTrigger: { trigger: container, start: 'top 92%', once: true }
					});
					if (tween.scrollTrigger) entranceTriggers.push(tween.scrollTrigger);
				});
			}

			return function cleanup() {
				observer.disconnect();
				document.removeEventListener('keydown', handleEscape);
				handlers.forEach(function removeHandlers(record, card) {
					card.removeEventListener('click', record.click);
					card.removeEventListener('keydown', record.keydown);
					if (record.hoverBound) {
						card.removeEventListener('mouseenter', record.enter);
						card.removeEventListener('mouseleave', record.leave);
						card.removeEventListener('focus', record.enter);
						card.removeEventListener('blur', record.leave);
					}
				});
				handlers.clear();
				entranceTriggers.forEach(function killTrigger(trigger) { trigger.kill(); });
			};
		});
	});

	window.gsapContext = ctx;
	window.addEventListener('beforeunload', function cleanupCardFlip() { ctx.kill(); }, { once: true });
});
```

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
