CSS 3D 큐브 캐러셀 틸트 9종 — 한 줄 복붙으로
css 3d 큐브 회전, 원형으로 도는 캐러셀, 커서를 올리면 기우는 카드까지 — perspective 와 preserve-3d 로만 만든 입체 CSS 효과 아홉 개를 모았습니다. 아홉 개 전부 transform 만 움직이고, JS 는 한 줄도 쓰지 않습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 입체감을 만드는 세 속성 — perspective(카메라 거리) · transform-style: preserve-3d(자식을 평면으로 안 뭉갬) · backface-visibility(뒷면 숨김) — 을 다루는 순서로 놓았습니다. 앞의 셋(01~03)은 이 세 속성을 가장 단순한 형태로 보여주고, 중간 셋(04~06)은 여러 요소가 겹치는 구조로, 뒤의 셋(07~09)은 무대 전체를 3D 로 세우는 데까지 나갑니다. 항목마다 css 3d 회전 코드는 핵심 부분만 실었고, 전체 파일은 zip 에 있습니다. 여기서 움직이는 것은 면과 무대 전체라, 아이콘 하나를 선 단위로 그려 움직이는 방식은 CSS 아이콘 애니메이션 9종에 따로 있고, 물체 대신 뒤쪽 배경을 채워 깊이를 만드는 방식은 CSS 배경 애니메이션 효과 모음 9종에 있습니다.
01큐브 회전
네 면이 도는 축
4면짜리 큐브가 부모의 perspective 안에서 Y축으로 계속 돕니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
면마다 다른 문구를 넣을 때
로고나 짧은 문구를 면마다 다르게 넣어 순서대로 보여줄 때 씁니다.
.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 캐러셀
원 둘레에 세워 두고 부모만 돌리기
카드 다섯 장을 원 둘레에 세워 놓고 부모 하나만 계속 돌립니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
마퀴와 무엇이 다른가
translateX 로 미는 마퀴와 달리 회전만으로 도는 로고 쇼케이스에 씁니다.
.ring {
transform-style: preserve-3d;
animation: orbit $duration $easing infinite;
}
// 카드 5장을 72deg 간격으로 원 둘레에 세운다 — 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 호버 효과만으로 기울이는 법
커서를 올리면 카드가 고정된 각도로 살짝 기웁니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
JS 없이 값 하나만 트랜지션
마우스 좌표를 매 프레임 읽는 JS 없이, :hover 값 하나만 트랜지션합니다.
.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책 펼침 효과
왼쪽 변을 경첩으로 쓰기
표지가 왼쪽 변을 경첩 삼아 열리며 안쪽 페이지를 드러냅니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
펼쳐 보이는 느낌이 필요할 때
온보딩 가이드나 카탈로그 표지처럼 "펼쳐 보인다"는 느낌이 필요한 곳에 씁니다.
.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--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); }
}
05플립 갤러리
0.15초씩 어긋나는 뒤집힘
격자 타일 네 개가 0.15초씩 시차를 두고 차례로 뒤집혀 카테고리를 드러냅니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
카테고리를 소개하는 격자에
미디어 갤러리나 대시보드 위젯을 소개하는 격자에 씁니다.
.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 로 만드는 어긋남
카드 다섯 장이 같은 keyframes 를 음수 delay 로 어긋나게 돌아, 가운데는 크고 정면으로 양옆은 작고 기울어진 채 흐릅니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
커버를 넘기는 슬라이더에
앨범이나 포트폴리오 커버를 넘기는 슬라이더에 씁니다.
.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초에 한 바퀴 천천히 돕니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
360도 상세 페이지에
신제품을 360도로 보여주는 이커머스 상세 페이지에 씁니다.
.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입체 텍스트
여덟 겹 text-shadow 의 두께
같은 색을 한 단계씩 어둡게 여덟 겹 겹친 text-shadow 로 글자에 두께를 주고, 아래쪽 변을 축으로 기울었다 돌아옵니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
히어로 타이틀과 배너에
랜딩 히어로 타이틀이나 이벤트 배너 제목에 씁니다.
@for $i from 1 through 8 {
$out: list.append($out, #{$i}px #{$i}px darken($color, $i * 4%), comma);
}
.sign {
text-shadow: $out;
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 로 카메라가 방 안에 들어와 있는 것처럼 둘러보게 합니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
포트폴리오 내비게이션에
포트폴리오의 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); }
}
어디서 깨지는가 — 함정 하나
이 아홉 개가 가장 자주 얹히는 자리는 제품 소개 페이지의 첫 화면인데, 07 회전 스테이지처럼 큰 것을 세게 돌리면 코드가 아니라 신뢰감 쪽이 먼저 깎입니다. 그 차분한 기준선은 saas 랜딩페이지 신뢰감 디자인 키트의 클린 섹션 아홉 개에서 확인할 수 있습니다. 3D 자체가 무너지는 자리는 그보다 앞에 있습니다.
preserve-3d 는 조상까지 이어져야 한다
아홉 개 전부 transform-style: preserve-3d 를 회전하는 요소 자신에게 걸어 두는 데는 이유가 있습니다. backface-visibility: hidden 은 그 자체로는 아무 힘이 없고, 조상 중 하나라도 preserve-3d 가 없으면 3D 컨텍스트가 거기서 끊겨 자식이 평면으로 눌려버립니다. 04 책 펼침 효과를 처음 만들 때 앞뒤 .face 요소에만 preserve-3d 를 걸고 정작 회전을 거는 .cover 자체에는 빠뜨렸더니, 안쪽 면이 뒤집히지 않고 그냥 사라졌습니다. 02 3D 캐러셀에서는 또 다른 함정을 겪었습니다. 카드마다 다른 각도를 주려고 rotateY(#{$i} * #{$step}deg) 처럼 SCSS 보간 문자열을 그대로 이어 붙였더니 rotateY(0 * 72deg) 같은 CSS 가 나왔는데, 브라우저는 calc() 없이는 이 곱셈을 읽지 못해 카드가 전부 한자리에 겹쳐 버렸습니다. 값은 Sass 쪽에서 먼저 곱해 완성된 각도 리터럴로 넘겨야 합니다. 06 커버플로우에서는 z-index 를 keyframes 안에 넣었는데, 이 속성은 애니메이션 가능한 속성이 아니라서 중간값으로 보간되지 않고 각 키프레임 경계에서 순간적으로 바뀝니다. 부드럽게 겹치는 것처럼 보이는 건 사실 opacity 와 scale 이 동시에 바뀌어서 눈이 속는 것이고, z-index 자체는 딱딱 전환됩니다. 이 값들이 이미 정리된 아홉 개 파일의 압축 비밀번호는 bfmwjndp 이고, 아래 버튼으로 받을 수 있습니다.
접근성
동작 줄이기에서 각도를 고정한다
시스템에서 "동작 줄이기"를 켠 사용자를 위해 아홉 개 전부 prefers-reduced-motion: reduce 를 처리합니다. 회전이나 틸트를 끄되, 각자 정해 둔 각도로 고정해 내용이 갑자기 사라지지 않게 했습니다. 03 호버 틸트만 예외로 transform 자체를 none 으로 되돌립니다 — 눌렀을 때 반응이 없다는 오해를 막기 위해서입니다. perspective 와 transform-style, backface-visibility 는 각각 perspective · transform-style · backface-visibility MDN 문서에서 지원 범위를 확인할 수 있습니다.
@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); }
}
이어서 볼 글
3D 효과 말고 다른 CSS 애니메이션은 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
css 3d 효과에서 perspective 값은 얼마로 정해야 하나요?
작을수록 카메라가 가까이 다가간 것처럼 왜곡이 커지고, 클수록 밋밋해집니다. 09 3D 룸 스크롤처럼 안에 들어와 있는 느낌이 필요하면 260px 정도로 짧게, 01 큐브 회전처럼 물체 하나를 보여줄 땐 600~800px 정도가 무난합니다. 숫자 하나를 정답으로 외우기보다 항목 아홉 개의 값을 바꿔 가며 눈으로 비교해 보는 것이 좋습니다. 카드 한 장을 앞뒤로 뒤집는 정도만 필요하다면 20줄 안팎으로 끝나는 CSS 카드 뒤집기 애니메이션 9종이 더 짧게 끝납니다.
회전이 너무 빨라요.
전부 $duration 변수 하나로 속도를 정합니다. 01 큐브 회전은 2초, 07 회전 스테이지는 5초, 09 3D 룸 스크롤은 9초로 서로 다른데, 요소가 클수록·화면을 넓게 차지할수록 느리게 돌려야 어지럽지 않습니다.
React에서는 회전 배치를 어떻게 넘기나요?
zip의 react/ 폴더는 02·06처럼 카드가 여러 장인 항목을 배열로 map 해 렌더합니다. 카드마다 다른 각도(02)나 지연 시간(06)은 SCSS 변수가 아니라 인라인 style 로 넘기고, 나머지 공통 변수(duration·easing·color) 세 개만 props 로 받습니다.









