カード フリップ アニメーション 9選【コピペ20行】
カード フリップ アニメーションとは、マウスを乗せたりクリックしたりするとカードがくるっと回転したり半分に折れたりして、裏側の内容が現れる効果のことです。コードが分からなくても、下のデモにマウスを乗せてみれば何が起きているか一目で分かります。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9個は人気順ではなく、仕組みが単純なものから複雑なものへ、最後はアクセシビリティにつながる順番で並んでいます。もっとも基本のホバー反転(01)とクリック反転(02)から始めて、紙のように折って開くアイデア型の変形(03〜04)を経たあと、複数枚のカードを扱う実戦パターン(05〜08)に進み、同じカード反転をモーションを抑えて再生する09番で締めくくります。
01横に反転
css カード ホバー で回る
カードにマウスを乗せるとY軸を中心に180度回転し、裏面が現れます。

<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 · 横に反転 — 計測 累積面積 13.071% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どんなカードに使うか
クリックしなくても、見るだけで裏面が気になるカードに使います。
.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); }
02縦に反転(クリック)
クリックで縦に反転
カードをクリックするとX軸を中心に縦に反転し裏面が表示されます。

<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 · 縦に反転(クリック) — 計測 累積面積 12.343% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
タッチ端末で確実な理由
タッチ端末はホバーがないため、クリック・タップで発動させます。
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);
});
03半分に折る
中央の線で折りたたむ
カードが中央の線を基準に半分に折りたたまれ、内容が内側に隠れます。

<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 · 半分に折る — 計測 累積面積 13.074% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
紙のカードを閉じるように
紙のカードを閉じるように、メニュー表や案内カードを畳むときに使います。
.fh-half--fold {
transform-style: preserve-3d;
transform-origin: left center;
transition: transform $duration $easing;
}
.fh-half__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.fh-half__face--back {
background: rgba(23, 20, 26, .78);
transform: rotateY(180deg);
}
04手紙を開く
上下2段が順番に開く
折りたたまれていたカードが手紙を開くように上下2段が順番に広がり、中身が現れます。

<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 · 手紙を開く — 計測 累積面積 26.174% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
封筒を開ける感覚
下の段だけ少し遅れて開くので、封筒を開ける感覚になります。
.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); }
}
.ul-scene .ul-flap--top { animation: ul-open-top ($duration * 2.6) $easing infinite; }
.ul-scene .ul-flap--bottom {
animation: ul-open-bottom ($duration * 2.6) $easing infinite;
animation-delay: 120ms;
}
05山札をシャッフル
一番上だけが動く
積み重なったカードの一番上が反転して後ろへ移動し、次のカードが見えるようになります。

<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 · 山札をシャッフル — 計測 累積面積 10.424% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
1枚ずつめくって見せる
レビューやおすすめ商品のカードを1枚ずつめくって見せたいときに使います。
.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; }
}
.ss-stack .ss-card--1 { z-index: 3; animation: ss-shuffle ($duration * 6) $easing infinite; }
06ページめくり
カード めくる アニメーション の基本
カードの右端を基準に本のページをめくるように回転し、下のカードが現れます。

<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 · ページめくり — 計測 累積面積 16.329% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
影も一緒に動く
影も一緒に動くので、紙がふわっと持ち上がる感覚が出ます。
.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); }
}
07価格を見せるカード
裏面に割引価格
カードにマウスを乗せると反転し、裏面に割引価格が現れます。

<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">定価</span>
</div>
<div class="rp-card__face rp-card__face--back">
<span class="rp-price">$29</span>
<span class="rp-label">本日限定</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 · 価格を見せるカード — 計測 累積面積 12.062% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
1枚で見比べる
定価と割引価格を1枚のカードで見比べたいときに使います。
.rp-card__inner {
transform-style: preserve-3d;
transition: transform $duration $easing;
}
.rp-card:hover .rp-card__inner { transform: rotateY(180deg); }
.rp-card__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.rp-card__face--back { transform: rotateY(180deg); }
084面キューブ
4つの面を持つカード
カード1枚が立方体のように4つの面を持ち、クリックするたびに1面ずつ回転します。

<main class="stage">
<div class="cb-scene">
<button type="button" class="cb-cube is-demo" aria-label="次の面を見る">
<span class="cb-face cb-face--1">写真</span>
<span class="cb-face cb-face--2">説明</span>
<span class="cb-face cb-face--3">$29</span>
<span class="cb-face cb-face--4">購入</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 · 4面キューブ — 計測 累積面積 12.789% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
1枚にまとめたいとき
画像・説明・価格・購入ボタンを1枚のカードにまとめたいときに使います。
var cube = document.querySelector(".cb-cube");
var step = 0;
cube.addEventListener("click", function () {
step = (step + 1) % 4;
cube.style.setProperty("--cb-step", String(step));
});
09モーション削減版
回転の代わりにクロスフェード
同じカード反転をprefers-reduced-motionが有効な環境で再生すると、回転の代わりに柔らかいクロスフェードに切り替わります。

<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 · モーション削減版 — 計測 累積面積 10.497% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
切り替え自体を見せる
この項目はその切り替え自体を見せています。
@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;
}
}
カード フリップはどこで壊れるか
90度直前ににじむ文字
もっとも先に見つかった落とし穴は03番の半分に折るカードでした。折れる側の面にはbackface-visibility: hiddenを掛けてあるので裏面がこちら向きになった瞬間はきちんと消えますが、回転角度がちょうど90度に近づく直前のフレームでは面がまだ完全に垂直を超えておらず、遠近法で押しつぶされた極細の文字がうっすらと透けて見えます。hiddenは面が90度を超えてはじめて完全に消える仕組みで、それまでは元の角度のまま描画され続けるためです(MDNのbackface-visibilityドキュメントを参照)。実際に撮影したフレーム画像(回転角90度付近の1枚)でも、この薄いにじみをはっきり確認できました。
translateZ がずれると
08番の4面キューブにも似た落とし穴があり、4つの面をつなぎ合わせるときに使うtranslateZの値がカード1辺の半分と正確に一致していないと、面と面の間にすき間ができたり、逆に重なって見えたりします。110pxのカードならtranslateZは必ず55pxにしないと、面がずれて立方体に見えなくなります。どちらも一コマずつ確認してはじめて気づけた不具合で、通しで再生しただけでは見逃していました。9個のファイルが入ったzipの合言葉はzmahx9ysで、解凍するとvanillaフォルダとreactフォルダがそれぞれ入っています。
アクセシビリティ — 9個がreduced-motionで残すもの
項目ごとに反応が違う
システムで動作を減らす設定を入れている場合、項目ごとに反応が違います。01・02・07番はtransform: none !importantで反転自体が止まるため、マウスを乗せてもクリックしても裏面は一切見えず表面のままです — 裏面の情報が必要なら、別のボタンなど反転以外の手段を用意する必要があります。
08番は切り替えだけ止まる
08番はクリックで状態が変わること自体はそのままに、なめらかな切り替えだけを止めるので、クリックすると一瞬でぱっと切り替わります。03・04・05・06番はそもそもホバーやクリックのトリガーがなく自動再生ループだけなので、動作を減らす設定と一緒に消える対象自体がありません。
09番の解き方
09番はこの切り替え自体がテーマなので、回転の代わりにopacityのクロスフェードだけを残す様子を常に見せるように作ってあります。カード反転以外のフィードバック系アニメーションが気になる方は価格表アニメーション9選も参考になりますし、クリックで反応するUIはクリック・タッチのフィードバックアニメーション9選でも探せます。ほかのCSSアニメーションはCSSカテゴリーからまとめて見られます。
FAQ
css カード アニメーション はどのブラウザでも同じように見えますか?
rotateY・rotateXとpreserve-3dは最新のChrome・Edge・Safari・Firefoxのすべてで動作します。ただしごく古いブラウザでは3D回転なしにカードの内容がそのまま切り替わって見えることがあるため、決済のような重要な画面では、クリックでも同じ情報を確認できる代替手段を一緒に用意しておくと安全です。
JSなしでカードを反転できますか?
はい、01・07番のように:hoverで発動するカードはCSSだけで十分です。反転した状態を覚えておく必要がある02・08番はクリックのたびに状態を保存しなければならないため、JavaScriptが6〜8行だけ必要になります。
zip内のvanillaとreactのコードは違いますか?
画面で見える動き自体は同じです。vanilla側はSCSSファイルの先頭でduration・easing・colorの3つの変数を直接書き換え、react側は同じ値をコンポーネントのpropsとして受け取り、--durationのようなCSSカスタムプロパティに渡している点だけが異なります。








