3D Flip Cards
A row of cards that flip in 3D to reveal a back face on hover AND on keyboard focus, so the CTA stays reachable. Pure CSS with preserve-3d and backface-visibility; reduced-motion swaps the flip for a cross-fade.
- transform-style: preserve-3d
- backface-visibility
- :focus-within
- rotateY
Source
A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.
---
export const meta = {
title: "3D Flip Cards",
tags: ["cards", "3d", "flip", "css-only"],
description:
"A row of cards that flip in 3D to reveal a back face on hover AND on keyboard focus, so the CTA stays reachable. Pure CSS with preserve-3d and backface-visibility; reduced-motion swaps the flip for a cross-fade.",
tech: ["transform-style: preserve-3d", "backface-visibility", ":focus-within", "rotateY"],
height: 460,
};
const cards = [
{
icon: "◇",
title: "Realtime",
tint: "a",
backTitle: "Live everywhere",
detail: "Sub-50ms sync across every client with conflict-free replicated data types.",
cta: "See the demo",
},
{
icon: "◈",
title: "Edge native",
tint: "b",
backTitle: "300+ regions",
detail: "Requests resolve at the nearest node. Your users never wait on a round-trip.",
cta: "View the map",
},
{
icon: "◆",
title: "Type-safe",
tint: "c",
backTitle: "End to end",
detail: "Types flow from database to component. Refactor the whole stack without fear.",
cta: "Read the docs",
},
];
---
<section class="flip">
<ul class="flip__row">
{cards.map((c) => (
<li class={`flip__card tint-${c.tint}`}>
<div class="flip__inner">
<div class="face face--front">
<span class="face__icon" aria-hidden="true">{c.icon}</span>
<h3 class="face__title">{c.title}</h3>
<span class="face__hint" aria-hidden="true">Hover or focus →</span>
</div>
<div class="face face--back">
<h4 class="face__back-title">{c.backTitle}</h4>
<p class="face__detail">{c.detail}</p>
<a class="face__cta" href="#">{c.cta}<span aria-hidden="true"> →</span></a>
</div>
</div>
</li>
))}
</ul>
</section>
<style>
.flip {
min-height: 460px; display: grid; place-items: center;
padding: 3rem 1.5rem; background: var(--bg-sunk, #0b0c15);
color: var(--ink, #fff); font-family: var(--font-sans, system-ui, sans-serif);
}
.flip__row {
list-style: none; margin: 0; padding: 0;
display: grid; gap: 1.5rem; width: min(60rem, 100%);
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
}
.flip__card { perspective: 1200px; }
.flip__inner {
position: relative; height: 15rem; border-radius: var(--radius-l, 22px);
transform-style: preserve-3d;
transition: transform var(--dur-slow, 640ms) var(--ease-emph, cubic-bezier(0.2,0,0,1));
will-change: transform;
}
/* flip on pointer hover AND keyboard focus (the back CTA is focusable) */
.flip__card:hover .flip__inner,
.flip__card:focus-within .flip__inner { transform: rotateY(180deg); }
.face {
position: absolute; inset: 0; border-radius: var(--radius-l, 22px);
padding: 1.5rem; box-sizing: border-box;
display: grid; align-content: center; gap: 0.5rem;
-webkit-backface-visibility: hidden; backface-visibility: hidden;
overflow: hidden; border: 1px solid var(--border, #2c2d38);
}
.face--front { justify-items: center; text-align: center; background: var(--surface, #16171f); }
.face--front::before {
content: ""; position: absolute; inset: 0; z-index: -1; opacity: 0.9;
background: radial-gradient(120% 90% at 50% -10%, var(--t1) 0%, transparent 55%);
}
.face--back {
transform: rotateY(180deg); align-content: start;
color: #fff; background: linear-gradient(150deg, var(--t1), var(--t2));
}
.face--back::before {
content: ""; position: absolute; inset: 0; z-index: 0;
background: radial-gradient(90% 70% at 100% 0%, rgb(255 255 255 / 0.22), transparent 60%);
pointer-events: none;
}
.face__icon {
font-size: 2.6rem; line-height: 1;
background: linear-gradient(150deg, var(--t1), var(--t2));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.face__title { margin: 0; font-size: 1.4rem; letter-spacing: -0.02em; }
.face__hint { font-size: 0.75rem; color: var(--ink-faint, #8a8c9c); letter-spacing: 0.04em; }
.face--back > * { position: relative; z-index: 1; }
.face__back-title { margin: 0; font-size: 1.2rem; letter-spacing: -0.01em; }
.face__detail { margin: 0; font-size: 0.9rem; line-height: 1.5; color: rgb(255 255 255 / 0.9); }
.face__cta {
margin-top: 0.6rem; justify-self: start; text-decoration: none; font-weight: 600; font-size: 0.9rem;
padding: 0.55rem 1rem; border-radius: 999px; color: #fff;
background: rgb(255 255 255 / 0.16); border: 1px solid rgb(255 255 255 / 0.35);
backdrop-filter: blur(4px);
transition: background var(--dur-fast, 160ms) ease, transform var(--dur-fast, 160ms) var(--ease-spring, ease);
}
.face__cta:hover { background: rgb(255 255 255 / 0.28); transform: translateX(2px); }
/* per-card tints */
.tint-a { --t1: var(--spectrum-1, #8b5cf6); --t2: var(--spectrum-2, #5b8def); }
.tint-b { --t1: var(--spectrum-3, #45d3e8); --t2: var(--spectrum-4, #34d399); }
.tint-c { --t1: var(--spectrum-5, #fbbf24); --t2: var(--spectrum-6, #fb7185); }
@media (prefers-reduced-motion: reduce) {
.flip__inner { transition: none; transform: none !important; transform-style: flat; }
.face { transition: opacity var(--dur, 320ms) ease; }
.face--back { transform: none; opacity: 0; }
.flip__card:hover .face--back,
.flip__card:focus-within .face--back { opacity: 1; }
.flip__card:hover .face--front,
.flip__card:focus-within .face--front { opacity: 0; }
.face--front, .face--back { -webkit-backface-visibility: visible; backface-visibility: visible; }
}
</style>