9 CSS 3D Cube Effects — One Line, Copy-Paste
A css 3d cube spins its four labeled faces inside a perspective wrapper — one of nine such effects below, each one line, copy-paste ready in SCSS, with a
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Cube rotate
- 02 3D carousel
- 03 Tilt on hover
- 04 Book open effect
- 05 Flip gallery
- 06 Coverflow
- 07 Rotating stage
- 08 Perspective text
- 09 3D room scroll
The nine below build up the three properties that give CSS a sense of depth in the first place — perspective for how close the camera sits, transform-style: preserve-3d so a rotated child keeps its depth instead of getting flattened by its parent, and backface-visibility for hiding whichever face is turned away. 01 through 03 show each of those three doing the least amount of work possible. 04 through 06 stack several faces or several cards inside one shared 3D parent. 07 through 09 build a whole stage rather than a single spinning object. Each code block below keeps only the rule that matters for that item; the full file for every one sits in the zip. What moves here is a face or a whole stage; drawing a single mark stroke by stroke instead is the subject of 9 SVG icon animations, and filling the space behind an object rather than turning the object itself is what the 9 CSS particle backgrounds do.
01Cube rotate
What a css 3d rotating cube needs around it
A four-sided cube spins on its Y axis inside a perspective container, cycling a different label onto each face as it turns.

<main class="stage">
<div class="scene">
<div class="cube is-demo">
<div class="cube__face cube__face--front">Product</div>
<div class="cube__face cube__face--right">Reviews</div>
<div class="cube__face cube__face--back">Pricing</div>
<div class="cube__face cube__face--left">Contact</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 · Cube rotate — Measured area 9.847% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a rotating tile fits
It suits a hero banner or a product tile that needs to rotate through three or four short messages without any script running the show.
.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 carousel
Five cards on one ring
Five cards stand around a circle at fixed rotateY offsets, and spinning only the parent ring carries all five through a full turn together. Post 028's marquee pushes its row sideways with translateX; this ring never moves along that axis at all — rotation alone does the work.

<main class="stage">
<div class="scene">
<ul class="ring is-demo">
<li class="ring__card">New</li>
<li class="ring__card">Best</li>
<li class="ring__card">Sale</li>
<li class="ring__card">Reviews</li>
<li class="ring__card">Picks</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 carousel — Measured area 15.028% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A logo showcase that keeps circling
It fits a logo showcase or a rotating product badge that needs to keep circling on its own.
.ring {
transform-style: preserve-3d;
animation: orbit $duration $easing infinite;
}
// Five cards spaced 72deg apart around the circle — Sass multiplies the
// angle first so the browser never sees rotateY(0 * 72deg)
@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); }
}
03Tilt on hover
One fixed angle, no pointer math
A card tilts to one fixed angle the moment a cursor, a keyboard focus ring, or a tap lands on it. Post 032's parallax tilt reads live pointer coordinates through JavaScript on every frame; this one transitions a single :hover value with zero script involved.

<main class="stage">
<div class="frame">
<div class="tilt is-demo" tabindex="0">Product preview</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 · Tilt on hover — Measured area 5.41% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Touch without hover
A touch screen without any hover state still reaches the same tilted angle through an @media (hover: none) fallback on :active.
.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); }
}
04Book open effect
One edge as a hinge
A cover swings open around its left edge to rotateY(-150deg) and reveals the page sitting underneath before easing shut again. Post 006's css 3d card flip spins an entire card 180deg around its own center; here only one edge acts as a hinge and the cover never turns all the way over.

<main class="stage">
<div class="book">
<div class="book__page">Ch.1 Getting started</div>
<div class="book__cover is-demo">
<div class="book__face book__face--front">Manual</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 · Book open effect — Measured area 32.419% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Paper, not a flat tile
It works for an onboarding guide or a catalog whose front matter wants to look like paper rather than a flat tile.
.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); }
}
05Flip gallery
A 0.15s stagger across four tiles
Four grid tiles flip one after another, each one starting 0.15s after the last, so the reveal ripples across the set instead of firing all at once. Where post 006 flips a single card end to end, this spreads that same rotateY flip over four tiles on a staggered delay.

<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">Photos</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">Videos</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">Docs</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">Music</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 · Flip gallery — Measured area 15.097% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Introducing several categories
It suits a media gallery grid or a dashboard set introducing several categories at once.
.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); }
}
06Coverflow
Negative delay instead of logic
Five cards share one keyframes animation, offset from one another only by a negative animation-delay, so the center card sits large and facing forward while the two on each side shrink and turn away. Nothing calculates which card counts as centered — the delay math alone keeps all five cycling through their slots.

<main class="stage">
<ul class="flow is-demo">
<li class="flow__card" style="--i: 0;" aria-label="Album cover"></li>
<li class="flow__card" style="--i: 1;" aria-label="Album cover"></li>
<li class="flow__card" style="--i: 2;" aria-label="Album cover"></li>
<li class="flow__card" style="--i: 3;" aria-label="Album cover"></li>
<li class="flow__card" style="--i: 4;" aria-label="Album cover"></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 · Coverflow — Measured area 15.591% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A slider that keeps flowing
It suits an album or a portfolio-cover slider that needs to keep flowing without a script deciding the order.
.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); }
}
07Rotating stage
A turntable, not a spinning box
An object turns once every five seconds on a tilted, ellipse-shaped floor that never moves on its own. That's noticeably slower than the css 3d cube animation in item 01, and the added floor is what makes it read as a turntable rather than a bare spinning box.

<main class="stage">
<div class="turntable">
<div class="turntable__floor"></div>
<div class="turntable__object is-demo">
<div class="turntable__face turntable__face--front">New arrival</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 · Rotating stage — Measured area 7.839% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
360 degrees on a detail page
It fits a 360-degree new-product preview on an e-commerce detail page.
.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); }
}
08Perspective text
Eight shadow layers of thickness
Eight layers of text-shadow, each one shade darker than the one before it, give a heading the look of extruded thickness. The whole sign then rotates on rotateX around its bottom edge and eases back, like a sign swinging on a hinge fixed at its base.

<main class="stage">
<div class="scene">
<h2 class="sign is-demo">Welcome</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 · Perspective text — Measured area 3.422% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Dimension without a 3D library
It fits a landing hero title or an event banner headline that wants a little dimension without loading a 3D library for it.
.sign {
text-shadow: $extruded-shadow;
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 room scroll
Open on one side, seen from inside
A room missing its front wall — three walls plus a floor — turns once every nine seconds under a short, 260px perspective, so the view reads like a camera standing inside looking around rather than an object being examined from outside it. Unlike the closed six-sided cube in item 01, this structure is open and viewed from within.

<main class="stage">
<div class="room-scene">
<div class="room is-demo">
<div class="room__wall room__wall--back">Portfolio</div>
<div class="room__wall room__wall--left">About</div>
<div class="room__wall room__wall--right">Work</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 room scroll — Measured area 18.625% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why nothing listens for scroll
Despite the name, nothing here listens for an actual scroll event — the turn runs on the same kind of CSS loop as the rest of this set.
.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); }
}
Where does this break?
The place these nine most often end up is the first screen of a product page, where spinning something as large as item 07's stage costs trust long before it costs performance. The nine clean SaaS landing sections show the calmer baseline worth measuring that against. The ways the 3D itself falls apart come earlier than that.
When the three properties landed
All nine lean on the same three properties for their sense of depth, and the three didn't land everywhere at the same time. perspective reached Baseline "Widely available" back in 2015, and transform-style: preserve-3d reached that same status on the same timeline according to MDN. backface-visibility — the one this set leans on hardest, since six of the nine items depend on it — only crossed that line in March 2022, years after the other two. caniuse's CSS 3D Transforms table shows all three running in every current major browser now, so the gap only matters for a project still supporting very old releases.
Two traps that only show at runtime
Two of the nine also hide a trap that only shows up once the code actually runs. Item 02 places its five cards with rotateY(#{$i} * #{$step}deg), an SCSS string built by dropping two variables straight into the value. For the first card, Sass hands the browser rotateY(0 * 72deg) — and a transform function reading that outside of calc() can't multiply it, so the whole declaration is thrown out as invalid, and every card lands stacked on the same spot instead of spread around the ring. Multiplying ($i - 1) * $step inside Sass first, before the value ever reaches the compiled CSS, hands the browser a finished angle like 72deg it can actually parse. Item 06's coverflow hides a quieter version of the same lesson: z-index sits inside its shared keyframes next to opacity and scale, but z-index isn't an animatable property, so it snaps at each keyframe boundary instead of interpolating between them — the smooth-looking overlap a viewer sees comes entirely from opacity and scale shifting together, not from z-index easing anywhere at all. Every one of the nine also keeps backface-visibility: hidden next to a transform-style: preserve-3d set on an actual ancestor in the chain, since the hidden face does nothing once any parent along that chain drops out of the 3D context and flattens its children back down to a plane. The nine SCSS files with every one of those fixes already in place unlock from the zip below with bfmwjndp, typed exactly as this line spells it, no capitals added anywhere.
Accessibility
A fixed angle instead of nothing
All nine drop to a fixed end state under prefers-reduced-motion: reduce rather than disappearing outright, so the content stays visible with the motion gone. Items 01, 02, and 04 through 09 turn their animation off and settle at a fixed viewing angle — a cube caught mid-turn, a room facing forward — so nothing snaps back to a blank starting frame. Item 03 is the one exception: reduced motion clears its transition and its transform back to none entirely, since a hover card stuck holding a tilted angle with no way to reach it again would read as broken rather than restrained. That same item is also the one built for a keyboard from the start — its trigger carries tabindex="0" so :focus-visible lands the identical fixed angle a mouse hover gets, with nothing here depending on a pointer being present at all.
@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); }
}
Where the rest lives
The rest of this line of demos lives on the CSS category hub, and what this site does and doesn't sell is covered on the about page.
FAQ
Can I use just one css 3d cube effect without the other eight?
Yes — each item's SCSS sits in its own file under its own class names, so item 01's cube copies over on its own without pulling in the ring, the tilt, or anything else in this set. The three shared properties only need to sit on that cube's own markup, not on a page-wide wrapper shared by all nine. If a front-and-back card is the whole requirement, the nine CSS card flip animations reach it in about twenty lines apiece.
Do any of these nine, including the css 3d tilt effect, need JavaScript to run?
No — all nine, including the hover tilt in item 03, run entirely on a CSS animation or transition once a browser supports the three properties covered above. The only place a script shows up at all is the .is-demo class the live grid uses to auto-loop every effect for a viewer who can't hover a fixed iframe; the standalone files in the zip drop that class and answer to a real cursor, a keyboard, or a tap instead.
Why does rotateY(0 * 72deg) break the 3D carousel instead of just rotating by zero?
Because Sass interpolates #{$i} * #{$step} as a literal string rather than doing the multiplication itself, the browser ends up reading rotateY(0 * 72deg) as a raw value in its transform list, and outside of calc() that list doesn't evaluate arithmetic written that way — so the whole declaration is dropped rather than rounded to zero. Multiplying inside Sass before the value reaches the compiled CSS avoids the issue completely.









