9 CSS Card Flip Animations (Copy-Paste, 20 Lines)
A CSS card flip animation spins a card around its own center to reveal different content on the back, built with 3D transforms instead of any JavaScript
01 flip-y-hover
02 flip-x-click
03 fold-half
04 unfold-letter
05 flip-stack-shuffle
06 flip-book-page
07 flip-reveal-price
08 flip-cube-4-faces
09 flip-reduced-motion
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Flip on Y
- 02 Flip on X (click)
- 03 Fold in half
- 04 Unfold a letter
- 05 Shuffle a stack
- 06 Page turn
- 07 Reveal price card
- 08 Four-face cube
- 09 Reduced-motion variant
The order below runs from the simplest trigger to the most involved one, then closes on accessibility. It opens with a plain hover flip (01) and its click-triggered twin (02), moves through two paper-fold ideas that swap rotateY for rotateX (03 and 04), continues through patterns built around more than one card — a shuffling stack (05), a page turn (06), a pricing variant of the very first flip (07), and a four-face cube that needs real click state (08) — and ends on 09, which plays back that same flip the way a visitor with reduced motion turned on actually experiences it.
01Flip on Y
css flip card on hover, in one rule
Hovering the card spins it 180 degrees around a vertical axis so the back comes into view — nothing gets clicked, the card just turns to face away from the cursor.

<main class="stage">
<div class="fy-card is-demo">
<div class="fy-card__inner">
<div class="fy-card__face fy-card__face--front">FRONT</div>
<div class="fy-card__face fy-card__face--back">BACK</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.fy-card {
perspective: 700px;
width: 170px;
height: 108px;
}
.fy-card__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.fy-card:hover .fy-card__inner { transform: rotateY(180deg); }
.fy-card__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $r-card;
backface-visibility: hidden;
font: 700 15px/1 -apple-system, "Segoe UI", sans-serif;
letter-spacing: .06em;
color: $subject-cream;
}
.fy-card__face--front { background: $subject-blue-deep; }
.fy-card__face--back { background: rgba(255, 255, 255, .14); transform: rotateY(180deg); }
// 데모 루프: is-demo 는 같은 회전을 무한 재생 (캡처용) — :hover 실사용 경로와 별개
@keyframes fy-flip {
0%, 40% { transform: rotateY(0); }
60%, 100% { transform: rotateY(180deg); }
}
.fy-card.is-demo .fy-card__inner { animation: fy-flip $duration * 2 $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fy-card:hover .fy-card__inner,
.fy-card.is-demo .fy-card__inner { animation: none !important; transition: none !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fy-card {
perspective: 700px;
width: 170px;
height: 108px;
}
.fy-card__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fy-card:hover .fy-card__inner {
transform: rotateY(180deg);
}
.fy-card__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
backface-visibility: hidden;
font: 700 15px/1 -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.06em;
color: #fff7e6;
}
.fy-card__face--front {
background: #0a4fe8;
}
.fy-card__face--back {
background: rgba(255, 255, 255, 0.14);
transform: rotateY(180deg);
}
@keyframes fy-flip {
0%, 40% {
transform: rotateY(0);
}
60%, 100% {
transform: rotateY(180deg);
}
}
.fy-card.is-demo .fy-card__inner {
animation: fy-flip 1200ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.fy-card:hover .fy-card__inner,
.fy-card.is-demo .fy-card__inner {
animation: none !important;
transition: none !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./FlipYHover.module.scss";
export interface FlipYHoverProps {
duration?: string;
easing?: string;
front?: string;
back?: string;
}
export default function FlipYHover({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
front = "FRONT",
back = "BACK",
}: FlipYHoverProps) {
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<div className={styles.card} style={style}>
<div className={styles.inner}>
<div className={`${styles.face} ${styles.front}`}>{front}</div>
<div className={`${styles.face} ${styles.back}`}>{back}</div>
</div>
</div>
);
}01 · Flip on Y — Measured area 13.071% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
How the two faces stay hidden
A perspective wrapper holds an inner layer that rotates, and both faces hide their own reverse side so the two never show through each other mid-turn.
.fy-card { perspective: 700px; }
.fy-card__inner {
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.fy-card:hover .fy-card__inner { transform: rotateY(180deg); }
.fy-card__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.fy-card__face--back { transform: rotateY(180deg); }
02Flip on X (click)
css card flip on click, and why not hover
Clicking the card flips it top-to-bottom instead of side-to-side, and the flipped state sticks after the click — useful because hover never fires on a touchscreen, so this version needs an actual tap to work everywhere.

<main class="stage">
<button type="button" class="fx-card is-demo" aria-pressed="false">
<span class="fx-card__inner">
<span class="fx-card__face fx-card__face--front">Q</span>
<span class="fx-card__face fx-card__face--back">A</span>
</span>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.fx-card {
perspective: 700px;
width: 132px;
height: 132px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
display: block;
}
.fx-card:focus-visible { outline: 3px solid $subject-ink; outline-offset: $sp-1; }
.fx-card__inner {
position: relative;
display: block;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.fx-card.is-flipped .fx-card__inner { transform: rotateX(180deg); }
.fx-card__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $r-card;
backface-visibility: hidden;
font: 700 34px/1 -apple-system, "Segoe UI", sans-serif;
color: $subject-cream;
}
.fx-card__face--front { background: $color; }
.fx-card__face--back { background: rgba(23, 20, 26, .8); transform: rotateX(180deg); }
// 데모 루프: 실제 클릭 상태와 별개로 is-demo 가 같은 회전을 무한 재생
@keyframes fx-flip {
0%, 40% { transform: rotateX(0); }
60%, 100% { transform: rotateX(180deg); }
}
.fx-card.is-demo .fx-card__inner { animation: fx-flip $duration * 2 $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fx-card.is-flipped .fx-card__inner,
.fx-card.is-demo .fx-card__inner { animation: none !important; transition: none !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fx-card {
perspective: 700px;
width: 132px;
height: 132px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
display: block;
}
.fx-card:focus-visible {
outline: 3px solid #17141a;
outline-offset: 4px;
}
.fx-card__inner {
position: relative;
display: block;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fx-card.is-flipped .fx-card__inner {
transform: rotateX(180deg);
}
.fx-card__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
backface-visibility: hidden;
font: 700 34px/1 -apple-system, "Segoe UI", sans-serif;
color: #fff7e6;
}
.fx-card__face--front {
background: #17141a;
}
.fx-card__face--back {
background: rgba(23, 20, 26, 0.8);
transform: rotateX(180deg);
}
@keyframes fx-flip {
0%, 40% {
transform: rotateX(0);
}
60%, 100% {
transform: rotateX(180deg);
}
}
.fx-card.is-demo .fx-card__inner {
animation: fx-flip 1200ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.fx-card.is-flipped .fx-card__inner,
.fx-card.is-demo .fx-card__inner {
animation: none !important;
transition: none !important;
transform: none !important;
}
}var btn = document.querySelector(".fx-card");
btn.addEventListener("click", function () {
var open = btn.getAttribute("aria-pressed") === "true";
btn.setAttribute("aria-pressed", String(!open));
btn.classList.toggle("is-flipped", !open);
});import { CSSProperties, useState } from "react";
import styles from "./FlipXClick.module.scss";
export interface FlipXClickProps {
duration?: string;
easing?: string;
front?: string;
back?: string;
}
export default function FlipXClick({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
front = "Q",
back = "A",
}: FlipXClickProps) {
const [open, setOpen] = useState(false);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<button
type="button"
className={`${styles.card} ${open ? styles.flipped : ""}`}
style={style}
aria-pressed={open}
onClick={() => setOpen((v) => !v)}
>
<span className={styles.inner}>
<span className={`${styles.face} ${styles.front}`}>{front}</span>
<span className={`${styles.face} ${styles.back}`}>{back}</span>
</span>
</button>
);
}02 · Flip on X (click) — Measured area 12.343% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Six lines of state
Six lines of script toggle aria-pressed and a class on every click, since CSS alone can't remember which face was last shown.
var btn = document.querySelector(".fx-card");
btn.addEventListener("click", function () {
var open = btn.getAttribute("aria-pressed") === "true";
btn.setAttribute("aria-pressed", String(!open));
btn.classList.toggle("is-flipped", !open);
});
03Fold in half
Only the right half swings
Instead of turning the whole card over, only the right half rotates: it swings shut on a hinge planted at the card's own center line, so the card folds shut like a piece of paper, rather than spinning in place.

<main class="stage">
<div class="fh-card is-demo">
<div class="fh-half fh-half--static">MENU</div>
<div class="fh-half fh-half--fold">
<div class="fh-half__face fh-half__face--front">MENU</div>
<div class="fh-half__face fh-half__face--back"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.fh-card {
position: relative;
display: flex;
width: 176px;
height: 108px;
perspective: 900px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.fh-half {
width: 50%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: $subject-blue-deep;
color: $subject-cream;
font: 700 12px/1.3 -apple-system, "Segoe UI", sans-serif;
letter-spacing: .08em;
}
.fh-half--fold {
position: relative;
transform-style: preserve-3d;
transform-origin: left center; // 규칙8: 왼쪽 변 경첩 접힘
transition: transform $duration $easing;
background: none;
}
.fh-half__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
backface-visibility: hidden;
font: 700 12px/1.3 -apple-system, "Segoe UI", sans-serif;
letter-spacing: .08em;
color: $subject-cream;
}
.fh-half__face--front { background: $subject-blue-deep; }
.fh-half__face--back { background: rgba(23, 20, 26, .78); transform: rotateY(180deg); }
// 데모 루프: 오른쪽 절반이 왼쪽 절반 위로 접힌다
@keyframes fh-fold {
0% { transform: rotateY(0); }
50% { transform: rotateY(-172deg); }
100% { transform: rotateY(0); }
}
.fh-card.is-demo .fh-half--fold { animation: fh-fold $duration * 2.4 $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.fh-card.is-demo .fh-half--fold { animation: none !important; transition: none !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fh-card {
position: relative;
display: flex;
width: 176px;
height: 108px;
perspective: 900px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.fh-half {
width: 50%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #0a4fe8;
color: #fff7e6;
font: 700 12px/1.3 -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.08em;
}
.fh-half--fold {
position: relative;
transform-style: preserve-3d;
transform-origin: left center;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
background: none;
}
.fh-half__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
backface-visibility: hidden;
font: 700 12px/1.3 -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.08em;
color: #fff7e6;
}
.fh-half__face--front {
background: #0a4fe8;
}
.fh-half__face--back {
background: rgba(23, 20, 26, 0.78);
transform: rotateY(180deg);
}
@keyframes fh-fold {
0% {
transform: rotateY(0);
}
50% {
transform: rotateY(-172deg);
}
100% {
transform: rotateY(0);
}
}
.fh-card.is-demo .fh-half--fold {
animation: fh-fold 1440ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.fh-card.is-demo .fh-half--fold {
animation: none !important;
transition: none !important;
transform: none !important;
}
}import { CSSProperties, useState } from "react";
import styles from "./FoldHalf.module.scss";
export interface FoldHalfProps {
duration?: string;
easing?: string;
label?: string;
}
export default function FoldHalf({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
label = "MENU",
}: FoldHalfProps) {
const [folded, setFolded] = useState(false);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<div
className={styles.card}
style={style}
role="button"
tabIndex={0}
aria-pressed={folded}
onClick={() => setFolded((v) => !v)}
onKeyDown={(e: { key: string }) => (e.key === "Enter" || e.key === " ") && setFolded((v) => !v)}
>
<div className={styles.static}>{label}</div>
<div className={`${styles.fold} ${folded ? styles.closed : ""}`}>
<div className={`${styles.face} ${styles.front}`}>{label}</div>
<div className={`${styles.face} ${styles.back}`} />
</div>
</div>
);
}03 · Fold in half — Measured area 13.074% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
The spine that never moves
The left half never moves — it's the fixed spine the right half folds against.
.fh-half--fold {
transform-style: preserve-3d;
transform-origin: left center;
transition: transform $duration $easing;
}
@keyframes fh-fold {
0% { transform: rotateY(0); }
50% { transform: rotateY(-172deg); }
100% { transform: rotateY(0); }
}
04Unfold a letter
Two flaps, two hinges
A folded card opens like a letter: the top half swings up and the bottom half swings down, each hinged at its own outer edge.

<main class="stage">
<div class="ul-scene is-demo">
<div class="ul-letter">YOU'RE<br>INVITED</div>
<div class="ul-flap ul-flap--top"></div>
<div class="ul-flap ul-flap--bottom"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.ul-scene {
position: relative;
width: 172px;
height: 112px;
perspective: 900px;
border-radius: $r-card;
box-shadow: $shadow-raised;
}
.ul-letter {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
border-radius: $r-card;
background: $subject-cream;
color: $color;
font: 700 13px/1.35 -apple-system, "Segoe UI", sans-serif;
letter-spacing: .04em;
}
.ul-flap {
position: absolute;
left: 0;
width: 100%;
height: 50%;
background: $color;
border-radius: $r-card $r-card 0 0;
transform-style: preserve-3d;
}
.ul-flap--top { top: 0; transform-origin: top center; } // 규칙8: 봉투 플랩 상변 경첩
.ul-flap--bottom { bottom: 0; border-radius: 0 0 $r-card $r-card; transform-origin: bottom center; } // 규칙8: 봉투 플랩 하변 경첩
// 데모 루프: 위 flap 먼저, 아래 flap 이 시차(transition-delay)를 두고 뒤따라 펴진다
@keyframes ul-open-top {
0%, 25% { transform: rotateX(0); }
60%, 100% { transform: rotateX(-172deg); }
}
@keyframes ul-open-bottom {
0%, 40% { transform: rotateX(0); }
75%, 100% { transform: rotateX(172deg); }
}
.ul-scene.is-demo .ul-flap--top { animation: ul-open-top $duration * 2.6 $easing infinite; }
.ul-scene.is-demo .ul-flap--bottom { animation: ul-open-bottom $duration * 2.6 $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.ul-scene.is-demo .ul-flap--top,
.ul-scene.is-demo .ul-flap--bottom { animation: none !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ul-scene {
position: relative;
width: 172px;
height: 112px;
perspective: 900px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ul-letter {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 16px;
background: #fff7e6;
color: #2f6df6;
font: 700 13px/1.35 -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.04em;
}
.ul-flap {
position: absolute;
left: 0;
width: 100%;
height: 50%;
background: #2f6df6;
border-radius: 16px 16px 0 0;
transform-style: preserve-3d;
}
.ul-flap--top {
top: 0;
transform-origin: top center;
}
.ul-flap--bottom {
bottom: 0;
border-radius: 0 0 16px 16px;
transform-origin: bottom center;
}
@keyframes ul-open-top {
0%, 25% {
transform: rotateX(0);
}
60%, 100% {
transform: rotateX(-172deg);
}
}
@keyframes ul-open-bottom {
0%, 40% {
transform: rotateX(0);
}
75%, 100% {
transform: rotateX(172deg);
}
}
.ul-scene.is-demo .ul-flap--top {
animation: ul-open-top 1560ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ul-scene.is-demo .ul-flap--bottom {
animation: ul-open-bottom 1560ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.ul-scene.is-demo .ul-flap--top,
.ul-scene.is-demo .ul-flap--bottom {
animation: none !important;
transform: none !important;
}
}import { CSSProperties, useState } from "react";
import styles from "./UnfoldLetter.module.scss";
export interface UnfoldLetterProps {
duration?: string;
easing?: string;
message?: string;
}
export default function UnfoldLetter({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
message = "YOU'RE INVITED",
}: UnfoldLetterProps) {
const [open, setOpen] = useState(false);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<div
className={styles.scene}
style={style}
role="button"
tabIndex={0}
aria-pressed={open}
onClick={() => setOpen((v) => !v)}
onKeyDown={(e: { key: string }) => (e.key === "Enter" || e.key === " ") && setOpen((v) => !v)}
>
<div className={styles.letter}>{message}</div>
<div className={`${styles.flap} ${styles.top} ${open ? styles.openTop : ""}`} />
<div className={`${styles.flap} ${styles.bottom} ${open ? styles.openBottom : ""}`} />
</div>
);
}04 · Unfold a letter — Measured area 26.174% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the bottom waits
The bottom flap's motion doesn't start until the top flap is already most of the way open, so the two edges read as two separate steps instead of one symmetric spread.
.ul-flap--top { transform-origin: top center; }
.ul-flap--bottom { transform-origin: bottom center; }
@keyframes ul-open-top {
0%, 25% { transform: rotateX(0); }
60%, 100% { transform: rotateX(-172deg); }
}
@keyframes ul-open-bottom {
0%, 40% { transform: rotateX(0); }
75%, 100% { transform: rotateX(172deg); }
}
05Shuffle a stack
The top card alone moves
Three cards sit stacked with a slight depth offset, and the top card alone flips and slides back to reveal the card underneath, then returns to the front of the pile.

<main class="stage">
<div class="ss-stack is-demo">
<div class="ss-card ss-card--3">★★★☆☆</div>
<div class="ss-card ss-card--2">★★★★☆</div>
<div class="ss-card ss-card--1">★★★★★</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.ss-stack {
position: relative;
width: 150px;
height: 92px;
perspective: 700px;
}
.ss-card {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $r-card;
background: $color;
color: $subject-ink;
font: 700 16px/1 -apple-system, "Segoe UI", sans-serif;
box-shadow: $shadow-raised;
transform-style: preserve-3d;
}
// 아래 두 장은 살짝 물러나 쌓여 보인다 (translateZ + translateY)
.ss-card--2 { transform: translateZ(-16px) translateY($sp-2); opacity: .85; }
.ss-card--3 { transform: translateZ(-32px) translateY($sp-4); opacity: .65; }
// 데모 루프: 맨 위 카드(1번, z-index 최상)가 뒤집히며 물러나 다음 카드를 드러낸다
@keyframes ss-shuffle {
0%, 30% { transform: translateZ(0) translateX(0) rotateY(0); opacity: 1; }
70% { transform: translateZ(-8px) translateX(-46px) rotateY(-150deg); opacity: .25; }
71%, 100% { transform: translateZ(0) translateX(0) rotateY(0); opacity: 1; }
}
.ss-stack.is-demo .ss-card--1 { z-index: 3; animation: ss-shuffle ($duration * 6) $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.ss-stack.is-demo .ss-card--1 { animation: none !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ss-stack {
position: relative;
width: 150px;
height: 92px;
perspective: 700px;
}
.ss-card {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font: 700 16px/1 -apple-system, "Segoe UI", sans-serif;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-style: preserve-3d;
}
.ss-card--2 {
transform: translateZ(-16px) translateY(8px);
opacity: 0.85;
}
.ss-card--3 {
transform: translateZ(-32px) translateY(16px);
opacity: 0.65;
}
@keyframes ss-shuffle {
0%, 30% {
transform: translateZ(0) translateX(0) rotateY(0);
opacity: 1;
}
70% {
transform: translateZ(-8px) translateX(-46px) rotateY(-150deg);
opacity: 0.25;
}
71%, 100% {
transform: translateZ(0) translateX(0) rotateY(0);
opacity: 1;
}
}
.ss-stack.is-demo .ss-card--1 {
z-index: 3;
animation: ss-shuffle 1800ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.ss-stack.is-demo .ss-card--1 {
animation: none !important;
transform: none !important;
}
}import { CSSProperties, useState } from "react";
import styles from "./StackShuffle.module.scss";
export interface StackShuffleProps {
duration?: string;
easing?: string;
cards?: string[];
}
export default function StackShuffle({
duration = "300ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
cards = ["★★★★★", "★★★★☆", "★★★☆☆"],
}: StackShuffleProps) {
const [order, setOrder] = useState(cards);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
const shuffle = () => setOrder((o) => [...o.slice(1), o[0]]);
return (
<div className={styles.stack} style={style} onClick={shuffle}>
{order.map((text, i) => (
<div
key={text}
className={`${styles.card} ${i === 0 ? styles.top : ""}`}
style={{ transform: `translateZ(${-16 * i}px) translateY(${8 * i}px)`, opacity: 1 - i * 0.18 }}
>
{text}
</div>
))}
</div>
);
}05 · Shuffle a stack — Measured area 10.424% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the pile reads as a deck
The two resting cards never animate — they're just pushed back with translateZ and a touch of transparency so the pile reads as a deck rather than one flat card.
.ss-card--2 { transform: translateZ(-16px) translateY($sp-2); opacity: .85; }
.ss-card--3 { transform: translateZ(-32px) translateY($sp-4); opacity: .65; }
@keyframes ss-shuffle {
0%, 30% { transform: translateZ(0) translateX(0) rotateY(0); opacity: 1; }
70% { transform: translateZ(-8px) translateX(-46px) rotateY(-150deg); opacity: .25; }
71%, 100% { transform: translateZ(0) translateX(0) rotateY(0); opacity: 1; }
}
06Page turn
Turning on the right edge
The card rotates around its right edge the way a book page lifts and turns, and its shadow grows on the same schedule as the rotation so the motion sells a page lifting off the one underneath rather than a flat card spinning in place.

<main class="stage">
<div class="bp-book is-demo">
<div class="bp-page-under">02</div>
<div class="bp-page">
<div class="bp-page__face bp-page__face--front">01</div>
<div class="bp-page__face bp-page__face--back">01</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-yellow);
.bp-book {
position: relative;
width: 150px;
height: 108px;
perspective: 900px;
}
.bp-page-under,
.bp-page__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $r-card;
font: 700 22px/1 -apple-system, "Segoe UI", sans-serif;
color: $subject-ink;
}
.bp-page-under { background: $color; box-shadow: $shadow-press; }
.bp-page {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: right center; // 규칙8: 오른쪽 책등 경첩 넘김
transition: transform $duration $easing, box-shadow $duration $easing;
border-radius: $r-card;
box-shadow: $shadow-raised;
}
.bp-page__face { backface-visibility: hidden; }
.bp-page__face--front { background: $color; }
.bp-page__face--back { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: rotateY(180deg); }
// 데모 루프: 오른쪽 끝을 축으로 페이지가 넘어간다
@keyframes bp-turn {
0%, 30% { transform: rotateY(0); }
70%, 100% { transform: rotateY(-155deg); }
}
.bp-book.is-demo .bp-page { animation: bp-turn $duration * 2.6 $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.bp-book.is-demo .bp-page { animation: none !important; transition: none !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp-book {
position: relative;
width: 150px;
height: 108px;
perspective: 900px;
}
.bp-page-under,
.bp-page__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
font: 700 22px/1 -apple-system, "Segoe UI", sans-serif;
color: #17141a;
}
.bp-page-under {
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.bp-page {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: right center;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.bp-page__face {
backface-visibility: hidden;
}
.bp-page__face--front {
background: #fff7e6;
}
.bp-page__face--back {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: rotateY(180deg);
}
@keyframes bp-turn {
0%, 30% {
transform: rotateY(0);
}
70%, 100% {
transform: rotateY(-155deg);
}
}
.bp-book.is-demo .bp-page {
animation: bp-turn 1560ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.bp-book.is-demo .bp-page {
animation: none !important;
transition: none !important;
transform: none !important;
}
}import { CSSProperties, useState } from "react";
import styles from "./PageTurn.module.scss";
export interface PageTurnProps {
duration?: string;
easing?: string;
current?: string;
next?: string;
}
export default function PageTurn({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
current = "01",
next = "02",
}: PageTurnProps) {
const [turned, setTurned] = useState(false);
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<div
className={styles.book}
style={style}
role="button"
tabIndex={0}
aria-pressed={turned}
onClick={() => setTurned((v) => !v)}
onKeyDown={(e: { key: string }) => (e.key === "Enter" || e.key === " ") && setTurned((v) => !v)}
>
<div className={styles.under}>{next}</div>
<div className={`${styles.page} ${turned ? styles.turned : ""}`}>
<div className={`${styles.face} ${styles.front}`}>{current}</div>
<div className={`${styles.face} ${styles.back}`}>{current}</div>
</div>
</div>
);
}06 · Page turn — Measured area 16.329% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
A shadow on the same schedule
A second, completely static card sits behind it as the "next page."
.bp-page {
transform-style: preserve-3d;
transform-origin: right center;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.bp-page__face { backface-visibility: hidden; }
.bp-page__face--back { transform: rotateY(180deg); }
@keyframes bp-turn {
0%, 30% { transform: rotateY(0); }
70%, 100% { transform: rotateY(-155deg); }
}
07Reveal a price card
Item 01 on a pricing stage
This is the same hover flip as item 01 on a different stage: the front carries a plain price, and flipping over shows a second line underneath it — room for a discount or a condition that wouldn't fit next to the front price.

<main class="stage">
<div class="rp-card is-demo">
<div class="rp-card__inner">
<div class="rp-card__face rp-card__face--front">
<span class="rp-price">$49</span>
<span class="rp-label">List price</span>
</div>
<div class="rp-card__face rp-card__face--back">
<span class="rp-price">$29</span>
<span class="rp-label">Today only</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick * 1.5;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.rp-card {
perspective: 700px;
width: 150px;
height: 100px;
}
.rp-card__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.rp-card:hover .rp-card__inner { transform: rotateY(180deg); }
.rp-card__face {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
border-radius: $r-card;
backface-visibility: hidden;
box-shadow: $shadow-raised;
}
.rp-card__face--front { background: $subject-cream; }
.rp-card__face--back { background: $subject-blue-deep; transform: rotateY(180deg); }
.rp-price { font: 700 24px/1 -apple-system, "Segoe UI", sans-serif; color: $subject-ink; }
.rp-card__face--back .rp-price { color: $subject-cream; }
.rp-label { font: 600 11px/1 -apple-system, sans-serif; opacity: .6; color: $subject-ink; }
.rp-card__face--back .rp-label { color: $subject-cream; opacity: .8; }
// 데모 루프
@keyframes rp-flip {
0%, 40% { transform: rotateY(0); }
60%, 100% { transform: rotateY(180deg); }
}
.rp-card.is-demo .rp-card__inner { animation: rp-flip $duration * 2.6 $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.rp-card:hover .rp-card__inner,
.rp-card.is-demo .rp-card__inner { animation: none !important; transition: none !important; transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rp-card {
perspective: 700px;
width: 150px;
height: 100px;
}
.rp-card__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 330ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rp-card:hover .rp-card__inner {
transform: rotateY(180deg);
}
.rp-card__face {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border-radius: 16px;
backface-visibility: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rp-card__face--front {
background: #fff7e6;
}
.rp-card__face--back {
background: #0a4fe8;
transform: rotateY(180deg);
}
.rp-price {
font: 700 24px/1 -apple-system, "Segoe UI", sans-serif;
color: #17141a;
}
.rp-card__face--back .rp-price {
color: #fff7e6;
}
.rp-label {
font: 600 11px/1 -apple-system, sans-serif;
opacity: 0.6;
color: #17141a;
}
.rp-card__face--back .rp-label {
color: #fff7e6;
opacity: 0.8;
}
@keyframes rp-flip {
0%, 40% {
transform: rotateY(0);
}
60%, 100% {
transform: rotateY(180deg);
}
}
.rp-card.is-demo .rp-card__inner {
animation: rp-flip 858ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.rp-card:hover .rp-card__inner,
.rp-card.is-demo .rp-card__inner {
animation: none !important;
transition: none !important;
transform: none !important;
}
}import { CSSProperties } from "react";
import styles from "./RevealPriceCard.module.scss";
export interface RevealPriceCardProps {
duration?: string;
easing?: string;
listPrice?: string;
salePrice?: string;
saleLabel?: string;
}
export default function RevealPriceCard({
duration = "330ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
listPrice = "$49",
salePrice = "$29",
saleLabel = "Today only",
}: RevealPriceCardProps) {
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<div className={styles.card} style={style}>
<div className={styles.inner}>
<div className={`${styles.face} ${styles.front}`}>
<span className={styles.price}>{listPrice}</span>
<span className={styles.label}>List price</span>
</div>
<div className={`${styles.face} ${styles.back}`}>
<span className={styles.price}>{salePrice}</span>
<span className={styles.label}>{saleLabel}</span>
</div>
</div>
</div>
);
}07 · Reveal price card — Measured area 12.062% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
Two numbers, one card
Reusing the rotate-and-hide pattern keeps two numbers on one card without adding a second element anywhere on the page.
.rp-card__face {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
backface-visibility: hidden;
}
.rp-price { font: 700 24px/1 -apple-system, "Segoe UI", sans-serif; }
.rp-label { font: 600 11px/1 -apple-system, sans-serif; opacity: .6; }
08Four-face cube
Four faces inside a cube
The card has four faces glued to the inside of a cube, and each click turns it another quarter-turn to the next one — an image, a description, a price, and a call to action can each get their own face instead of competing for the same spot.

<main class="stage">
<div class="cb-scene">
<button type="button" class="cb-cube is-demo" aria-label="Show next face">
<span class="cb-face cb-face--1">Photo</span>
<span class="cb-face cb-face--2">Details</span>
<span class="cb-face cb-face--3">$29</span>
<span class="cb-face cb-face--4">Buy</span>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$cb-size: 110px;
$cb-half: 55px;
.cb-scene { perspective: 700px; }
.cb-cube {
--cb-step: 0;
position: relative;
width: $cb-size;
height: $cb-size;
border: 0;
padding: 0;
background: none;
cursor: pointer;
transform-style: preserve-3d;
transform: rotateY(calc(var(--cb-step) * -90deg));
transition: transform $duration $easing;
}
.cb-cube:focus-visible { outline: 3px solid $subject-ink; outline-offset: $sp-2; }
.cb-face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $r-card;
backface-visibility: hidden;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
color: $subject-cream;
}
.cb-face--1 { background: $subject-blue-deep; transform: rotateY(0deg) translateZ(#{$cb-half}); }
.cb-face--2 { background: rgba(23, 20, 26, .82); transform: rotateY(90deg) translateZ(#{$cb-half}); }
.cb-face--3 { background: $subject-blue-deep; transform: rotateY(180deg) translateZ(#{$cb-half}); }
.cb-face--4 { background: rgba(23, 20, 26, .82); transform: rotateY(270deg) translateZ(#{$cb-half}); }
// 데모 루프: 클릭 대신 네 면을 순서대로 자동 회전
@keyframes cb-spin {
0%, 15% { transform: rotateY(0); }
30%, 40% { transform: rotateY(-90deg); }
55%, 65% { transform: rotateY(-180deg); }
80%, 90% { transform: rotateY(-270deg); }
100% { transform: rotateY(-360deg); }
}
.cb-cube.is-demo { animation: cb-spin ($duration * 5) $easing infinite; }
@media (prefers-reduced-motion: reduce) {
.cb-cube { animation: none !important; transition: none !important; }
.cb-cube.is-demo { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb-scene {
perspective: 700px;
}
.cb-cube {
--cb-step: 0;
position: relative;
width: 110px;
height: 110px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
transform-style: preserve-3d;
transform: rotateY(calc(var(--cb-step) * -90deg));
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cb-cube:focus-visible {
outline: 3px solid #17141a;
outline-offset: 8px;
}
.cb-face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
backface-visibility: hidden;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
color: #fff7e6;
}
.cb-face--1 {
background: #0a4fe8;
transform: rotateY(0deg) translateZ(55px);
}
.cb-face--2 {
background: rgba(23, 20, 26, 0.82);
transform: rotateY(90deg) translateZ(55px);
}
.cb-face--3 {
background: #0a4fe8;
transform: rotateY(180deg) translateZ(55px);
}
.cb-face--4 {
background: rgba(23, 20, 26, 0.82);
transform: rotateY(270deg) translateZ(55px);
}
@keyframes cb-spin {
0%, 15% {
transform: rotateY(0);
}
30%, 40% {
transform: rotateY(-90deg);
}
55%, 65% {
transform: rotateY(-180deg);
}
80%, 90% {
transform: rotateY(-270deg);
}
100% {
transform: rotateY(-360deg);
}
}
.cb-cube.is-demo {
animation: cb-spin 3000ms cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.cb-cube {
animation: none !important;
transition: none !important;
}
.cb-cube.is-demo {
animation: none !important;
}
}var cube = document.querySelector(".cb-cube");
var step = 0;
cube.addEventListener("click", function () {
step = (step + 1) % 4;
cube.style.setProperty("--cb-step", String(step));
});import { CSSProperties, useState } from "react";
import styles from "./CubeFourFaces.module.scss";
export interface CubeFourFacesProps {
duration?: string;
easing?: string;
faces?: [string, string, string, string];
}
export default function CubeFourFaces({
duration = "600ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
faces = ["Photo", "Details", "$29", "Buy"],
}: CubeFourFacesProps) {
const [step, setStep] = useState(0);
const style = { "--duration": duration, "--easing": easing, "--step": step } as CSSProperties;
return (
<div className={styles.scene}>
<button
type="button"
className={styles.cube}
style={style}
aria-label="Show next face"
onClick={() => setStep((s) => s + 1)}
>
{faces.map((f, i) => (
<span key={f} className={`${styles.face} ${styles["face" + i]}`}>{f}</span>
))}
</button>
</div>
);
}08 · Four-face cube — Measured area 12.789% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
A custom property counts turns
A CSS custom property tracks which face is showing, and a handful of script lines bump it by one on every click.
var cube = document.querySelector(".cb-cube");
var step = 0;
cube.addEventListener("click", function () {
step = (step + 1) % 4;
cube.style.setProperty("--cb-step", String(step));
});
09Reduced-motion variant
The same flip, reduced
This item plays back the exact flip from item 01, but captured with the system's reduced-motion setting turned on — so what shows here isn't a rotation at all, it's a soft cross-fade between the front and back faces.

<main class="stage">
<div class="rm-card is-demo">
<div class="rm-card__inner">
<div class="rm-card__face rm-card__face--front">FRONT</div>
<div class="rm-card__face rm-card__face--back">BACK</div>
</div>
<span class="rm-tag">reduced-motion</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.rm-card {
position: relative;
perspective: 700px;
width: 170px;
height: 108px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.rm-card__inner {
position: relative;
width: 170px;
height: 92px;
transform-style: preserve-3d;
}
// 실제 사용 경로: 평소엔 3D 회전, reduced-motion 이 켜지면 회전을 끄고 교차 페이드만 남긴다 (BD5)
.rm-card:hover .rm-card__inner { transform: rotateY(180deg); transition: transform $dur-enter $ease-spring; }
.rm-card__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $r-card;
backface-visibility: hidden;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
color: $subject-cream;
}
.rm-card__face--front { background: $subject-blue-deep; }
.rm-card__face--back { background: rgba(255, 255, 255, .14); transform: rotateY(180deg); }
.rm-tag {
font: 600 10px/1 -apple-system, sans-serif;
letter-spacing: .05em;
color: $subject-cream;
opacity: .6;
}
// 데모 루프: 이 항목은 reduced-motion 결과 자체를 보여준다 — 회전 대신 opacity 교차 페이드만 무한 재생
@keyframes rm-crossfade-front { 0% { opacity: 1; } 50% { opacity: 0; } 100% { opacity: 1; } }
@keyframes rm-crossfade-back { 0% { opacity: 0; } 50% { opacity: 1; } 100% { opacity: 0; } }
.rm-card.is-demo .rm-card__inner { transform: none; }
.rm-card.is-demo .rm-card__face--front { animation: rm-crossfade-front ($duration * 2.6) $easing infinite; backface-visibility: visible; transform: none; }
.rm-card.is-demo .rm-card__face--back { animation: rm-crossfade-back ($duration * 2.6) $easing infinite; backface-visibility: visible; transform: none; }
@media (prefers-reduced-motion: reduce) {
.rm-card:hover .rm-card__inner { transform: none; transition: none; }
.rm-card:hover .rm-card__face--front { opacity: 0; transition: opacity $dur-base $ease-out; }
.rm-card:hover .rm-card__face--back { opacity: 1; transform: none; transition: opacity $dur-base $ease-out; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rm-card {
position: relative;
perspective: 700px;
width: 170px;
height: 108px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.rm-card__inner {
position: relative;
width: 170px;
height: 92px;
transform-style: preserve-3d;
}
.rm-card:hover .rm-card__inner {
transform: rotateY(180deg);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rm-card__face {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
backface-visibility: hidden;
font: 700 14px/1 -apple-system, "Segoe UI", sans-serif;
color: #fff7e6;
}
.rm-card__face--front {
background: #0a4fe8;
}
.rm-card__face--back {
background: rgba(255, 255, 255, 0.14);
transform: rotateY(180deg);
}
.rm-tag {
font: 600 10px/1 -apple-system, sans-serif;
letter-spacing: 0.05em;
color: #fff7e6;
opacity: 0.6;
}
@keyframes rm-crossfade-front {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes rm-crossfade-back {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.rm-card.is-demo .rm-card__inner {
transform: none;
}
.rm-card.is-demo .rm-card__face--front {
animation: rm-crossfade-front 1560ms ease-out infinite;
backface-visibility: visible;
transform: none;
}
.rm-card.is-demo .rm-card__face--back {
animation: rm-crossfade-back 1560ms ease-out infinite;
backface-visibility: visible;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.rm-card:hover .rm-card__inner {
transform: none;
transition: none;
}
.rm-card:hover .rm-card__face--front {
opacity: 0;
transition: opacity 300ms ease-out;
}
.rm-card:hover .rm-card__face--back {
opacity: 1;
transform: none;
transition: opacity 300ms ease-out;
}
}import { CSSProperties } from "react";
import styles from "./ReducedMotionFlip.module.scss";
export interface ReducedMotionFlipProps {
duration?: string;
easing?: string;
front?: string;
back?: string;
}
// prefers-reduced-motion 이 켜진 사용자는 3D 회전 대신 opacity 교차 페이드만 받는다 (CSS @media 블록, JS 분기 없음)
export default function ReducedMotionFlip({
duration = "600ms",
easing = "ease-out",
front = "FRONT",
back = "BACK",
}: ReducedMotionFlipProps) {
const style = { "--duration": duration, "--easing": easing } as CSSProperties;
return (
<div className={styles.card} style={style}>
<div className={styles.inner}>
<div className={`${styles.face} ${styles.front}`}>{front}</div>
<div className={`${styles.face} ${styles.back}`}>{back}</div>
</div>
<span className={styles.tag}>reduced-motion</span>
</div>
);
}09 · Reduced-motion variant — Measured area 10.497% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
What the production rule swaps
The moment prefers-reduced-motion: reduce is detected, the production rule swaps transform: rotateY(180deg) for two opposite opacity transitions instead.
@media (prefers-reduced-motion: reduce) {
.rm-card:hover .rm-card__inner { transform: none; transition: none; }
.rm-card:hover .rm-card__face--front {
opacity: 0;
transition: opacity $dur-base $ease-out;
}
.rm-card:hover .rm-card__face--back {
opacity: 1;
transform: none;
transition: opacity $dur-base $ease-out;
}
}
Where does this break?
The sliver near 90 degrees
Item 03's fold looks solid until the card nears a 90-degree turn. backface-visibility: hidden only removes a face once it has rotated past 90 degrees — for every degree before that, the browser keeps painting the face at its real, extremely foreshortened angle. Catch a frame near the middle of the fold and the front face's lettering is still there, compressed into a thin sliver, a moment before it disappears a frame later.
Why translateZ must be exact
Item 08 breaks differently: its four faces are glued onto the cube with translateZ, and that value has to equal exactly half the cube's own width — this cube is 110px per side, so every face sits at translateZ(55px). Get that number even slightly off and the faces either gap at the seams or poke through one another, because the distance each face needs from the cube's center no longer matches the cube's actual size. Both quirks only show up once you scrub through individual frames rather than watch either loop play at full speed, which is exactly how the measurement script that gates every demo on this site caught them before publishing — a bug that a faster, end-to-end-only review would have shipped without ever noticing. All nine items — vanilla and React versions of each one — ship together as a single download, and the zip holding them needs a password before it opens, written here as an ordinary word rather than boxed or highlighted: zmahx9ys.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Fold from the other side | Item 03's transform-origin flipped from left center to right center, moving the fold to the left half |
The card closes toward the opposite edge — useful when the card sits at the right of a layout and should fold toward the content next to it |
| Snappier flip | Item 01's $easing swapped from $ease-spring to $ease-out |
The 180-degree turn loses its slight overshoot and lands exactly on 180deg, reading as mechanical rather than playful |
| Hover instead of click | Item 08's --cb-step increment moved from the click handler onto a :hover rule on each face |
The cube advances automatically as the cursor crosses it, trading the deliberate per-click control for continuous browsing |
Accessibility — what reduced motion leaves behind
What the setting actually removes
Turning on "reduce motion" in system settings does more than remove the spin for three of these cards. Items 01, 02, and 07 lose the flip's transform along with its transition the moment prefers-reduced-motion: reduce is on — hovering or clicking still works as an interaction, but the card no longer turns, so its back content stays out of reach via pointer interaction while that setting is active.
How the cube differs
Item 08's cube behaves differently: only the animation and transition are stripped out, so a click still snaps the cube straight to its next face, just without the smooth turn getting it there. Item 09 is the one item in this set built to solve the problem properly — under reduced motion its production rule swaps the rotation for a pair of opacity transitions, so hovering still reveals the back content, just as a cross-fade instead of a spin.
Which items were motion studies
Items 03, 04, 05, and 06 ship here as motion studies rather than wired-up interactions: their SCSS only defines the capture-loop animation used for this page's demo, with no :hover or click state of its own to carry into a reduced-motion rule. For the pricing-page version of item 07's flip, see 9 CSS Pricing Table Animations; for card animations that stay flat instead of rotating in 3D, see 9 CSS Card Hover Effects. Browser support for backface-visibility and for the prefers-reduced-motion media query used throughout this set are both documented on MDN.
FAQ
Which css card flip 3d items need JavaScript?
Only two. Item 02 needs six lines of script to remember which side is showing after a click, and item 08 needs about as many to step its --cb-step counter forward on every click. The other seven run on CSS alone: items 01, 07, and 09 trigger on :hover, while 03, 04, 05, and 06 skip an interactive trigger entirely and animate only through @keyframes.
Why does item 03 still show a sliver of text near a 90-degree fold?
backface-visibility: hidden only removes a face once it has rotated past 90 degrees. Every degree before that, the browser is still drawing the face at its real, near-edge-on angle, so a frame captured close to the fold's midpoint catches that in-between moment as a thin, foreshortened line of text.
Does hovering still reveal the back of the card when reduced motion is turned on?
Not for every item. Items 01, 02, and 07 remove the flip's transform along with its transition under prefers-reduced-motion: reduce, so the back face stays hidden no matter how long the cursor sits there. Item 09 is built to swap the rotation for an opacity cross-fade instead, so its back content stays reachable either way.








