f01

3D Card Flip Gallery

Free

Tactile GSAP 3D cards with deep perspective, reactive edge lighting and shifting shadows. Flip on hover, keyboard focus or tap, with grouped auto-close.

ScrollTrigger intermediate
6 more details
3d-transformshover-effectclick-togglestaggerscroll-revealmatchMedia
3D Card Flip Gallery - GSAP animation effect preview

This demo reads better at your own screen size than in the frame below:

About this effect

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.

What's included

8 items
  • 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

Perfect for

5 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

How it works

1 section

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.

Plugins ScrollTrigger
Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, 15 September 2026. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!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">
</head>
<body>
	<main class="stage" aria-label="Creative collective project roster">
		<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>

		<p class="prompt">
			<strong>Open a dossier</strong>
			<span>Hover, focus or tap</span>
		</p>
	</main>

	<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
	<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
	<script src="assets/script.js"></script>
</body>
</html>
/**
 * 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.revert(); }, { once: true });
});
gsap.registerPlugin(ScrollTrigger),function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()}(function(){document.documentElement.classList.add("has-js");const e=gsap.context(function(){gsap.matchMedia().add({isFine:"(pointer: fine)",isMotionSafe:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(e){const{isFine:t,isReduced:n}=e.conditions,o=Array.from(document.querySelectorAll(".flip-card")),r=new Map,i=new WeakMap,c=[];let a=null;function s(e,t){a=e&&e.isConnected?e:null,o.forEach(function(e){const o=e===a,r=Boolean(a&&!o);n||t?gsap.set(e,{scale:o?1.025:r?.965:1,y:r?8:0}):gsap.to(e,{scale:o?1.025:r?.965:1,y:r?8:0,duration:.55,ease:"power3.out",overwrite:"auto"})})}function u(e,t,o){if(!e.isConnected)return;const r=i.get(e);if(!r||r.open===t&&!o)return;r.open=t,e.setAttribute("aria-pressed",String(t));const c=e.querySelector(".flip-card-inner"),a=e.querySelector(".card-shadow"),s=e.querySelector(".card-edge"),u=e.querySelector(".flip-card-front .face-light"),d=e.querySelector(".flip-card-back .face-light"),l=o||n?0:.82,f="power4.inOut";gsap.to(c,{rotateY:t?180:0,duration:l,ease:f,overwrite:!0}),gsap.to(s,{opacity:t?.82:.3,x:t?-5:5,duration:.72*l,ease:"power3.inOut",overwrite:!0}),gsap.to(u,{opacity:t?.05:.42,xPercent:t?-35:25,duration:l,ease:f,overwrite:!0}),gsap.to(d,{opacity:t?.48:.08,xPercent:t?-18:35,duration:l,ease:f,overwrite:!0}),gsap.to(a,{x:t?-18:18,scaleX:t?.86:1,opacity:t?.58:.34,duration:l,ease:f,overwrite:!0})}function d(e,t,n){e&&e.isConnected&&(t&&function(e,t){const n=e.closest('[data-flip-group="auto-close"]');n&&n.querySelectorAll(".flip-card.flipped").forEach(function(n){n!==e&&d(n,!1,t)})}(e,n),e.classList.toggle("flipped",t),u(e,t,n),s(t?e:a===e?null:a,n))}function l(e){"Escape"===e.key&&o.forEach(function(e){d(e,!1,n)})}o.forEach(function(e){const o=e.dataset.flip||"hover";function c(){d(e,!e.classList.contains("flipped"),n)}function a(){d(e,!0,n)}function s(){e.matches(":hover")||document.activeElement===e||d(e,!1,n)}function l(){!n&&"hover"===o&&t||c()}function f(r){/^(BUTTON|A)$/.test(e.tagName)||"Enter"!==r.key&&" "!==r.key||(r.preventDefault(),"click"!==o&&t&&!n||c())}e.hasAttribute("tabindex")||/^(BUTTON|A)$/.test(e.tagName)||(e.tabIndex=0),e.hasAttribute("role")||/^(BUTTON|A)$/.test(e.tagName)||e.setAttribute("role","button"),i.set(e,{open:!e.classList.contains("flipped")}),u(e,e.classList.contains("flipped"),!0),e.addEventListener("click",l),e.addEventListener("keydown",f),"hover"===o&&t&&!n&&(e.addEventListener("mouseenter",a),e.addEventListener("mouseleave",s),e.addEventListener("focus",a),e.addEventListener("blur",s)),r.set(e,{click:l,keydown:f,enter:a,leave:s,hoverBound:"hover"===o&&t&&!n})}),document.addEventListener("keydown",l);const f=new MutationObserver(function(e){e.forEach(function(e){const t=e.target;u(t,t.classList.contains("flipped"),n),s(t.classList.contains("flipped")?t:a===t?null:a,n)})});return o.forEach(function(e){f.observe(e,{attributes:!0,attributeFilter:["class"]})}),n||document.querySelectorAll("[data-flip-stagger]").forEach(function(e){const t=gsap.from(e.querySelectorAll(".flip-card"),{y:52,rotationX:-8,opacity:0,duration:.85,ease:"power3.out",stagger:.1,scrollTrigger:{trigger:e,start:"top 92%",once:!0}});t.scrollTrigger&&c.push(t.scrollTrigger)}),function(){f.disconnect(),document.removeEventListener("keydown",l),r.forEach(function(e,t){t.removeEventListener("click",e.click),t.removeEventListener("keydown",e.keydown),e.hoverBound&&(t.removeEventListener("mouseenter",e.enter),t.removeEventListener("mouseleave",e.leave),t.removeEventListener("focus",e.enter),t.removeEventListener("blur",e.leave))}),r.clear(),c.forEach(function(e){e.kill()})}})});window.gsapContext=e,window.addEventListener("beforeunload",function(){e.revert()},{once:!0})});
* { box-sizing: border-box; margin: 0; padding: 0; }

:root {
	/* Cool grey ground, the site's ink, one violet accent. The three card
	   stocks exist only to tell the cards apart: two neutral greys and one
	   accent face, never a warm/beige stock. */
	--page: #ececeb;
	--surface: #f7f7f6;
	--ink: #141412;
	--chalk: #f1efea;
	--muted: rgba(20, 20, 18, 0.66); /* 4.8:1 on the deepest stock */
	--rule: rgba(20, 20, 18, 0.14);
	--stock-a: var(--surface); /* light grey stock */
	--stock-c: #ccced4;    /* deeper cool grey stock */
	--accent: #6d4cff;
	--accent-ink: #ffffff; /* 5.0:1 on the accent */
	--accent-lift: #a48cff; /* accent legible on the ink back face */
	color-scheme: light;
}

html, body { min-height: 100%; }
html { background: var(--page); }
body {
	background: var(--page);
	color: var(--ink);
	font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
	overflow-x: hidden;
	-webkit-font-smoothing: antialiased;
}

::selection { background: var(--accent); color: var(--accent-ink); }

/* Full-bleed ground. The three cards and the one trigger hint, nothing else. */
.stage {
	display: grid;
	align-content: center;
	justify-items: center;
	gap: clamp(28px, 4vh, 40px);
	width: 100%;
	min-height: 100svh;
	padding: clamp(24px, 5vh, 56px) clamp(22px, 4vw, 58px);
}

.prompt {
	display: flex;
	align-items: baseline;
	gap: 8px;
	/* Clear of the cards: on a tall viewport the row grows until the cue sits
	   against their bottom edge, which check:composition reads as ink touching
	   a border. */
	margin-top: 28px;
	font-size: 13px;
}
.prompt strong { font-weight: 500; }
.prompt span { color: var(--muted); }

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

.flip-card {
	--card-accent: var(--stock-a);
	--card-face-ink: var(--ink);
	--card-face-muted: var(--muted);
	position: relative;
	width: 100%;
	max-width: 326px;
	aspect-ratio: .76;
	padding: 0;
	border: 0;
	border-radius: 18px;
	background: transparent;
	color: var(--card-face-ink);
	font: inherit;
	text-align: left;
	cursor: pointer;
	perspective: 1400px;
	transform-origin: center bottom;
	-webkit-tap-highlight-color: transparent;
}
.flip-card--violet {
	--card-accent: var(--accent);
	--card-face-ink: var(--accent-ink);
	--card-face-muted: rgba(255, 255, 255, 0.96); /* 4.7:1 on the accent */
}
.flip-card--lime { --card-accent: var(--stock-c); }

.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(--accent) 70%, white);
	border-radius: 17px;
	opacity: .3;
	transform: translateZ(-9px) translateX(5px);
	box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 34%, 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 var(--rule);
	border-radius: 12px;
	background: var(--card-accent);
	color: var(--card-face-ink);
	backface-visibility: hidden;
	-webkit-backface-visibility: hidden;
	box-shadow: 0 1px 2px rgba(20,20,18,.06);
	transform: rotateY(0deg) translateZ(1px);
}

/* The dossier side inverts: ink face, chalk type. Deliberate contrast with
   the stock front, and it makes the flip read at a glance. */
.flip-card-back {
	background: var(--ink);
	border-color: rgba(241,239,234,.18);
	color: var(--chalk);
	transform: rotateY(180deg) translateZ(1px);
	box-shadow: 0 1px 2px rgba(20,20,18,.14);
}

.face-light {
	position: absolute;
	inset: -25%;
	pointer-events: none;
	background: linear-gradient(105deg, transparent 30%, rgba(255,255,255,.9) 49%, transparent 67%);
	mix-blend-mode: soft-light;
	opacity: .3;
	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-size: clamp(10px, .95vw, 12px);
	font-weight: 500;
	color: var(--card-face-muted);
}

.card-monogram {
	position: relative;
	z-index: 1;
	align-self: center;
	font-weight: 600;
	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-weight: 600;
	font-size: clamp(22px, 2.35vw, 34px);
	line-height: 1;
	letter-spacing: -.055em;
}
.card-identity span, .project-type {
	font-size: clamp(10px, .95vw, 12px);
	color: var(--card-face-muted);
}

.project-type { position: relative; z-index: 1; color: var(--accent-lift); }
.project-title {
	position: relative;
	z-index: 1;
	font-weight: 600;
	font-size: clamp(38px, 4.3vw, 60px);
	font-weight: 700;
	line-height: .86;
	letter-spacing: -.07em;
}
.project-meta { gap: 8px; color: rgba(241, 239, 234, 0.6); font-size: clamp(7px, .68vw, 9px); }

.flip-card-back .card-topline { color: rgba(241, 239, 234, 0.62); }

.flip-card:focus-visible { outline: 2px solid var(--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) {
	.stage {
		align-content: start;
		gap: 18px;
		padding: 22px 18px;
	}
	.roster {
		grid-template-columns: 1fr;
		gap: 11px;
	}
	.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) {
	.stage { gap: 14px; padding: 16px 18px; }
	.roster { gap: 8px; 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) {
	.stage { gap: 18px; padding: 20px clamp(22px, 4vw, 58px); }
	.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); }
}

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>:

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

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

<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:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/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 light grey stock Per-card front-face colour. The edge-light and focus ring follow --accent.

Trigger Examples

Hover, focus, and touch fallback

Add to your HTML <body>:

<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>:

<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>:

<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:

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:

.flip-card {
  --card-accent: #ccced4;
  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.revert() runs or the page unloads.

Dependencies

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

Your cart

Your cart is empty

The Vault £99

The Vault library, plus future additions to the library.