Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 02:43:11Get Lifetime →
loopkit
freeNew101 lines · framer-motion

Card Flip

A short claim on the front, the mechanism on the back. A row of cards that flip in true 3D to reveal a second face, so a bold one-line claim up front can be backed by the actual explanation without crowding the layout.

Use it for
Any feature that benefits from a short headline plus a longer 'how it actually works' explanation, without resorting to an accordion or a tooltip.
Themes
Light + dark
License
Commercial use
Live

How it holds up

Flip any card for the mechanism

tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

function useReducedMotionSafe() {
  const prefersReduced = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  React.useEffect(() => setMounted(true), []);
  return mounted ? !!prefersReduced : false;
}

export interface FlipCardItem {
  frontTitle: string;
  frontBody: string;
  backTitle: string;
  backBody: string;
}

export interface CardFlipProps {
  eyebrow?: string;
  title?: string;
  cards?: FlipCardItem[];
  /** What flips the card over. */
  trigger?: "click" | "hover";
  className?: string;
}

const DEFAULT_CARDS: FlipCardItem[] = [
  { frontTitle: "Rate limits", frontBody: "Per-key budgets that fail soft, not loud.", backTitle: "How it works", backBody: "A token bucket refills per minute; past it, calls queue instead of erroring." },
  { frontTitle: "Retries", frontBody: "Flaky tools get a second try automatically.", backTitle: "How it works", backBody: "Exponential backoff with jitter, capped at three attempts, logged either way." },
  { frontTitle: "Fallbacks", frontBody: "One model down doesn't take the whole run down.", backTitle: "How it works", backBody: "A ranked list of providers; the router moves to the next on a timeout." },
];

function Flip({ card, trigger }: { card: FlipCardItem; trigger: "click" | "hover" }) {
  const reduce = useReducedMotionSafe();
  const [flipped, setFlipped] = React.useState(false);
  const hoverProps =
    trigger === "hover"
      ? {
          onHoverStart: () => setFlipped(true),
          onHoverEnd: () => setFlipped(false),
        }
      : {};

  return (
    <button
      type="button"
      aria-pressed={flipped}
      aria-label={flipped ? `${card.frontTitle}, showing details, activate to flip back` : `${card.frontTitle}, activate to flip for details`}
      onClick={trigger === "click" ? () => setFlipped((f) => !f) : undefined}
      {...hoverProps}
      className="relative block h-60 w-full cursor-pointer text-left [perspective:1400px] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
    >
      <motion.div
        className="relative h-full w-full [transform-style:preserve-3d]"
        animate={reduce ? undefined : { rotateY: flipped ? 180 : 0 }}
        transition={{ duration: 0.55, ease: [0.16, 1, 0.3, 1] }}
      >
        <div className="absolute inset-0 flex flex-col rounded-3xl border border-border bg-card p-6 [backface-visibility:hidden]" style={reduce ? { opacity: flipped ? 0 : 1, transition: "opacity .2s" } : undefined}>
          <h3 className="font-display text-xl font-bold tracking-tight text-foreground">{card.frontTitle}</h3>
          <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{card.frontBody}</p>
          <span className="mt-auto inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-wider text-signal">
            flip for details
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-3.5">
              <path d="M3 12a9 9 0 1 1 3 6.7M3 12V6m0 6h6" />
            </svg>
          </span>
        </div>
        <div
          className="absolute inset-0 flex flex-col rounded-3xl border border-foreground bg-primary p-6 text-primary-foreground [backface-visibility:hidden] [transform:rotateY(180deg)]"
          style={reduce ? { opacity: flipped ? 1 : 0, transition: "opacity .2s" } : undefined}
        >
          <p className="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-foreground/60">{card.backTitle}</p>
          <p className="mt-2 flex-1 text-sm leading-relaxed">{card.backBody}</p>
          <span className="font-mono text-[11px] uppercase tracking-wider text-primary-foreground/60">flip back</span>
        </div>
      </motion.div>
    </button>
  );
}

export function CardFlip({ eyebrow = "How it holds up", title = "Flip any card for the mechanism", cards = DEFAULT_CARDS, trigger = "click", className }: CardFlipProps) {
  return (
    <section className={cn("w-full bg-background px-4 py-20 sm:px-6 lg:px-10", className)}>
      <div className="mx-auto max-w-3xl text-center">
        <p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{eyebrow}</p>
        <h2 className="mt-3 font-display text-[clamp(2rem,5vw,3.5rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">{title}</h2>
      </div>
      <div className="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-5 sm:grid-cols-3">
        {cards.map((c) => (
          <Flip key={c.frontTitle} card={c} trigger={trigger} />
        ))}
      </div>
    </section>
  );
}

export default CardFlip;

How it behaves

  • →Each card is a real <button> with aria-pressed, rotating 180° on the Y axis using transform-style: preserve-3d and backface-visibility: hidden faces.
  • →trigger switches between flipping on click (default, keyboard-friendly) and flipping on hover.
  • →Under prefers-reduced-motion the 3D rotation is replaced with a plain opacity cross-fade between the two faces.

Accessibility

  • ✓Each card is a native button with a descriptive `aria-label` that states which face is currently showing and what activating it does.
  • ✓Click mode keeps the flip reachable by keyboard (Enter/Space); hover mode is an enhancement layered on top, not the only way to see the back face's content in click mode.
  • ✓Honours prefers-reduced-motion.