Cube Flip Cards — Six-Faced Glass Prisms that Flip on Hover and Tilt with the Pointer
Goal
Build a row of four cards, each rendered as a real 3D prism with six faces: front and back are frosted white glass carrying a thumbnail, a title, a short description and an arrow; the four sides are translucent. On hover the prism flips 180° on its horizontal axis (you see the top and bottom faces sweep past) and lands on its identical back face; from then on pointer movement tilts it on both axes (up to ±40°) with short tweens, and a specular highlight sweeps across the face with the rotation. On leave it eases back to rest. GSAP core.
Tech
Vanilla HTML/CSS/JS with ES module imports. gsap (npm). Font: DM Sans (400–500).
Layout / HTML
<div class="cf">
<section class="cf-stage">
<div class="cf-card"></div><div class="cf-card"></div><div class="cf-card"></div><div class="cf-card"></div>
</section>
</div>
JS fills each .cf-card with:
<div class="cf-cube">
<div class="cf-face cf-face--front">FACE</div>
<div class="cf-face cf-face--back">FACE</div>
<div class="cf-face cf-face--right"></div><div class="cf-face cf-face--left"></div>
<div class="cf-face cf-face--top"></div><div class="cf-face cf-face--bottom"></div>
</div>
where FACE = <img class="cf-thumb">, <h2 class="cf-title">, <div class="cf-meta"><p class="cf-desc">…</p><svg class="cf-arrow">→</svg></div>. Data: "Flip me / Go on, give it a hover. I dare you.", "Spin cycle / Now with 100% more tumble per second.", "Peek-a-boo / Six faces, one attitude. See them all.", "Lean in / It follows your cursor. A little clingy, honestly."
Styling
:root: --cf-paper:#ebece9, --cf-ink:#16171a, --cf-face:rgba(255,255,255,.82), --cf-side:rgba(255,255,255,.35), --cf-line:rgba(255,255,255,.7), --cf-card-w:300px, --cf-card-h:400px, --cf-depth:340px.
.cf-stage—position:relative; min-height:100svh; display:flex; gap:3rem; align-items:center; justify-content:center; overflow:hidden; padding:2rem;::beforeis a big soft white radial light (60vmax,opacity:.6) centred behind the cards..cf-card—width:var(--cf-card-w); height:var(--cf-card-h); perspective:2000px; flex-shrink:0..cf-cube—position:relative; width:100%; height:100%; transform-style:preserve-3d; cursor:pointer; transform:translateZ(calc(var(--cf-depth) / -2))..cf-face—position:absolute; backface-visibility:hidden; border:1px solid var(--cf-line).- front/back —
width:var(--cf-card-w); height:var(--cf-card-h); display:flex; flex-direction:column; padding:1.5rem; background:var(--cf-face); backdrop-filter:blur(18px) saturate(1.3); box-shadow:inset 0 1px 0 rgba(255,255,255,.9). Fronttransform:translateZ(calc(var(--cf-depth)/2)), backrotateX(180deg) translateZ(calc(var(--cf-depth)/2)). - right/left —
width:var(--cf-depth); height:var(--cf-card-h); left:calc((var(--cf-card-w) − var(--cf-depth))/2); background:var(--cf-side); rightrotateY(90deg) translateZ(calc(var(--cf-card-w)/2)), leftrotateY(−90deg) …. - top/bottom —
width:var(--cf-card-w); height:var(--cf-depth); top:calc((var(--cf-card-h) − var(--cf-depth))/2); background:var(--cf-side); toprotateX(90deg) translateZ(calc(var(--cf-card-h)/2)), bottomrotateX(−90deg) …. - Specular sweep on front/back
::after:linear-gradient(115deg, transparent 30%, rgba(255,255,255,.55) 50%, transparent 70%),background-size:250% 100%,background-position: var(--cf-shine, 100%) 0,mix-blend-mode:screen; pointer-events:none. .cf-thumb—align-self:flex-end; width:125px; height:155px; margin-bottom:auto; object-fit:cover; border-radius:6px..cf-title—2rem, 500,letter-spacing:-.03em..cf-meta—display:flex; align-items:flex-end; justify-content:space-between..cf-desc—.9rem,opacity:.75..cf-arrow—1.5remsquare.@media (max-width:1000px):--cf-card-w:260px; --cf-card-h:340px; --cf-depth:280px, stage becomes a column withpadding:20svh 1rem.
GSAP effect (be exact)
Build in mount(config) → destroy().
const DEFAULTS = { parallaxStrength: 40, flipDuration: .5, returnDuration: .6, tiltDuration: .6 };
Per cube read depth = parseFloat(getComputedStyle(wrapper).getPropertyValue("--cf-depth")) and keep rot = { flip: 0, tiltX: 0, tiltY: 0 }, flipped = false.
render() — gsap.set(cube, { rotationX: rot.flip + rot.tiltX, rotationY: rot.tiltY, z: −depth / 2 }); then shine = 100 − (rot.flip / 180) * 100 − rot.tiltY * .6 and cube.style.setProperty("--cf-shine", shine + "%"). Call once at init.
- mouseenter —
flipped = false;gsap.to(rot, { flip: 180, duration: flipDuration, ease: "power2.inOut", overwrite: "flip", onUpdate: render, onComplete: () => flipped = true }). - mouseleave —
flipped = false;gsap.to(rot, { flip: 0, tiltX: 0, tiltY: 0, duration: returnDuration, ease: "power3.out", overwrite: true, onUpdate: render }). - mousemove (only when
flipped) —ox = (clientX − centreX) / width,oy = (clientY − centreY) / heightfrom the cube's bounds;gsap.to(rot, { tiltY: ox * parallaxStrength, tiltX: −oy * parallaxStrength, duration: tiltDuration, ease: "power2.out", overwrite: "tilt", onUpdate: render }).
overwrite:"flip" / "tilt" are overwrite *groups*: a new flip tween cancels the previous flip but not the tilt, and vice-versa; the leave tween uses overwrite:true to cancel both.
destroy() — remove listeners, kill tweens on the cubes, empty the wrappers.
Assets / images
Four product-style stills on flat pastel backgrounds (a vase, a lamp, books, a chair), portrait-cropped to 125×155 in the card. ~600px source is enough.
Behavior notes
- The tilt only starts after the flip completes, so the first movement is always the clean 180° turn.
backface-visibility:hiddenkeeps the six faces from bleeding through each other during the flip; the sides use a translucent fill so the volume reads as glass rather than a box.- On touch devices there is no hover: the cards are static, fully readable fronts.
- Only prefixed variables are global; the section is self-contained.
Images
This component ships with 4 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/cube-flip-cards/img1.jpg
https://motionprompts.dev/c/cube-flip-cards/img2.jpg
https://motionprompts.dev/c/cube-flip-cards/img3.jpg
https://motionprompts.dev/c/cube-flip-cards/img4.jpg
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--cf-paper,--cf-ink,--cf-face,--cf-side,--cf-line,--cf-card-w,--cf-card-h,--cf-depth. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - Rules on
html, body— the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.