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
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/flip-card.jsonPrompt
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-1makes 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/focusoutdrive the same state;relatedTargetfiltering keeps focus moves inside the card from flickering it. - Inert hidden face — the turned-away face is
aria-hiddenandinert, 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+onFlippedChangelet 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-motionthe rotation becomes an opacity swap and the perspective is dropped; the feature is intact, only the spinning is gone.