# Flip Card

> 3D flip card that turns on hover, focus or click, is keyboard accessible and cross-fades under reduced motion.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/flip-card
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/flip-card.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/flip-card-demo.tsx"
import { Rocket, ShieldCheck } from "lucide-react"

import { FlipCard } from "@/components/flip-card"

export default function FlipCardDemo() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-6">
      <FlipCard
        label="Flip to read about fast deploys"
        front={
          <>
            <span className="mb-auto flex size-11 items-center justify-center rounded-xl bg-primary text-primary-foreground">
              <Rocket className="size-5" aria-hidden />
            </span>
            <span className="text-lg font-semibold">Fast deploys</span>{" "}
            <span className="mt-1 text-sm text-muted-foreground">Hover, focus or tap to flip</span>
          </>
        }
        back={
          <>
            <span className="text-lg font-semibold">Ship in seconds</span>{" "}
            <span className="mt-2 text-sm opacity-80">
              Push to main and your preview is live globally in under ten seconds, with instant rollbacks.
            </span>
          </>
        }
      />
      <FlipCard
        label="Flip to read about security"
        backClassName="bg-gradient-to-br from-emerald-500 to-teal-600 text-white"
        front={
          <>
            <span className="mb-auto flex size-11 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
              <ShieldCheck className="size-5" aria-hidden />
            </span>
            <span className="text-lg font-semibold">Secure by default</span>{" "}
            <span className="mt-1 text-sm text-muted-foreground">Press Enter or Space to flip</span>
          </>
        }
        back={
          <>
            <span className="text-lg font-semibold">Zero-trust access</span>{" "}
            <span className="mt-2 text-sm opacity-90">
              Every request is verified, encrypted in transit and logged for audit.
            </span>
          </>
        }
      />
    </div>
  )
}
```

## Source

```tsx title="components/flip-card.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface FlipCardProps
  extends Omit<React.ComponentProps<"button">, "children" | "onClick"> {
  front: React.ReactNode
  back: React.ReactNode
  /** Accessible name for the toggle button */
  label?: string
  frontClassName?: string
  backClassName?: string
}

/**
 * Hover (mouse) / focus-visible (keyboard) previews the back face; click, tap,
 * Enter or Space pins it open (aria-pressed). With prefers-reduced-motion the faces cross-fade
 * instead of rotating.
 */
export function FlipCard({
  front,
  back,
  label = "Flip card",
  frontClassName,
  backClassName,
  className,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: FlipCardProps) {
  const [pinned, setPinned] = React.useState(false)
  const [peek, setPeek] = React.useState(false)
  const flipped = pinned || peek
  const id = React.useId()

  return (
    <button
      type="button"
      aria-pressed={pinned}
      // Name lists both faces in DOM order so all visible text is part of the name (WCAG 2.5.3)
      aria-labelledby={`${id}-label ${id}-front ${id}-back`}
      data-flipped={flipped}
      onClick={() => setPinned((v) => !v)}
      onPointerEnter={(e) => {
        if (e.pointerType === "mouse") setPeek(true)
        onPointerEnter?.(e)
      }}
      onPointerLeave={(e) => {
        if (e.pointerType === "mouse") setPeek(false)
        onPointerLeave?.(e)
      }}
      onFocus={(e) => {
        if (e.currentTarget.matches(":focus-visible")) setPeek(true)
        onFocus?.(e)
      }}
      onBlur={(e) => {
        setPeek(false)
        onBlur?.(e)
      }}
      className={cn(
        "group relative block h-64 w-full max-w-xs rounded-2xl text-left outline-none [perspective:1000px]",
        "focus-visible:ring-4 focus-visible:ring-ring/40",
        className
      )}
      {...props}
    >
      <span id={`${id}-label`} className="sr-only">
        {label}
      </span>{" "}
      <span
        className={cn(
          "relative block size-full transition-transform duration-700 ease-out [transform-style:preserve-3d]",
          "motion-safe:group-data-[flipped=true]:[transform:rotateY(180deg)]"
        )}
      >
        <span
          id={`${id}-front`}
          aria-hidden={flipped}
          className={cn(
            "absolute inset-0 flex flex-col justify-end overflow-hidden rounded-2xl border bg-card p-6 text-card-foreground shadow-sm",
            "transition-opacity duration-500 motion-safe:[backface-visibility:hidden]",
            "motion-reduce:group-data-[flipped=true]:opacity-0",
            frontClassName
          )}
        >
          {front}
        </span>{" "}
        <span
          id={`${id}-back`}
          aria-hidden={!flipped}
          className={cn(
            "absolute inset-0 flex flex-col justify-center overflow-hidden rounded-2xl border bg-primary p-6 text-primary-foreground shadow-sm",
            "transition-opacity duration-500 motion-safe:[backface-visibility:hidden] motion-safe:[transform:rotateY(180deg)]",
            "motion-reduce:opacity-0 motion-reduce:group-data-[flipped=true]:opacity-100",
            backClassName
          )}
        >
          {back}
        </span>
      </span>
    </button>
  )
}
```
