01マウスに合わせて回る製品モックアップ
マウスを左右に動かすと箱がその方向へ回り、離すと弾むように正面へ戻ります。
使いどころ商品詳細ページのメイン画像、グッズ販売ページのプレビュー
perspectiverotateYtransform-style

<main class="stage">
<div class="pr is-demo" id="pr">
<div class="pr__scene" id="scene" role="img" aria-label="マウスに合わせて左右に回る箱のモデル">
<div class="pr__model" id="model">
<div class="pr__face pr__face--front">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="pr__ico" aria-hidden="true"><g fill="currentColor"><path d="M128 129.09V232a8 8 0 0 1-3.84-1l-88-48.16a8 8 0 0 1-4.16-7V80.2a8 8 0 0 1 .7-3.27Z" opacity=".2"/><path d="m223.68 66.15l-88-48.15a15.88 15.88 0 0 0-15.36 0l-88 48.17a16 16 0 0 0-8.32 14v95.64a16 16 0 0 0 8.32 14l88 48.17a15.88 15.88 0 0 0 15.36 0l88-48.17a16 16 0 0 0 8.32-14V80.18a16 16 0 0 0-8.32-14.03M128 32l80.34 44L128 120L47.66 76ZM40 90l80 43.78v85.79l-80-43.75Zm96 129.57v-85.75L216 90v85.78Z"/></g></svg>
<span class="pr__name">コーヒー豆 1袋</span>
</div>
<div class="pr__face pr__face--side"><span class="pr__side">250 g</span></div>
<div class="pr__face pr__face--top"></div>
</div>
<span class="pr__floor" aria-hidden="true"></span>
</div>
<p class="pr__hint">マウスを乗せて回してみてください</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 마우스를 따라 도는 제품 목업 — 실제 회전은 JS 가 포인터 좌표를 각도로 바꿔 모델에 인라인 transform 으로 준다.
// 격자 미리보기(.is-demo)는 그 JS 가 만드는 것과 **같은 범위**(rotateY ±16도 · rotateX ±6도)를
// CSS @keyframes 궤적으로 왕복시킨다 — demo.py 는 CSS 애니메이션 시계로 프레임을 뽑으므로
// rAF 로 그리는 각도는 24장이 전부 같은 값이 된다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
@include stage($stage-ink);
.pr { display: grid; justify-items: center; gap: $sp-3; width: 100%; }
// perspective 는 장면(부모)에 두고 preserve-3d 는 도는 요소에 둔다 — 둘을 한 요소에 겹치면 면이 납작해진다
.pr__scene {
position: relative;
width: clamp(132px, 34vw, 168px);
height: clamp(96px, 26vw, 124px);
perspective: 620px;
perspective-origin: 50% 42%;
}
.pr__scene:focus-visible { outline: 3px solid $subject-blue; outline-offset: $sp-2; border-radius: $r-sm; }
.pr__model {
position: absolute; inset: 0; margin: auto;
width: clamp(72px, 19vw, 92px); height: clamp(88px, 23vw, 112px);
transform-style: preserve-3d;
transform: rotateX(0deg) rotateY(0deg);
}
.pr__face { position: absolute; box-sizing: border-box; backface-visibility: hidden; }
// 앞면·옆면·윗면 셋이면 ±16도 안에서 상자로 보인다. 깊이(--d)는 옆면 폭이자 앞면을 앞으로 내미는 거리의 두 배
.pr__model { --d: 52px; }
.pr__face--front {
inset: 0;
display: grid; place-items: center; gap: $sp-1;
background: $color; color: $subject-ink;
border-radius: $r-sm;
transform: translateZ(calc(var(--d) / 2));
padding: $sp-2;
}
.pr__face--side {
top: 0; right: 0; width: var(--d); height: 100%;
display: grid; place-items: center;
background: $subject-blue; color: $stage-paper;
border-radius: $r-sm;
transform: rotateY(90deg) translateZ(calc(var(--d) / 2));
}
.pr__face--top {
top: 0; left: 0; width: 100%; height: var(--d);
background: $subject-blue;
border-radius: $r-sm;
opacity: .58;
transform: rotateX(90deg) translateZ(calc(var(--d) / 2));
}
.pr__ico { width: clamp(20px, 5vw, 26px); height: clamp(20px, 5vw, 26px); color: $subject-blue; }
.pr__name { font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.3; text-align: center; }
.pr__side { font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); }
// 바닥 그림자는 모델 아래 평면에 눕혀 둔다 — 회전할 때 상자만 돌고 그림자는 제자리라 깊이가 읽힌다
.pr__floor {
position: absolute; left: 50%; bottom: -2px;
width: clamp(76px, 20vw, 96px); height: 12px;
transform: translateX(-50%);
border-radius: $r-pill;
background: radial-gradient(closest-side, rgba(0, 0, 0, .52), rgba(0, 0, 0, 0));
}
.pr__hint {
margin: 0; color: rgba(255, 247, 230, .76);
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px);
}
// ── 미리보기 루프: 포인터가 왼쪽 끝 → 오른쪽 끝으로 지나간 것과 같은 궤적.
// 중앙(정면)을 느리게 지나고 양 끝에서 빠르게 꺾는다 — 면적 변화율은 |sin θ| × 각속도라
// 등속으로 돌리면 언제나 정면(θ≈0) 프레임이 최대 변화가 되어 poster 가 납작한 장면으로 뽑힌다.
.pr.is-demo .pr__model {
animation-name: prTurn;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes prTurn {
0% { transform: rotateX(6deg) rotateY(-16deg); }
7% { transform: rotateX(5deg) rotateY(-7deg); }
25% { transform: rotateX(2deg) rotateY(-2deg); }
43% { transform: rotateX(-2deg) rotateY(2deg); }
50% { transform: rotateX(-5deg) rotateY(7deg); }
57% { transform: rotateX(-6deg) rotateY(16deg); }
64% { transform: rotateX(-5deg) rotateY(7deg); }
82% { transform: rotateX(0deg) rotateY(0deg); }
93% { transform: rotateX(5deg) rotateY(-7deg); }
100% { transform: rotateX(6deg) rotateY(-16deg); }
}
@media (max-width: 340px) {
.pr { gap: $sp-2; }
.pr__model { --d: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.pr.is-demo .pr__model { animation: none !important; transform: rotateX(2deg) 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pr {
display: grid;
justify-items: center;
gap: 12px;
width: 100%;
}
.pr__scene {
position: relative;
width: clamp(132px, 34vw, 168px);
height: clamp(96px, 26vw, 124px);
perspective: 620px;
perspective-origin: 50% 42%;
}
.pr__scene:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 8px;
border-radius: 8px;
}
.pr__model {
position: absolute;
inset: 0;
margin: auto;
width: clamp(72px, 19vw, 92px);
height: clamp(88px, 23vw, 112px);
transform-style: preserve-3d;
transform: rotateX(0deg) rotateY(0deg);
}
.pr__face {
position: absolute;
box-sizing: border-box;
backface-visibility: hidden;
}
.pr__model {
--d: 52px;
}
.pr__face--front {
inset: 0;
display: grid;
place-items: center;
gap: 4px;
background: #fff7e6;
color: #17141a;
border-radius: 8px;
transform: translateZ(calc(var(--d) / 2));
padding: 8px;
}
.pr__face--side {
top: 0;
right: 0;
width: var(--d);
height: 100%;
display: grid;
place-items: center;
background: #2f6df6;
color: #ffffff;
border-radius: 8px;
transform: rotateY(90deg) translateZ(calc(var(--d) / 2));
}
.pr__face--top {
top: 0;
left: 0;
width: 100%;
height: var(--d);
background: #2f6df6;
border-radius: 8px;
opacity: 0.58;
transform: rotateX(90deg) translateZ(calc(var(--d) / 2));
}
.pr__ico {
width: clamp(20px, 5vw, 26px);
height: clamp(20px, 5vw, 26px);
color: #2f6df6;
}
.pr__name {
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.3;
text-align: center;
}
.pr__side {
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
}
.pr__floor {
position: absolute;
left: 50%;
bottom: -2px;
width: clamp(76px, 20vw, 96px);
height: 12px;
transform: translateX(-50%);
border-radius: 999px;
background: radial-gradient(closest-side, rgba(0, 0, 0, 0.52), rgba(0, 0, 0, 0));
}
.pr__hint {
margin: 0;
color: rgba(255, 247, 230, 0.76);
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
}
.pr.is-demo .pr__model {
animation-name: prTurn;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes prTurn {
0% {
transform: rotateX(6deg) rotateY(-16deg);
}
7% {
transform: rotateX(5deg) rotateY(-7deg);
}
25% {
transform: rotateX(2deg) rotateY(-2deg);
}
43% {
transform: rotateX(-2deg) rotateY(2deg);
}
50% {
transform: rotateX(-5deg) rotateY(7deg);
}
57% {
transform: rotateX(-6deg) rotateY(16deg);
}
64% {
transform: rotateX(-5deg) rotateY(7deg);
}
82% {
transform: rotateX(0deg) rotateY(0deg);
}
93% {
transform: rotateX(5deg) rotateY(-7deg);
}
100% {
transform: rotateX(6deg) rotateY(-16deg);
}
}
@media (max-width: 340px) {
.pr {
gap: 8px;
}
.pr__model {
--d: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.pr.is-demo .pr__model {
animation: none !important;
transform: rotateX(2deg) rotateY(-14deg) !important;
}
}var pr = document.getElementById('pr');
var scene = document.getElementById('scene');
var model = document.getElementById('model');
var MAX_Y = 16, MAX_X = 6; // 미리보기 루프의 키프레임과 같은 범위
var aimX = 0, aimY = 0; // 포인터가 요구하는 각도
var nowX = 0, nowY = 0, velX = 0, velY = 0;
var running = false, holding = false;
// 감쇠 추적: 목표로 당기는 힘(stiffness)과 마찰(damping)로 관성과 스프링 복귀를 한 식으로 처리한다
function step() {
velX = (velX + (aimX - nowX) * 0.18) * 0.76;
velY = (velY + (aimY - nowY) * 0.18) * 0.76;
nowX += velX;
nowY += velY;
model.style.transform = 'rotateX(' + nowX.toFixed(2) + 'deg) rotateY(' + nowY.toFixed(2) + 'deg)';
var resting = Math.abs(velX) < 0.01 && Math.abs(velY) < 0.01 && Math.abs(aimX - nowX) < 0.05 && Math.abs(aimY - nowY) < 0.05;
if (resting && !holding) { running = false; return; }
requestAnimationFrame(step);
}
function start() {
if (running) { return; }
running = true;
requestAnimationFrame(step);
}
function aimFrom(e) {
var r = scene.getBoundingClientRect();
var nx = (e.clientX - r.left) / r.width * 2 - 1;
var ny = (e.clientY - r.top) / r.height * 2 - 1;
aimY = Math.max(-1, Math.min(1, nx)) * MAX_Y;
aimX = Math.max(-1, Math.min(1, ny)) * -MAX_X;
}
scene.addEventListener('pointerenter', function () { pr.classList.remove('is-demo'); holding = true; });
scene.addEventListener('pointermove', function (e) { pr.classList.remove('is-demo'); holding = true; aimFrom(e); start(); });
scene.addEventListener('pointerleave', function () { holding = false; aimX = 0; aimY = 0; start(); });
// 키보드 경로: 화살표로 4도씩 돌린다 (포인터가 없는 사람도 같은 각도를 볼 수 있게)
scene.setAttribute('tabindex', '0');
scene.addEventListener('keydown', function (e) {
var keys = { ArrowLeft: [0, -4], ArrowRight: [0, 4], ArrowUp: [4, 0], ArrowDown: [-4, 0] };
var move = keys[e.key];
if (!move) { return; }
e.preventDefault();
pr.classList.remove('is-demo');
holding = true;
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();
});// ProductRotate3D — vanilla 판(run/290/demo/01-pointer-3d-product-rotate/index.html) 의 React 포트.
// 포인터 좌표를 -1~1 로 정규화해 목표 각도(aim)를 만들고, 감쇠 추적으로 현재 각도(now)를 따라가게 한다.
// 목표로 당기는 힘 0.18 과 마찰 0.76 이 관성과 스프링 복귀를 한 식으로 처리한다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProductRotate3D.module.scss";
export type ProductRotate3DProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
const MAX_Y = 16; // 미리보기 루프의 키프레임과 같은 범위
const MAX_X = 6;
const SCENE_LABEL = "마우스를 따라 좌우로 도는 상자 모형";
// 키보드 경로: 화살표로 4도씩 돌린다 (포인터가 없는 사람도 같은 각도를 볼 수 있게)
const KEY_MOVES: { [key: string]: [number, number] } = {
ArrowLeft: [0, -4],
ArrowRight: [0, 4],
ArrowUp: [4, 0],
ArrowDown: [-4, 0],
};
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export default function ProductRotate3D({
duration = 2000,
easing = "linear",
color = "#fff7e6",
}: ProductRotate3DProps) {
const sceneRef = useRef<HTMLDivElement | null>(null);
const modelRef = useRef<HTMLDivElement | null>(null);
const aimXRef = useRef<number>(0) as Box<number>; // 포인터가 요구하는 각도
const aimYRef = useRef<number>(0) as Box<number>;
const nowXRef = useRef<number>(0) as Box<number>;
const nowYRef = useRef<number>(0) as Box<number>;
const velXRef = useRef<number>(0) as Box<number>;
const velYRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const holdingRef = 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 };
useEffect(() => {
return () => cancelAnimationFrame(frameRef.current);
}, []);
// 감쇠 추적: 목표로 당기는 힘(stiffness)과 마찰(damping)로 관성과 스프링 복귀를 한 식으로 처리한다
function step(): void {
velXRef.current = (velXRef.current + (aimXRef.current - nowXRef.current) * 0.18) * 0.76;
velYRef.current = (velYRef.current + (aimYRef.current - nowYRef.current) * 0.18) * 0.76;
nowXRef.current += velXRef.current;
nowYRef.current += velYRef.current;
const model = modelRef.current;
if (model) {
model.style.transform =
"rotateX(" + nowXRef.current.toFixed(2) + "deg) rotateY(" + nowYRef.current.toFixed(2) + "deg)";
}
const resting =
Math.abs(velXRef.current) < 0.01 &&
Math.abs(velYRef.current) < 0.01 &&
Math.abs(aimXRef.current - nowXRef.current) < 0.05 &&
Math.abs(aimYRef.current - nowYRef.current) < 0.05;
if (resting && !holdingRef.current) {
runningRef.current = false;
return;
}
frameRef.current = requestAnimationFrame(step);
}
function start(): void {
if (runningRef.current) return;
runningRef.current = true;
frameRef.current = requestAnimationFrame(step);
}
function aimFrom(event: ReactPointerEvent<HTMLDivElement>): void {
const scene = sceneRef.current;
if (!scene) return;
const r = scene.getBoundingClientRect();
const nx = ((event.clientX - r.left) / r.width) * 2 - 1;
const ny = ((event.clientY - r.top) / r.height) * 2 - 1;
aimYRef.current = clamp(nx, -1, 1) * MAX_Y;
aimXRef.current = clamp(ny, -1, 1) * -MAX_X;
}
function onPointerEnter(): void {
setDemo(false);
holdingRef.current = true;
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
setDemo(false);
holdingRef.current = true;
aimFrom(event);
start();
}
function onPointerLeave(): void {
holdingRef.current = false;
aimXRef.current = 0;
aimYRef.current = 0;
start();
}
function onKeyDown(event: KeyEvt): void {
const move = KEY_MOVES[event.key];
if (!move) return;
event.preventDefault();
setDemo(false);
holdingRef.current = true;
aimXRef.current = clamp(aimXRef.current + move[0], -MAX_X, MAX_X);
aimYRef.current = clamp(aimYRef.current + move[1], -MAX_Y, 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}
ref={sceneRef}
role="img"
aria-label={SCENE_LABEL}
tabIndex={0}
onPointerEnter={onPointerEnter}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<div className={styles.model} ref={modelRef}>
<div className={`${styles.face} ${styles.faceFront}`}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" className={styles.ico} aria-hidden="true"><g fill="currentColor"><path d="M128 129.09V232a8 8 0 0 1-3.84-1l-88-48.16a8 8 0 0 1-4.16-7V80.2a8 8 0 0 1 .7-3.27Z" opacity=".2"/><path d="m223.68 66.15l-88-48.15a15.88 15.88 0 0 0-15.36 0l-88 48.17a16 16 0 0 0-8.32 14v95.64a16 16 0 0 0 8.32 14l88 48.17a15.88 15.88 0 0 0 15.36 0l88-48.17a16 16 0 0 0 8.32-14V80.18a16 16 0 0 0-8.32-14.03M128 32l80.34 44L128 120L47.66 76ZM40 90l80 43.78v85.79l-80-43.75Zm96 129.57v-85.75L216 90v85.78Z"/></g></svg>
<span className={styles.name}>원두 한 봉지</span>
</div>
<div className={`${styles.face} ${styles.faceSide}`}><span className={styles.side}>250 g</span></div>
<div className={`${styles.face} ${styles.faceTop}`}></div>
</div>
<span className={styles.floor} aria-hidden="true"></span>
</div>
<p className={styles.hint}>마우스를 올려 돌려 보세요</p>
</div>
</main>
);
}01 マウスに合わせて回る製品モックアップ — 計測 累積面積 3.96% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · マウス 追従 アニメーション css で作る3D場面9選
このデモが属する記事
マウス 追従 アニメーション css で作る3D場面9選マウス追従アニメーションとは、ポインタの位置に反応して要素が回る CSS 3D 回転の場面です。「マウス 追従 アニメーション css」の9つの場面は perspective と preserve-3d、素の JavaScript のポイン この記事のデモをすべて見る


