【一行でコピペOK】CSS 3D アニメーション9選
css 3d アニメーションは、perspectiveとtransform-style: preserve-3dだけで奥行きを作るCSSの技法です。回転するキューブや円周上のカルーセル、ホバーで傾くカードなど9種類を選び、それぞれ一行でコピペOKな短いコードにまとめました。
01 3d-cube-carousel-tilt-cube-rotate
02 3d-cube-carousel-tilt-3d-carousel
03 3d-cube-carousel-tilt-tilt-hover
04 3d-cube-carousel-tilt-book-open
05 3d-cube-carousel-tilt-flip-gallery
06 3d-cube-carousel-tilt-coverflow
07 3d-cube-carousel-tilt-rotate-stage
08 3d-cube-carousel-tilt-perspective-text
09 3d-cube-carousel-tilt-room-scroll
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9つは立体感を作る3つのプロパティ――perspective(カメラとの距離)・transform-style: preserve-3d(子要素を平面に押しつぶさない)・backface-visibility(裏面を隠す)――をどう組み合わせるかで並べています。前半の3つ(01〜03)はこの3つを最も単純な形で見せ、中盤の3つ(04〜06)は複数の要素が重なる構造へ進み、後半の3つ(07〜09)は舞台全体を3Dで組み立てるところまで踏み込みます。perspectiveとtransform-styleはいずれもMDNのBaselineで「Widely available」と記されていて、backface-visibilityだけは2022年3月に対応がそろった少し新しい機能です。コードは要点だけを抜き出していて、完成した9本のファイルはzipにまとめてあります。ここで動くのは面と舞台そのものなので、印を1つだけ線として描いて動かす方法はCSS アイコン アニメーション 9選に、物体ではなく後ろの背景を埋めて奥行きを出す方法はCSS 背景アニメーション 星 9選にまとめています。
01キューブ回転
4面が回る軸
4面のキューブがperspectiveの中でY軸を軸に回り続け、面ごとに違う情報を順番に見せます。ロゴや短い一言を面ごとに変えて、LPヒーローの商品紹介キューブやバナーの情報切り替えに使います。

<main class="stage">
<div class="scene">
<div class="cube is-demo">
<div class="cube__face cube__face--front">製品</div>
<div class="cube__face cube__face--right">レビュー</div>
<div class="cube__face cube__face--back">価格</div>
<div class="cube__face cube__face--left">お問い合わせ</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-loop; // 한 바퀴 도는 시간
$easing: linear; // 등속. 튀면 큐브가 멈칫거려 보인다
$color: $subject-blue-deep; // 큐브 면 색 (크림 글자가 얹히므로 진한 파랑)
@include stage($stage-ink);
$size: 96px;
$half: 48px;
.scene { perspective: 640px; } // 자식 큐브가 이 값 기준으로 입체로 보인다
.cube {
position: relative;
width: $size;
height: $size;
transform-style: preserve-3d; // 자식 4면이 평면으로 뭉개지지 않게
animation: spin $duration $easing infinite;
}
.cube__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: $r-sm;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: $color;
backface-visibility: hidden; // 뒤로 돌아간 면의 글자가 거울처럼 비치지 않게
box-shadow: $shadow-press;
}
.cube__face--front { transform: translateZ($half); }
.cube__face--right { transform: rotateY(90deg) translateZ($half); background: darken($color, 10%); }
.cube__face--back { transform: rotateY(180deg) translateZ($half); }
.cube__face--left { transform: rotateY(-90deg) translateZ($half); background: darken($color, 10%); }
@keyframes spin {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 한쪽 면에서 멈춘다 ────────────
@media (prefers-reduced-motion: reduce) {
.cube { animation: none; transform: rotateY(-28deg); }
}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);
}
.scene {
perspective: 640px;
}
.cube {
position: relative;
width: 96px;
height: 96px;
transform-style: preserve-3d;
animation: spin 2s linear infinite;
}
.cube__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 8px;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #0a4fe8;
backface-visibility: hidden;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cube__face--front {
transform: translateZ(48px);
}
.cube__face--right {
transform: rotateY(90deg) translateZ(48px);
background: rgb(3.0951223465%, 24.451466537%, 71.8068384379%);
}
.cube__face--back {
transform: rotateY(180deg) translateZ(48px);
}
.cube__face--left {
transform: rotateY(-90deg) translateZ(48px);
background: rgb(3.0951223465%, 24.451466537%, 71.8068384379%);
}
@keyframes spin {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.cube {
animation: none;
transform: rotateY(-28deg);
}
}import type { CSSProperties } from "react";
import styles from "./CubeRotate.module.scss";
export type CubeRotateProps = {
/** 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
/** CSS easing (기본 linear — 등속이어야 멈칫거리지 않는다) */
easing?: string;
/** 큐브 면 색 */
color?: string;
/** 4면에 넣을 라벨 */
faces?: [string, string, string, string];
className?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function CubeRotate({
duration = 2000,
easing = "linear",
color = "#2f6df6",
faces = ["제품", "리뷰", "가격", "문의"],
className = "",
}: CubeRotateProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.scene} ${className}`} style={vars}>
<div className={styles.cube}>
<div className={`${styles.face} ${styles.front}`}>{faces[0]}</div>
<div className={`${styles.face} ${styles.right}`}>{faces[1]}</div>
<div className={`${styles.face} ${styles.back}`}>{faces[2]}</div>
<div className={`${styles.face} ${styles.left}`}>{faces[3]}</div>
</div>
</div>
);
}01 · キューブ回転 — 計測 累積面積 9.847% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
面ごとに情報を切り替える
.cube {
transform-style: preserve-3d;
animation: spin $duration $easing infinite;
}
.cube__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.cube__face--front { transform: translateZ($half); }
.cube__face--right { transform: rotateY(90deg) translateZ($half); }
.cube__face--back { transform: rotateY(180deg) translateZ($half); }
.cube__face--left { transform: rotateY(-90deg) translateZ($half); }
@keyframes spin {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
023Dカルーセル
「css カルーセル ループ」を回転だけで作る
カード5枚を円周上にrotateYで配置し、親のリングだけを回し続けてシリンダーのように回転するカルーセルを作ります。028のマーキーはtranslateXで押しますが、これは回転だけで回るので、ロゴショーケースや回転する商品タグバッジに向いています。

<main class="stage">
<div class="scene">
<ul class="ring is-demo">
<li class="ring__card">新商品</li>
<li class="ring__card">ベスト</li>
<li class="ring__card">セール</li>
<li class="ring__card">レビュー</li>
<li class="ring__card">おすすめ</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: 8s; // 링이 한 바퀴 도는 시간 (개수가 늘면 느리게)
$easing: linear;
$color: $subject-cream; // 카드 배경
@include stage($stage-yellow);
$count: 5;
$step: 72deg; // 360deg ÷ 5장
$radius: 118px; // 카드 폭(92px)이 안 겹치도록 계산한 반지름
.scene { perspective: 560px; }
.ring {
position: relative;
width: 92px;
height: 56px;
list-style: none;
margin: 0;
padding: 0;
transform-style: preserve-3d;
animation: orbit $duration $easing infinite;
}
.ring__card {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: $r-control;
background: $color;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
box-shadow: $shadow-press;
backface-visibility: hidden; // 뒤로 넘어간 카드 글자가 반전돼 비치지 않게
}
// 카드 5장을 원 둘레에 72deg 간격으로 세운다 — rotateY 로 배치만 하고, 도는 건 부모 .ring 하나
@for $i from 1 through 5 {
.ring__card:nth-child(#{$i}) {
transform: rotateY(#{($i - 1) * $step}) translateZ($radius);
}
}
@keyframes orbit {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 정면 카드 하나만 보이게 고정 ──────
@media (prefers-reduced-motion: reduce) {
.ring { animation: none; transform: rotateY(0deg); }
}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);
}
.scene {
perspective: 560px;
}
.ring {
position: relative;
width: 92px;
height: 56px;
list-style: none;
margin: 0;
padding: 0;
transform-style: preserve-3d;
animation: orbit 8s linear infinite;
}
.ring__card {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
backface-visibility: hidden;
}
.ring__card:nth-child(1) {
transform: rotateY(0deg) translateZ(118px);
}
.ring__card:nth-child(2) {
transform: rotateY(72deg) translateZ(118px);
}
.ring__card:nth-child(3) {
transform: rotateY(144deg) translateZ(118px);
}
.ring__card:nth-child(4) {
transform: rotateY(216deg) translateZ(118px);
}
.ring__card:nth-child(5) {
transform: rotateY(288deg) translateZ(118px);
}
@keyframes orbit {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ring {
animation: none;
transform: rotateY(0deg);
}
}import type { CSSProperties } from "react";
import styles from "./Carousel3D.module.scss";
export type Carousel3DProps = {
/** 링이 한 바퀴 도는 시간 (기본 8000ms). 카드가 늘면 느리게 */
duration?: number;
easing?: string;
/** 카드 배경 */
color?: string;
cards?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--i"?: number };
export default function Carousel3D({
duration = 8000,
easing = "linear",
color = "#fff7e6",
cards = ["신상품", "베스트", "할인", "리뷰", "추천"],
}: Carousel3DProps) {
const step = 360 / cards.length;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.scene}>
<ul className={styles.ring} style={vars}>
{cards.map((c, i) => (
<li key={c} className={styles.card} style={{ transform: `rotateY(${i * step}deg) translateZ(118px)` }}>
{c}
</li>
))}
</ul>
</div>
);
}02 · 3Dカルーセル — 計測 累積面積 15.028% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ロゴショーケースに
.ring {
transform-style: preserve-3d;
animation: orbit $duration $easing infinite;
}
.ring__card {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
// Sassで先に角度を掛け算する — rotateY(0 * 72deg) のような無効なCSSを防ぐ
@for $i from 1 through 5 {
.ring__card:nth-child(#{$i}) {
transform: rotateY(#{($i - 1) * $step}) translateZ($radius);
}
}
@keyframes orbit {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
03ホバーでチルト
決まった角度へ傾くだけ
「css カード ホバー」で探す動きのうち、カーソルを乗せるとカードが決まった角度にふっと傾くタイプです。032の3Dチルト視差はマウス座標をJSで毎フレーム読みますが、これはJSを一行も使わず:hoverの値ひとつだけをトランジションするので、機能紹介カードやサムネイルプレビューに向いています。

<main class="stage">
<div class="frame">
<div class="tilt is-demo" tabindex="0">製品プレビュー</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 기울기가 붙는 시간
$easing: $ease-spring;
$color: $subject-cream; // 카드 배경
@include stage($stage-orange);
.frame { perspective: 600px; } // JS 없이 :hover 값만 고정으로 기운다(032 는 마우스 좌표 추적, 여기는 고정 각도)
.tilt {
width: min(190px, 58vw);
padding: $sp-6 $sp-4;
border-radius: $r-card;
background: $color;
color: $subject-ink;
font-weight: 700;
font-size: 14px;
line-height: 1.3;
font-family: inherit;
text-align: center;
box-shadow: $shadow-press;
outline: none;
cursor: pointer;
transform: rotateX(0deg) rotateY(0deg);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.tilt:hover,
.tilt:focus-visible,
.tilt:active {
transform: rotateX(10deg) rotateY(-14deg);
box-shadow: $shadow-raised;
}
// 데모 · 문서용: 고정 각도를 2초마다 오갔다 돌아온다
.tilt.is-demo { animation: tilt-loop $dur-loop $easing infinite; }
@keyframes tilt-loop {
0%, 20% { transform: rotateX(0deg) rotateY(0deg); box-shadow: $shadow-press; }
55% { transform: rotateX(10deg) rotateY(-14deg); box-shadow: $shadow-raised; }
100% { transform: rotateX(0deg) rotateY(0deg); box-shadow: $shadow-press; }
}
// 호버가 없는 터치 화면 — 누르는 동안만 기운다
@media (hover: none) {
.tilt:active { transform: rotateX(10deg) rotateY(-14deg); }
}
@media (prefers-reduced-motion: reduce) {
.tilt, .tilt.is-demo { animation: none; transition: none; transform: none; }
}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);
}
.frame {
perspective: 600px;
}
.tilt {
width: min(190px, 58vw);
padding: 24px 16px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 14px;
line-height: 1.3;
font-family: inherit;
text-align: center;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
outline: none;
cursor: pointer;
transform: rotateX(0deg) rotateY(0deg);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tilt:hover,
.tilt:focus-visible,
.tilt:active {
transform: rotateX(10deg) rotateY(-14deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.tilt.is-demo {
animation: tilt-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tilt-loop {
0%, 20% {
transform: rotateX(0deg) rotateY(0deg);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
55% {
transform: rotateX(10deg) rotateY(-14deg);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
100% {
transform: rotateX(0deg) rotateY(0deg);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@media (hover: none) {
.tilt:active {
transform: rotateX(10deg) rotateY(-14deg);
}
}
@media (prefers-reduced-motion: reduce) {
.tilt, .tilt.is-demo {
animation: none;
transition: none;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./TiltHoverCard.module.scss";
export type TiltHoverCardProps = {
duration?: number;
easing?: string;
/** 카드 배경 */
color?: string;
children?: ReactNode;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// 032(card-hover-tilt-3d-parallax) 와 달리 마우스 좌표를 안 읽는다 — :hover 값이 고정 각도로 붙는 순수 CSS 버전
export default function TiltHoverCard({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
children = "제품 미리보기",
}: TiltHoverCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.frame}>
<div className={styles.tilt} style={vars} tabIndex={0}>
{children}
</div>
</div>
);
}03 · ホバーでチルト — 計測 累積面積 5.41% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
JSを一行も使わない
.frame { perspective: 600px; }
.tilt {
transform: rotateX(0deg) rotateY(0deg);
transition: transform $duration $easing;
}
.tilt:hover,
.tilt:focus-visible,
.tilt:active {
transform: rotateX(10deg) rotateY(-14deg);
}
@media (hover: none) {
.tilt:active { transform: rotateX(10deg) rotateY(-14deg); }
}
04本が開く効果
左辺を軸に開く表紙
表紙が左辺を軸に-150degまで開き、内側のページを見せます。006のフリップカードはカード全体を中心軸で180deg裏返しますが、これは片方の辺だけが軸で完全には裏返らないので、オンボーディングガイドやカタログ表紙の展開に向いています。

<main class="stage">
<div class="book">
<div class="book__page">第1章 はじめに</div>
<div class="book__cover is-demo">
<div class="book__face book__face--front">マニュアル</div>
<div class="book__face book__face--back"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-enter; // 표지가 열리는 시간
$easing: $ease-spring;
$color: $subject-blue-deep; // 표지 색 (크림 글자가 얹히므로 진한 파랑)
@include stage($stage-paper);
.book {
position: relative;
width: 170px;
height: 130px;
perspective: 800px;
}
.book__page {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
font-family: inherit;
box-shadow: $shadow-press;
}
// 표지는 왼쪽 모서리를 축으로 돈다 — 카드 전체를 뒤집는 006 의 flip 과 달리 한쪽 변만 경첩이다
.book__cover {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: left center; // 규칙8: 왼쪽 변 경첩
animation: open $dur-loop $easing infinite;
}
.book__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: $r-card;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: $subject-cream;
backface-visibility: hidden; // 표지 안쪽 면이 앞면 글자와 겹쳐 비치지 않게
}
.book__face--front { background: $color; }
.book__face--back { background: darken($color, 14%); transform: rotateY(180deg); }
@keyframes open {
0%, 15% { transform: rotateY(0deg); }
55% { transform: rotateY(-150deg); }
85%, 100% { transform: rotateY(0deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 펼친 상태로 고정 ────────────
@media (prefers-reduced-motion: reduce) {
.book__cover { animation: none; transform: rotateY(-150deg); }
}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);
}
.book {
position: relative;
width: 170px;
height: 130px;
perspective: 800px;
}
.book__page {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
font-family: inherit;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.book__cover {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: left center;
animation: open 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.book__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 16px;
font-weight: 700;
font-size: 15px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
backface-visibility: hidden;
}
.book__face--front {
background: #0a4fe8;
}
.book__face--back {
background: rgb(2.7645438341%, 21.8398962891%, 64.1374169503%);
transform: rotateY(180deg);
}
@keyframes open {
0%, 15% {
transform: rotateY(0deg);
}
55% {
transform: rotateY(-150deg);
}
85%, 100% {
transform: rotateY(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.book__cover {
animation: none;
transform: rotateY(-150deg);
}
}import type { CSSProperties } from "react";
import styles from "./BookOpen.module.scss";
export type BookOpenProps = {
duration?: number;
easing?: string;
/** 표지 색 */
color?: string;
cover?: string;
page?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BookOpen({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8",
cover = "매뉴얼",
page = "1장. 시작하기",
}: BookOpenProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.book} style={vars}>
<div className={styles.page}>{page}</div>
<div className={styles.cover}>
<div className={`${styles.face} ${styles.front}`}>{cover}</div>
<div className={`${styles.face} ${styles.back}`} />
</div>
</div>
);
}04 · 本が開く効果 — 計測 累積面積 32.419% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
完全には裏返らない
.book__cover {
transform-style: preserve-3d;
transform-origin: left center;
animation: open $duration $easing infinite;
}
.book__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.book__face--back { transform: rotateY(180deg); }
@keyframes open {
0%, 15% { transform: rotateY(0deg); }
55% { transform: rotateY(-150deg); }
85%, 100% { transform: rotateY(0deg); }
}
05フリップギャラリー
0.15秒ずつずれる裏返り
格子タイル4枚が0.15秒ずつずれて順番に裏返り、カテゴリを見せます。006はカード1枚だけですが、これは複数枚が波のように続くので、メディアギャラリーのカテゴリ格子やダッシュボードウィジェットの紹介に向いています。

<main class="stage">
<ul class="gallery is-demo">
<li class="tile"><div class="tile__inner">
<div class="tile__face tile__face--front" aria-hidden="true"></div>
<div class="tile__face tile__face--back">写真</div>
</div></li>
<li class="tile"><div class="tile__inner">
<div class="tile__face tile__face--front" aria-hidden="true"></div>
<div class="tile__face tile__face--back">動画</div>
</div></li>
<li class="tile"><div class="tile__inner">
<div class="tile__face tile__face--front" aria-hidden="true"></div>
<div class="tile__face tile__face--back">ドキュメント</div>
</div></li>
<li class="tile"><div class="tile__inner">
<div class="tile__face tile__face--front" aria-hidden="true"></div>
<div class="tile__face tile__face--back">音楽</div>
</div></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base; // 타일 하나가 뒤집히는 시간
$easing: $ease-out;
$color: $subject-blue-deep; // 뒷면(카테고리) 배경 (크림 글자가 얹히므로 진한 파랑)
@include stage($stage-ink);
.gallery {
display: grid;
grid-template-columns: repeat(2, 74px);
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
perspective: 700px; // 격자 전체가 같은 시점을 공유
}
.tile { width: 74px; height: 74px; }
.tile__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
}
.tile__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: $r-sm;
backface-visibility: hidden; // 뒤로 넘어간 면 글자가 반전돼 비치지 않게
}
.tile__face--front { background: $subject-cream; }
.tile__face--back { background: $color; color: $subject-cream; font-weight: 700; font-size: 12px; line-height: 1; font-family: inherit; transform: rotateY(180deg); }
// 4칸이 0.15초씩 시차를 두고 같은 동작을 한다 — 물결처럼 뒤집힌다
.gallery.is-demo .tile:nth-child(1) .tile__inner { animation: flip $dur-loop $easing infinite; }
.gallery.is-demo .tile:nth-child(2) .tile__inner { animation: flip $dur-loop $easing infinite; animation-delay: .15s; }
.gallery.is-demo .tile:nth-child(3) .tile__inner { animation: flip $dur-loop $easing infinite; animation-delay: .3s; }
.gallery.is-demo .tile:nth-child(4) .tile__inner { animation: flip $dur-loop $easing infinite; animation-delay: .45s; }
@keyframes flip {
0%, 15% { transform: rotateY(0deg); }
50% { transform: rotateY(180deg); }
85%, 100% { transform: rotateY(360deg); } // 360 = 0 이라 다음 루프로 튀지 않고 이어진다
}
// ── 접근성: 움직임 줄이기 설정이면 뒷면(정보)에서 고정 ────────────
@media (prefers-reduced-motion: reduce) {
.gallery.is-demo .tile__inner { animation: none; transform: rotateY(180deg); }
}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);
}
.gallery {
display: grid;
grid-template-columns: repeat(2, 74px);
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
perspective: 700px;
}
.tile {
width: 74px;
height: 74px;
}
.tile__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
}
.tile__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 8px;
backface-visibility: hidden;
}
.tile__face--front {
background: #fff7e6;
}
.tile__face--back {
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
transform: rotateY(180deg);
}
.gallery.is-demo .tile:nth-child(1) .tile__inner {
animation: flip 2s ease-out infinite;
}
.gallery.is-demo .tile:nth-child(2) .tile__inner {
animation: flip 2s ease-out infinite;
animation-delay: 0.15s;
}
.gallery.is-demo .tile:nth-child(3) .tile__inner {
animation: flip 2s ease-out infinite;
animation-delay: 0.3s;
}
.gallery.is-demo .tile:nth-child(4) .tile__inner {
animation: flip 2s ease-out infinite;
animation-delay: 0.45s;
}
@keyframes flip {
0%, 15% {
transform: rotateY(0deg);
}
50% {
transform: rotateY(180deg);
}
85%, 100% {
transform: rotateY(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.gallery.is-demo .tile__inner {
animation: none;
transform: rotateY(180deg);
}
}import type { CSSProperties } from "react";
import styles from "./FlipGallery.module.scss";
export type FlipGalleryProps = {
duration?: number;
easing?: string;
/** 뒷면(카테고리) 배경 */
color?: string;
tiles?: string[];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--delay"?: string };
export default function FlipGallery({
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
tiles = ["사진", "동영상", "문서", "음악"],
}: FlipGalleryProps) {
const base: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery}>
{tiles.map((t, i) => (
<li key={t} className={styles.tile}>
<div className={styles.inner} style={{ ...base, "--delay": `${i * 0.15}s` } as Vars}>
<div className={`${styles.face} ${styles.front}`} aria-hidden="true" />
<div className={`${styles.face} ${styles.back}`}>{t}</div>
</div>
</li>
))}
</ul>
);
}05 · フリップギャラリー — 計測 累積面積 15.097% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
カテゴリ格子に
.tile__inner { transform-style: preserve-3d; }
.tile__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.tile__face--back { transform: rotateY(180deg); }
.tile:nth-child(1) .tile__inner {
animation: flip $duration $easing infinite;
}
.tile:nth-child(2) .tile__inner {
animation: flip $duration $easing infinite;
animation-delay: .15s;
}
@keyframes flip {
0%, 15% { transform: rotateY(0deg); }
50% { transform: rotateY(180deg); }
85%, 100% { transform: rotateY(360deg); }
}
06カバーフロー
負のdelayで作るずれ
カード5枚が同じkeyframesを負のanimation-delayでずらして回り、中央は大きく正面を向き、両脇は小さく傾いて流れます。JSを使わずCSSだけで循環させるので、アルバムやポートフォリオのカバースライダーに向いています。

<main class="stage">
<ul class="flow is-demo">
<li class="flow__card" style="--i: 0;" aria-label="アルバムカバー"></li>
<li class="flow__card" style="--i: 1;" aria-label="アルバムカバー"></li>
<li class="flow__card" style="--i: 2;" aria-label="アルバムカバー"></li>
<li class="flow__card" style="--i: 3;" aria-label="アルバムカバー"></li>
<li class="flow__card" style="--i: 4;" aria-label="アルバムカバー"></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop; // 카드 5장이 한 바퀴 흐르는 시간
$easing: $ease-out;
$color: $subject-cream; // 카드 배경
@include stage($stage-orange);
.flow {
position: relative;
width: 92px;
height: 92px;
list-style: none;
margin: 0;
padding: 0;
perspective: 640px;
}
.flow__card {
position: absolute;
inset: 0;
border-radius: $r-control;
background: $color;
box-shadow: $shadow-press;
// CSS 만으로 5장을 다른 위치에서 출발시킨다 — JS 없이 같은 keyframes 를 음수 delay 로 어긋나게 돈다
animation: flow $duration $easing infinite;
animation-delay: calc(var(--i) * -0.4s);
}
// 슬롯 5개(뒤 → 왼쪽 → 가운데 → 오른쪽 → 뒤)를 한 바퀴로. 100% 가 0% 와 같아 이음새 없이 돈다
@keyframes flow {
0% { transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(.6); opacity: .5; z-index: 1; }
20% { transform: translateX(-68px) translateZ(-40px) rotateY(35deg) scale(.82); opacity: .85; z-index: 2; }
40% { transform: translateX(0) translateZ(0) rotateY(0deg) scale(1); opacity: 1; z-index: 3; }
60% { transform: translateX(68px) translateZ(-40px) rotateY(-35deg) scale(.82); opacity: .85; z-index: 2; }
80% { transform: translateX(130px) translateZ(-90px) rotateY(-45deg) scale(.6); opacity: .5; z-index: 1; }
100% { transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(.6); opacity: .5; z-index: 1; }
}
// ── 접근성: 움직임 줄이기 설정이면 다섯 장을 가운데로 모아 고정 ────────
@media (prefers-reduced-motion: reduce) {
.flow__card { animation: none; transform: translateX(0) translateZ(0) rotateY(0deg) scale(1); opacity: 1; }
}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);
}
.flow {
position: relative;
width: 92px;
height: 92px;
list-style: none;
margin: 0;
padding: 0;
perspective: 640px;
}
.flow__card {
position: absolute;
inset: 0;
border-radius: 12px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
animation: flow 2s ease-out infinite;
animation-delay: calc(var(--i) * -0.4s);
}
@keyframes flow {
0% {
transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(0.6);
opacity: 0.5;
z-index: 1;
}
20% {
transform: translateX(-68px) translateZ(-40px) rotateY(35deg) scale(0.82);
opacity: 0.85;
z-index: 2;
}
40% {
transform: translateX(0) translateZ(0) rotateY(0deg) scale(1);
opacity: 1;
z-index: 3;
}
60% {
transform: translateX(68px) translateZ(-40px) rotateY(-35deg) scale(0.82);
opacity: 0.85;
z-index: 2;
}
80% {
transform: translateX(130px) translateZ(-90px) rotateY(-45deg) scale(0.6);
opacity: 0.5;
z-index: 1;
}
100% {
transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(0.6);
opacity: 0.5;
z-index: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.flow__card {
animation: none;
transform: translateX(0) translateZ(0) rotateY(0deg) scale(1);
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./Coverflow.module.scss";
export type CoverflowProps = {
/** 카드 5장이 한 바퀴 흐르는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 카드 배경 */
color?: string;
count?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--i"?: number };
// JS 없이 같은 keyframes 를 음수 animation-delay 로 어긋나게 돌려 흐름을 만든다
export default function Coverflow({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
count = 5,
}: CoverflowProps) {
const base: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.flow}>
{Array.from({ length: count }, (_, i) => (
<li key={i} className={styles.card} style={{ ...base, "--i": i } as Vars} aria-label="앨범 커버" />
))}
</ul>
);
}06 · カバーフロー — 計測 累積面積 15.591% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
カバースライダーに
.flow__card {
animation: flow $duration $easing infinite;
animation-delay: calc(var(--i) * -0.4s);
}
@keyframes flow {
0% { transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(.6); }
40% { transform: translateX(0) translateZ(0) rotateY(0deg) scale(1); }
80% { transform: translateX(130px) translateZ(-90px) rotateY(-45deg) scale(.6); }
100% { transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(.6); }
}
07回転ステージ
楕円の床の上を5秒で一周
楕円の床の上でオブジェクトが5秒に一周ゆっくり回ります。キューブ回転(01)より遅く、傾いた床が加わってターンテーブルのように見えるので、新商品の360度プレビューやECの商品詳細ページに使います。

<main class="stage">
<div class="turntable">
<div class="turntable__floor"></div>
<div class="turntable__object is-demo">
<div class="turntable__face turntable__face--front">新商品</div>
<div class="turntable__face turntable__face--back"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: 5s; // 한 바퀴 도는 시간 — 제품 회전은 느릴수록 고급스럽다
$easing: linear;
$color: $subject-blue-deep; // 오브젝트 색 (크림 글자가 얹히므로 진한 파랑)
@include stage($stage-paper);
.turntable {
position: relative;
width: 130px;
height: 160px;
perspective: 700px;
}
// 바닥 원반은 고정. transform-style 없이도 perspective 안에서 타원으로 눌려 입체 바닥처럼 보인다
.turntable__floor {
position: absolute;
left: 50%;
bottom: 6px;
width: 120px;
height: 28px;
border-radius: 50%;
background: rgba($subject-ink, .1);
transform: translateX(-50%) rotateX(70deg);
transform-style: preserve-3d;
}
.turntable__object {
position: absolute;
inset: 12px 25px 34px;
transform-style: preserve-3d;
animation: turn $duration $easing infinite;
}
.turntable__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: $r-control;
color: $subject-cream;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
font-family: inherit;
text-align: center;
box-shadow: $shadow-press;
backface-visibility: hidden; // 뒤로 돈 면의 글자가 비치지 않게
}
.turntable__face--front { background: $color; }
.turntable__face--back { background: darken($color, 16%); transform: rotateY(180deg); }
@keyframes turn {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 정면에서 고정 ────────────
@media (prefers-reduced-motion: reduce) {
.turntable__object { animation: none; transform: rotateY(-20deg); }
}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);
}
.turntable {
position: relative;
width: 130px;
height: 160px;
perspective: 700px;
}
.turntable__floor {
position: absolute;
left: 50%;
bottom: 6px;
width: 120px;
height: 28px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.1);
transform: translateX(-50%) rotateX(70deg);
transform-style: preserve-3d;
}
.turntable__object {
position: absolute;
inset: 12px 25px 34px;
transform-style: preserve-3d;
animation: turn 5s linear infinite;
}
.turntable__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 12px;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
font-family: inherit;
text-align: center;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
backface-visibility: hidden;
}
.turntable__face--front {
background: #0a4fe8;
}
.turntable__face--back {
background: rgb(2.5992545779%, 20.5341111651%, 60.3027062064%);
transform: rotateY(180deg);
}
@keyframes turn {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.turntable__object {
animation: none;
transform: rotateY(-20deg);
}
}import type { CSSProperties } from "react";
import styles from "./RotateStage.module.scss";
export type RotateStageProps = {
/** 한 바퀴 도는 시간 (기본 5000ms) — 느릴수록 고급스럽다 */
duration?: number;
easing?: string;
/** 오브젝트 색 */
color?: string;
label?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function RotateStage({
duration = 5000,
easing = "linear",
color = "#0a4fe8",
label = "신제품",
}: RotateStageProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.turntable}>
<div className={styles.floor} />
<div className={styles.object} style={vars}>
<div className={`${styles.face} ${styles.front}`}>{label}</div>
<div className={`${styles.face} ${styles.back}`} />
</div>
</div>
);
}07 · 回転ステージ — 計測 累積面積 7.839% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ECの商品詳細ページに
.turntable__floor {
border-radius: 50%;
transform: translateX(-50%) rotateX(70deg);
}
.turntable__object {
transform-style: preserve-3d;
animation: turn $duration $easing infinite;
}
.turntable__face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.turntable__face--back { transform: rotateY(180deg); }
@keyframes turn {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
08立体テキスト
8層のtext-shadowで出す厚み
同じ色を一段ずつ暗くした8層のtext-shadowで文字に厚みを出し、下辺を軸にrotateXが傾いて戻ります。LPヒーローのタイトルやイベントバナーの見出しに向いています。

<main class="stage">
<div class="scene">
<h2 class="sign is-demo">ようこそ</h2>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@use "sass:list";
// ── 변수 3개 ──────────────────────────────
$duration: $dur-loop; // 기울었다 돌아오는 한 주기
$easing: $ease-spring;
$color: $subject-blue; // 글자 정면 색
@include stage($stage-ink);
.scene { perspective: 500px; }
// 같은 색을 한 단계씩 어둡게 8겹 겹쳐서 글자에 두께(압출)가 있는 것처럼 보이게 한다
@function extrudeShadow($c, $n) {
$out: ();
@for $i from 1 through $n {
$out: list.append($out, #{$i}px #{$i}px darken($c, $i * 4%), comma);
}
@return $out;
}
.sign {
margin: 0;
font-weight: 800;
font-size: clamp(28px, 8vw, 40px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
text-shadow: extrudeShadow($color, 8);
transform-origin: center bottom;
animation: tilt $duration $easing infinite;
}
@keyframes tilt {
0%, 20% { transform: rotateX(0deg); }
55% { transform: rotateX(-34deg); }
100% { transform: rotateX(0deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 기운 상태로 고정 ────────────
@media (prefers-reduced-motion: reduce) {
.sign { animation: none; transform: rotateX(-20deg); }
}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);
}
.scene {
perspective: 500px;
}
.sign {
margin: 0;
font-weight: 800;
font-size: clamp(28px, 8vw, 40px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
text-shadow: 1px 1px rgb(10.763169784%, 37.36260956%, 96.1387910003%), 2px 2px rgb(4.1019246408%, 32.3596277221%, 94.8000361435%), 3px 3px rgb(3.7701274058%, 29.7421162013%, 87.1318333785%), 4px 4px rgb(3.4383301708%, 27.1246046806%, 79.4636306135%), 5px 5px rgb(3.1065329358%, 24.5070931598%, 71.7954278486%), 6px 6px rgb(2.7747357007%, 21.8895816391%, 64.1272250836%), 7px 7px rgb(2.4429384657%, 19.2720701184%, 56.4590223186%), 8px 8px rgb(2.1111412307%, 16.6545585976%, 48.7908195536%);
transform-origin: center bottom;
animation: tilt 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tilt {
0%, 20% {
transform: rotateX(0deg);
}
55% {
transform: rotateX(-34deg);
}
100% {
transform: rotateX(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.sign {
animation: none;
transform: rotateX(-20deg);
}
}import type { CSSProperties } from "react";
import styles from "./PerspectiveText.module.scss";
export type PerspectiveTextProps = {
duration?: number;
easing?: string;
/** 글자 정면 색 */
color?: string;
text?: string;
/** 압출 두께(겹) */
depth?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
// 같은 색을 한 단계씩 어둡게 겹쳐서 글자에 두께(압출)가 있는 것처럼 보이게 한다 (vanilla 의 extrudeShadow() 와 같은 계산)
function extrudeShadow(hex: string, n: number): string {
const num = parseInt(hex.replace("#", ""), 16);
let [r, g, b] = [(num >> 16) & 255, (num >> 8) & 255, num & 255];
const layers: string[] = [];
for (let i = 1; i <= n; i++) {
const f = 1 - i * 0.04;
const rr = Math.max(0, Math.round(r * f));
const gg = Math.max(0, Math.round(g * f));
const bb = Math.max(0, Math.round(b * f));
layers.push(`${i}px ${i}px rgb(${rr},${gg},${bb})`);
}
return layers.join(", ");
}
export default function PerspectiveText({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
text = "환영합니다",
depth = 8,
}: PerspectiveTextProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<div className={styles.scene}>
<h2 className={styles.sign} style={{ ...vars, textShadow: extrudeShadow(color, depth) } as CSSProperties}>
{text}
</h2>
</div>
);
}08 · 立体テキスト — 計測 累積面積 3.422% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
見出しとバナーに
@function extrudeShadow($c, $n) {
$out: ();
@for $i from 1 through $n {
$out: list.append($out, #{$i}px #{$i}px darken($c, $i * 4%), comma);
}
@return $out;
}
.sign {
text-shadow: extrudeShadow($color, 8);
transform-origin: center bottom;
animation: tilt $duration $easing infinite;
}
@keyframes tilt {
0%, 20% { transform: rotateX(0deg); }
55% { transform: rotateX(-34deg); }
100% { transform: rotateX(0deg); }
}
093Dルームスクロール
正面の壁がない部屋
正面の壁がない部屋を9秒で一周させ、短いperspectiveでカメラが部屋の中にいるように見回させます。キューブ回転(01)の閉じた6面構造と違い、内側から見る開いた構造なので、ポートフォリオの3Dナビゲーションやインタラクティブな紹介セクションに使います。

<main class="stage">
<div class="room-scene">
<div class="room is-demo">
<div class="room__wall room__wall--back">ポートフォリオ</div>
<div class="room__wall room__wall--left">紹介</div>
<div class="room__wall room__wall--right">作品</div>
<div class="room__floor" aria-hidden="true"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: 9s; // 방을 한 바퀴 둘러보는 시간 — 큐브(01)보다 느리게 돈다
$easing: linear;
$color: $subject-cream; // 벽 색
@include stage($stage-ink);
$half: 65px;
// perspective 를 짧게 잡아 카메라가 방 '안'에 들어와 있는 것처럼 벽이 시야 가득 보이게 한다
.room-scene { perspective: 260px; }
.room {
position: relative;
width: $half * 2;
height: $half * 2;
transform-style: preserve-3d;
animation: look-around $duration $easing infinite;
}
.room__wall {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
font-family: inherit;
background: $color;
backface-visibility: hidden; // 등진 벽이 뒤집혀 비치지 않게
}
// 정면 벽 하나를 없애 '문 없는 방' 구조로 만든다 — 큐브(01)처럼 닫힌 6면이 아니라 세 벽 + 바닥
.room__wall--back { transform: translateZ(-$half); }
.room__wall--left { transform: rotateY(90deg) translateZ(-$half); background: darken($color, 10%); }
.room__wall--right { transform: rotateY(-90deg) translateZ(-$half); background: darken($color, 10%); }
.room__floor {
position: absolute;
inset: 0;
background: darken($color, 20%);
transform: rotateX(90deg) translateZ(-$half);
}
@keyframes look-around {
from { transform: rotateY(-35deg); }
to { transform: rotateY(325deg); }
}
// ── 접근성: 움직임 줄이기 설정이면 정면을 바라본 채로 고정 ────────────
@media (prefers-reduced-motion: reduce) {
.room { animation: none; transform: rotateY(-15deg); }
}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);
}
.room-scene {
perspective: 260px;
}
.room {
position: relative;
width: 130px;
height: 130px;
transform-style: preserve-3d;
animation: look-around 9s linear infinite;
}
.room__wall {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #17141a;
font-weight: 700;
font-size: 13px;
line-height: 1.3;
font-family: inherit;
background: #fff7e6;
backface-visibility: hidden;
}
.room__wall--back {
transform: translateZ(-65px);
}
.room__wall--left {
transform: rotateY(90deg) translateZ(-65px);
background: rgb(100%, 90.462745098%, 70.1960784314%);
}
.room__wall--right {
transform: rotateY(-90deg) translateZ(-65px);
background: rgb(100%, 90.462745098%, 70.1960784314%);
}
.room__floor {
position: absolute;
inset: 0;
background: rgb(100%, 84.062745098%, 50.1960784314%);
transform: rotateX(90deg) translateZ(-65px);
}
@keyframes look-around {
from {
transform: rotateY(-35deg);
}
to {
transform: rotateY(325deg);
}
}
@media (prefers-reduced-motion: reduce) {
.room {
animation: none;
transform: rotateY(-15deg);
}
}import type { CSSProperties } from "react";
import styles from "./RoomScroll.module.scss";
export type RoomScrollProps = {
/** 방을 한 바퀴 둘러보는 시간 (기본 9000ms) — 큐브 회전보다 느리게 */
duration?: number;
easing?: string;
/** 벽 색 */
color?: string;
walls?: [string, string, string];
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function RoomScroll({
duration = 9000,
easing = "linear",
color = "#fff7e6",
walls = ["포트폴리오", "소개", "작업"],
}: RoomScrollProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.scene}>
<div className={styles.room} style={vars}>
<div className={`${styles.wall} ${styles.back}`}>{walls[0]}</div>
<div className={`${styles.wall} ${styles.left}`}>{walls[1]}</div>
<div className={`${styles.wall} ${styles.right}`}>{walls[2]}</div>
<div className={styles.floor} aria-hidden="true" />
</div>
</div>
);
}09 · 3Dルームスクロール — 計測 累積面積 18.625% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ポートフォリオの3Dナビに
.room-scene { perspective: 260px; }
.room {
transform-style: preserve-3d;
animation: look-around $duration $easing infinite;
}
.room__wall {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.room__wall--back { transform: translateZ(-$half); }
.room__wall--left { transform: rotateY(90deg) translateZ(-$half); }
.room__wall--right { transform: rotateY(-90deg) translateZ(-$half); }
@keyframes look-around {
from { transform: rotateY(-35deg); }
to { transform: rotateY(325deg); }
}
どこで崩れるか — 落とし穴
この9つが置かれる場所として多いのは製品紹介ページの最初の画面で、07の回転ステージのように大きなものを強く回すと、性能より先に信頼感が削られます。その落ち着いた基準はsaas サイト デザイン 信頼感キットのクリーンなセクション9選で確かめられます。3Dそのものが崩れる場所は、それより手前にあります。
preserve-3dは祖先までつながっている必要がある
backface-visibility: hiddenを使う5つ(01・04・05・07・09)は、いずれも裏返る要素自身ではなくpreserve-3dを持つ親の内側にそれを置いています。理由は単純です。backface-visibility自体には効果がなく、祖先のどれか一つでもpreserve-3dを欠くとそこで3Dのコンテキストが切れ、子要素は平面に押しつぶされてしまうからです。04の本が開く効果を最初に組んだとき、表紙の中身である.book__faceにはpreserve-3dを付けたのに、実際に回転させる.book__cover自身に付け忘れていて、内側のページが裏返らずただ消えるだけになりました。
Sassの掛け算は先に済ませる
02の3Dカルーセルで角度をカードごとに変えようとして、rotateY(#{$i} * #{$step}deg)のようにSassの変数をそのまま文字列としてつなげると、rotateY(0 * 72deg)という無効なCSSが出力されます。ブラウザはcalc()なしではこの掛け算を読めないため、カード5枚が同じ場所に重なって止まって見えました。角度はSass側で先に掛け算を済ませ、完成した数値のリテラルとして渡す必要があります。この直し方まで反映した9本のファイルを開く合言葉はbfmwjndpで、下のカードから取り出せます。
z-indexは補間されない
06のカバーフローではz-indexをkeyframesの中に書きましたが、このプロパティはアニメーション可能な値ではないため、途中の値には補間されずキーフレームの境界で一気に切り替わります。滑らかに前後が入れ替わって見えるのは、実際にはopacityとscaleが同時に変わって目をごまかしているだけで、z-index自体はカクッと切り替わったままです。
アクセシビリティ
動きを止めても角度は残す
OSの「動きを減らす」設定を有効にした人のために、9つともprefers-reduced-motion: reduceを処理します。回転や傾きは止めますが、それぞれ決まった角度で止めるので、内容が急に消えたようには見えません。03のホバーチルトだけは例外で、transform自体をnoneに戻します。触れても反応がないと誤解されないようにするためです。
@media (prefers-reduced-motion: reduce) {
.cube { animation: none; transform: rotateY(-28deg); }
.ring { animation: none; transform: rotateY(0deg); }
.tilt, .tilt.is-demo { animation: none; transition: none; transform: none; }
.book__cover { animation: none; transform: rotateY(-150deg); }
.gallery.is-demo .tile__inner { animation: none; transform: rotateY(180deg); }
.flow__card { animation: none; transform: translateX(0) translateZ(0) rotateY(0deg) scale(1); opacity: 1; }
.turntable__object { animation: none; transform: rotateY(-20deg); }
.sign { animation: none; transform: rotateX(-20deg); }
.room { animation: none; transform: rotateY(-15deg); }
}
キーボードでも同じ角度へ
03のカードにはtabindex="0"でキーボードフォーカスが当たるようにしてあり、:focus-visibleにも:hoverとまったく同じ角度を返します。マウスを使わない人にも同じ情報が届きます。
.tilt:hover,
.tilt:focus-visible,
.tilt:active {
transform: rotateX(10deg) rotateY(-14deg);
}
次に読む
perspective・transform-style・backface-visibilityはどれもcaniuseのCSS 3D Transforms対応表で主要ブラウザの現行バージョンがすべて対応済みと確認できます。3D以外のCSSアニメーションはカテゴリー一覧に、このサイトが何をしているかはこのサイトについてにまとめています。
FAQ
「css アニメーション 回転 3d」でperspectiveの値はどれくらいにすればいいですか?
小さいほどカメラが近づいた分だけ歪みが強くなり、大きいほど平坦に見えます。09の3Dルームスクロールのように中に入り込む感覚が欲しいときは260px程度と短く、01のキューブ回転のように一つの物体を見せたいときは600px前後(01は640px)が扱いやすいです。数字を一つ覚えるより、9つの値を変えながら見比べるほうが感覚をつかみやすくなります。カードを表裏で反転させるだけで足りる場合は、20行ほどで収まるカード フリップ アニメーション 9選のほうが手早く済みます。
回転が速すぎて酔いそうです。
すべて$durationという変数一つで速さを決めています。01のキューブ回転は2秒、07の回転ステージは5秒、09の3Dルームスクロールは9秒とそれぞれ違い、要素が大きく画面を広く占めるほど、ゆっくり回したほうが酔いにくくなります。
React版ではカードの並びをどう渡していますか?
zipのreact/フォルダでは、02や06のようにカードが複数枚ある項目を配列からmapして描画しています。カードごとに違う角度(02)や遅延時間(06)はSCSS変数ではなくインラインのstyleで渡し、残りの共通変数(duration・easing・color)の3つだけをpropsで受け取ります。









