Back to Gallery

3D Card Flip Gallery

FREE

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

ScrollTrigger intermediate
6 more details
3d-transformshover-effectclick-togglestaggerscroll-revealmatchMedia

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
<!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>
/**
 * 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 });
});
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 l(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"),l=e.querySelector(".flip-card-front .face-light"),u=e.querySelector(".flip-card-back .face-light"),d=o||n?0:.82,f="power4.inOut";gsap.to(c,{rotateY:t?180:0,duration:d,ease:f,overwrite:!0}),gsap.to(s,{opacity:t?.82:.3,x:t?-5:5,duration:.72*d,ease:"power3.inOut",overwrite:!0}),gsap.to(l,{opacity:t?.05:.42,xPercent:t?-35:25,duration:d,ease:f,overwrite:!0}),gsap.to(u,{opacity:t?.48:.08,xPercent:t?-18:35,duration:d,ease:f,overwrite:!0}),gsap.to(a,{x:t?-18:18,scaleX:t?.86:1,opacity:t?.58:.34,duration:d,ease:f,overwrite:!0})}function u(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&&u(n,!1,t)})}(e,n),e.classList.toggle("flipped",t),l(e,t,n),s(t?e:a===e?null:a,n))}function d(e){"Escape"===e.key&&o.forEach(function(e){u(e,!1,n)})}o.forEach(function(e){const o=e.dataset.flip||"hover";function c(){u(e,!e.classList.contains("flipped"),n)}function a(){u(e,!0,n)}function s(){e.matches(":hover")||document.activeElement===e||u(e,!1,n)}function d(){!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")}),l(e,e.classList.contains("flipped"),!0),e.addEventListener("click",d),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:d,keydown:f,enter:a,leave:s,hoverBound:"hover"===o&&t&&!n})}),document.addEventListener("keydown",d);const f=new MutationObserver(function(e){e.forEach(function(e){const t=e.target;l(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",d),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.kill()},{once:!0})});
* { 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); }
}

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

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

Your Cart

Your cart is empty

Browse Effects