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.

cards/flip-card.astro
---
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>