Display

Flip Card

A two-faced container that turns over on hover or click to swap a summary for its details.

Preview in your theme

Loading preview…

Registry dependencies
utils, use-prefers-reduced-motion
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

/** Rotation for the flipper, and the pre-rotation baked into the back face. */
const AXIS_ROTATION = {
  x: "rotate-x-180",
  y: "rotate-y-180",
} as const

export interface FlipCardProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Resting face. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/flip-card.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "FlipCard" component (no runtime
dependencies beyond React, the shared cn() utility and one shared hook).

Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — `usePrefersReducedMotion()` picks the 3D flip or the
  cross-fade. If rebuilding without it: a useSyncExternalStore over
  matchMedia("(prefers-reduced-motion: reduce)") with a "change" listener and a
  server snapshot of false.
- Export a forwardRef<HTMLDivElement, FlipCardProps> extending
  React.HTMLAttributes<HTMLDivElement>; spread the rest onto the root.
- Props: front and back (ReactNode, required), trigger = "hover" | "click"
  (default "hover"), axis = "x" | "y" (default "y"), duration = 600 (ms),
  flipButtonLabel?: string (click trigger only, see Behavior), and the
  controlled pair flipped?: boolean + onFlippedChange?: (b) => void.
- Uncontrolled by default: keep internal state, and treat `flipped !==
  undefined` as controlled. Notify onFlippedChange on every intended change
  in both modes, and skip the call when the value would not change (focusin
  can fire repeatedly).
- The faces are deliberately unstyled — pass fully formed cards as front /
  back. The component owns geometry, triggers and a11y, not the visuals.

Behavior
- trigger="hover": pointerenter/pointerleave flip it, and focusin/focusout
  do the same so keyboard users can reach the back at all; the root is
  tabbable for that reason. In the blur handler, ignore focusout events
  whose relatedTarget is still inside the card, otherwise moving focus
  between two children flickers the card back and forth.
- trigger="click" without flipButtonLabel: the root becomes role="button" +
  tabIndex + aria-pressed and toggles on click, Enter and Space
  (preventDefault on Space so the page does not scroll). The key handler
  only fires when event.target === event.currentTarget, so a descendant's
  Enter is never swallowed. This mode is only valid when neither face holds
  links, buttons or inputs: role="button" has presentational children, so
  screen readers flatten the card into one name and never expose an inner
  control (axe: nested-interactive) — the same content model as <button>.
- trigger="click" with flipButtonLabel: the root stays a plain container (no
  role, no tabIndex, no aria-pressed) and the first child is an sr-only
  <button type="button" aria-pressed data-flip-toggle> named by the label.
  It has no handler of its own: its click (Enter / Space included) bubbles to
  the root's onClick like a pointer click does. The root shows the focus ring
  via has-[[data-flip-toggle]:focus-visible]:ring-2, since an sr-only button
  cannot show its own. Use this whenever a face has interactive content.
- In both click modes clicks bubble by design (the whole face must be
  clickable), so an interactive child that should not also flip the card
  calls stopPropagation() in its own handler.
- Both faces live in the same CSS grid cell (col-start-1 row-start-1), so
  the card is exactly as tall as its tallest face and no fixed height has to
  be maintained.
- 3D mode: the flipper carries transform-3d plus a transition-transform of
  `duration`; the back face is pre-rotated 180° on the chosen axis and both
  faces are backface-hidden, so exactly one face ever paints.
- prefers-reduced-motion: swap the rotation for an opacity cross-fade of the
  same duration and drop the perspective entirely. Flipping still works —
  only the 3D motion is removed.
- The face that is turned away gets aria-hidden, `inert` and
  pointer-events-none the moment the state changes, so it can never be
  clicked or tabbed into while it is out of sight ("invisible but
  focusable" is the classic flip-card bug). The trade-off is honest: a
  screen reader only ever hears the face that is showing, which is why the
  root carries an sr-only line stating which face is up (and, in the
  whole-card-button and hover modes, how to flip it).

Rendering & styling
- Root: relative w-full, perspective-[1200px] (omitted under reduced
  motion), rounded-xl focus-visible:ring-2 focus-visible:ring-ring
  focus-visible:ring-offset-2, cursor-pointer only in click mode.
- Faces: col-start-1 row-start-1 with a *:h-full so both faces fill the
  shared cell no matter which one is taller.
- No color of its own beyond what the caller passes — semantic tokens live
  in the front / back nodes (bg-card, border, bg-muted for the reverse).
- Merge the consumer className through cn().

Customization levers
- Trigger: "hover" for marketing grids on desktop, "click" when the card
  must work on touch — a finger cannot hover, so hover mode is desktop-only
  by nature.
- axis="x" for a vertical tumble (reads well on wide, short cards),
  axis="y" for the classic left/right spin.
- duration: 400–500ms feels snappy, 700–900ms feels heavy and premium.
- Controlled mode: drive `flipped` from a parent to flip several cards at
  once, to reset a grid, or to put the trigger on a visible button of your
  own. When a face has its own buttons, pass flipButtonLabel (or trigger from
  such a visible button) so the card itself stops being one big toggle.
- Face design: any markup; give both faces the same padding and radius so
  the turn does not visibly resize the card, and keep them within ~1.5x of
  each other in height or the front face will look padded out.

Concepts

  • Two faces, one grid cell — stacking both faces at col-start-1 row-start-1 makes the card as tall as its tallest side, which is why this container never needs a hardcoded height.
  • Backface-hidden pairing — the flipper rotates, the back face is pre-rotated by the same 180°, and both faces hide their backside, so exactly one side ever paints and the two coplanar faces never fight.
  • Hover means hover-or-focus — a hover-only reveal is invisible to keyboard users, so focusin/focusout drive the same state; relatedTarget filtering keeps focus moves inside the card from flickering it.
  • Inert hidden face — the turned-away face is aria-hidden and inert, closing the "can't see it, can still Tab into it" hole that most CSS-only flip cards leave open.
  • No controls inside a button role — a card that is itself role="button" hides every link or button on its faces from screen readers, so a card with its own actions gets a dedicated sr-only toggle (flipButtonLabel) and stays a plain container.
  • Controlled flip — flipped + onFlippedChange let a parent own the state: flip a whole grid at once, reset on route change, or move the trigger onto a dedicated button when the back face has its own actions.
  • Reduced-motion cross-fade — under prefers-reduced-motion the rotation becomes an opacity swap and the perspective is dropped; the feature is intact, only the spinning is gone.

On This Page