05層になって浮かぶ3D文字
同じ文字を何枚も重ねて浮かせ、層ごとに違う角度で傾けて厚みを作ります。
使いどころイベントページの大見出し、ポートフォリオ冒頭の名前
translateZperspectiverotateX

<main class="stage">
<div class="tx is-demo" id="tx">
<div class="tx__scene" id="scene" role="img" aria-label="LAYERS という文字が5層に浮かび、マウスに合わせて傾くモデル">
<div class="tx__stack" id="stack">
<span class="tx__layer" aria-hidden="true">LAYERS</span>
<span class="tx__layer" aria-hidden="true">LAYERS</span>
<span class="tx__layer" aria-hidden="true">LAYERS</span>
<span class="tx__layer" aria-hidden="true">LAYERS</span>
<span class="tx__layer tx__layer--top">LAYERS</span>
</div>
</div>
<p class="tx__hint">マウスを動かすと文字の層が開きます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 층층이 뜬 3D 글자 — 같은 낱말을 다섯 겹 겹치고 겹마다 translateZ 를 달리해 두께를 만든다.
// 실제 기울기는 JS 가 포인터 좌표를 rotateX ±10도 · rotateY ±18도 로 바꿔 묶음에 준다.
// 격자 미리보기(.is-demo)는 같은 범위를 @keyframes 궤적으로 왕복시킨다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
@include stage($stage-orange);
.tx { display: grid; justify-items: center; gap: $sp-4; width: 100%; }
.tx__scene {
display: grid; place-items: center;
width: clamp(200px, 54vw, 268px); height: clamp(72px, 19vw, 92px);
perspective: 520px;
}
.tx__scene:focus-visible { outline: 3px solid $subject-ink; outline-offset: $sp-2; border-radius: $r-sm; }
.tx__stack {
position: relative; transform-style: preserve-3d;
transform: rotateX(0deg) rotateY(0deg);
}
.tx__layer {
position: absolute; left: 50%; top: 50%;
// 겹은 자기 자리에서 뒤로 물러나기만 한다 — 가운데 정렬은 translate 로 먼저 맞춘다
transform: translate(-50%, -50%) translateZ(var(--z));
color: rgba($subject-ink, .55);
font-weight: 800; font-size: clamp(26px, 8vw, 40px); line-height: 1; letter-spacing: .04em;
white-space: nowrap;
}
.tx__layer:nth-child(1) { --z: -32px; }
.tx__layer:nth-child(2) { --z: -24px; }
.tx__layer:nth-child(3) { --z: -16px; }
.tx__layer:nth-child(4) { --z: -8px; }
.tx__layer--top {
--z: 0px;
position: relative; left: auto; top: auto;
transform: translateZ(0);
color: $color;
}
.tx__hint { margin: 0; color: $subject-ink; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); }
// ── 미리보기 루프: 포인터가 왼쪽 위 → 오른쪽 아래로 지나간 것과 같은 궤적.
// 정면(0도)을 느리게 지나고 양 끝에서 빠르게 꺾어 poster 가 납작한 장면으로 뽑히지 않게 한다
.tx.is-demo .tx__stack {
animation-name: txTilt;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes txTilt {
0% { transform: rotateX(10deg) rotateY(-18deg); }
8% { transform: rotateX(7deg) rotateY(-8deg); }
26% { transform: rotateX(2deg) rotateY(-2deg); }
44% { transform: rotateX(-2deg) rotateY(2deg); }
52% { transform: rotateX(-7deg) rotateY(8deg); }
58% { transform: rotateX(-10deg) rotateY(18deg); }
66% { transform: rotateX(-7deg) rotateY(8deg); }
84% { transform: rotateX(0deg) rotateY(0deg); }
94% { transform: rotateX(7deg) rotateY(-8deg); }
100% { transform: rotateX(10deg) rotateY(-18deg); }
}
@media (max-width: 340px) {
.tx { gap: $sp-3; }
.tx__layer:nth-child(1) { --z: -24px; }
.tx__layer:nth-child(2) { --z: -18px; }
.tx__layer:nth-child(3) { --z: -12px; }
.tx__layer:nth-child(4) { --z: -6px; }
}
@media (prefers-reduced-motion: reduce) {
// 움직임 없이 층이 보이는 각도로 세운다
.tx.is-demo .tx__stack { animation: none !important; transform: rotateX(6deg) rotateY(-14deg) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tx {
display: grid;
justify-items: center;
gap: 16px;
width: 100%;
}
.tx__scene {
display: grid;
place-items: center;
width: clamp(200px, 54vw, 268px);
height: clamp(72px, 19vw, 92px);
perspective: 520px;
}
.tx__scene:focus-visible {
outline: 3px solid #17141a;
outline-offset: 8px;
border-radius: 8px;
}
.tx__stack {
position: relative;
transform-style: preserve-3d;
transform: rotateX(0deg) rotateY(0deg);
}
.tx__layer {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) translateZ(var(--z));
color: rgba(23, 20, 26, 0.55);
font-weight: 800;
font-size: clamp(26px, 8vw, 40px);
line-height: 1;
letter-spacing: 0.04em;
white-space: nowrap;
}
.tx__layer:nth-child(1) {
--z: -32px;
}
.tx__layer:nth-child(2) {
--z: -24px;
}
.tx__layer:nth-child(3) {
--z: -16px;
}
.tx__layer:nth-child(4) {
--z: -8px;
}
.tx__layer--top {
--z: 0px;
position: relative;
left: auto;
top: auto;
transform: translateZ(0);
color: #fff7e6;
}
.tx__hint {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
}
.tx.is-demo .tx__stack {
animation-name: txTilt;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes txTilt {
0% {
transform: rotateX(10deg) rotateY(-18deg);
}
8% {
transform: rotateX(7deg) rotateY(-8deg);
}
26% {
transform: rotateX(2deg) rotateY(-2deg);
}
44% {
transform: rotateX(-2deg) rotateY(2deg);
}
52% {
transform: rotateX(-7deg) rotateY(8deg);
}
58% {
transform: rotateX(-10deg) rotateY(18deg);
}
66% {
transform: rotateX(-7deg) rotateY(8deg);
}
84% {
transform: rotateX(0deg) rotateY(0deg);
}
94% {
transform: rotateX(7deg) rotateY(-8deg);
}
100% {
transform: rotateX(10deg) rotateY(-18deg);
}
}
@media (max-width: 340px) {
.tx {
gap: 12px;
}
.tx__layer:nth-child(1) {
--z: -24px;
}
.tx__layer:nth-child(2) {
--z: -18px;
}
.tx__layer:nth-child(3) {
--z: -12px;
}
.tx__layer:nth-child(4) {
--z: -6px;
}
}
@media (prefers-reduced-motion: reduce) {
.tx.is-demo .tx__stack {
animation: none !important;
transform: rotateX(6deg) rotateY(-14deg) !important;
}
}var tx = document.getElementById('tx');
var scene = document.getElementById('scene');
var stack = document.getElementById('stack');
var aimX = 0, aimY = 0, nowX = 0, nowY = 0, running = false;
var MAX_Y = 18, MAX_X = 10; // 미리보기 키프레임과 같은 범위
function step() {
nowX += (aimX - nowX) * 0.14;
nowY += (aimY - nowY) * 0.14;
stack.style.transform = 'rotateX(' + nowX.toFixed(2) + 'deg) rotateY(' + nowY.toFixed(2) + 'deg)';
if (Math.abs(aimX - nowX) < 0.04 && Math.abs(aimY - nowY) < 0.04) { running = false; return; }
requestAnimationFrame(step);
}
function start() { if (!running) { running = true; requestAnimationFrame(step); } }
scene.addEventListener('pointermove', function (e) {
tx.classList.remove('is-demo');
var r = scene.getBoundingClientRect();
aimY = Math.max(-1, Math.min(1, (e.clientX - r.left) / r.width * 2 - 1)) * MAX_Y;
aimX = Math.max(-1, Math.min(1, (e.clientY - r.top) / r.height * 2 - 1)) * -MAX_X;
start();
});
scene.addEventListener('pointerleave', function () { aimX = 0; aimY = 0; start(); });
// 키보드 경로: 화살표로 5도씩 기울인다
scene.setAttribute('tabindex', '0');
scene.addEventListener('keydown', function (e) {
var keys = { ArrowLeft: [0, -5], ArrowRight: [0, 5], ArrowUp: [5, 0], ArrowDown: [-5, 0] };
var move = keys[e.key];
if (!move) { return; }
e.preventDefault();
tx.classList.remove('is-demo');
aimX = Math.max(-MAX_X, Math.min(MAX_X, aimX + move[0]));
aimY = Math.max(-MAX_Y, Math.min(MAX_Y, aimY + move[1]));
start();
});// TextLayers3D — vanilla 판(run/290/demo/05-pointer-3d-text-layers/index.html) 의 React 포트.
// 포인터 좌표를 rotateX ±10도 · rotateY ±18도 로 바꿔 묶음에 주고, 목표값까지 프레임마다 14% 씩 다가간다.
// 목표에 0.04도 안으로 붙으면 루프를 끊어 idle 프레임을 돌리지 않는다. 값·수치는 vanilla 와 같다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./TextLayers3D.module.scss";
export type TextLayers3DProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type Tilt = { x: number; y: number };
// 키보드 이벤트는 ambient react 타입에 없다 — 쓰는 두 가지만 지역 타입으로 적는다
type KeyEvt = { key: string; preventDefault(): void };
const MAX_Y = 18; // 미리보기 키프레임과 같은 범위
const MAX_X = 10;
const EASE_RATE = 0.14;
const STOP_AT = 0.04;
const KEY_STEP = 5;
const WORD = "LAYERS";
const SCENE_LABEL = "LAYERS 라는 글자가 다섯 겹으로 떠서 마우스를 따라 기우는 모형";
const HINT = "마우스를 옮기면 글자 층이 벌어집니다";
function clamp(value: number, limit: number): number {
return Math.max(-limit, Math.min(limit, value));
}
export default function TextLayers3D({
duration = 2000,
easing = "linear",
color = "#fff7e6",
}: TextLayers3DProps) {
const stackRef = useRef<HTMLDivElement | null>(null);
const aimRef = useRef<Tilt>({ x: 0, y: 0 }) as Box<Tilt>;
const nowRef = useRef<Tilt>({ x: 0, y: 0 }) as Box<Tilt>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const step = useCallback((): void => {
const aim = aimRef.current;
const now = nowRef.current;
now.x += (aim.x - now.x) * EASE_RATE;
now.y += (aim.y - now.y) * EASE_RATE;
const stack = stackRef.current;
if (stack) {
stack.style.transform = "rotateX(" + now.x.toFixed(2) + "deg) rotateY(" + now.y.toFixed(2) + "deg)";
}
if (Math.abs(aim.x - now.x) < STOP_AT && Math.abs(aim.y - now.y) < STOP_AT) {
runningRef.current = false;
return;
}
frameRef.current = requestAnimationFrame(step);
}, []);
const start = useCallback((): void => {
if (!runningRef.current) {
runningRef.current = true;
frameRef.current = requestAnimationFrame(step);
}
}, [step]);
useEffect(() => {
return () => cancelAnimationFrame(frameRef.current);
}, []);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
setDemo(false);
const r = event.currentTarget.getBoundingClientRect();
aimRef.current.y = Math.max(-1, Math.min(1, ((event.clientX - r.left) / r.width) * 2 - 1)) * MAX_Y;
aimRef.current.x = Math.max(-1, Math.min(1, ((event.clientY - r.top) / r.height) * 2 - 1)) * -MAX_X;
start();
}
function onPointerLeave(): void {
aimRef.current.x = 0;
aimRef.current.y = 0;
start();
}
// 키보드 경로: 화살표로 5도씩 기울인다
function onKeyDown(event: KeyEvt): void {
const keys: { [key: string]: [number, number] } = {
ArrowLeft: [0, -KEY_STEP],
ArrowRight: [0, KEY_STEP],
ArrowUp: [KEY_STEP, 0],
ArrowDown: [-KEY_STEP, 0],
};
const move = keys[event.key];
if (!move) return;
event.preventDefault();
setDemo(false);
aimRef.current.x = clamp(aimRef.current.x + move[0], MAX_X);
aimRef.current.y = clamp(aimRef.current.y + move[1], MAX_Y);
start();
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div
className={styles.scene}
role="img"
aria-label={SCENE_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<div className={styles.stack} ref={stackRef}>
<span className={styles.layer} aria-hidden="true">{WORD}</span>
<span className={styles.layer} aria-hidden="true">{WORD}</span>
<span className={styles.layer} aria-hidden="true">{WORD}</span>
<span className={styles.layer} aria-hidden="true">{WORD}</span>
<span className={`${styles.layer} ${styles.layerTop}`}>{WORD}</span>
</div>
</div>
<p className={styles.hint}>{HINT}</p>
</div>
</main>
);
}05 層になって浮かぶ3D文字 — 計測 累積面積 3.034% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · マウス 追従 アニメーション css で作る3D場面9選
このデモが属する記事
マウス 追従 アニメーション css で作る3D場面9選マウス追従アニメーションとは、ポインタの位置に反応して要素が回る CSS 3D 回転の場面です。「マウス 追従 アニメーション css」の9つの場面は perspective と preserve-3d、素の JavaScript のポイン この記事のデモをすべて見る


