09ポインタがないときの自動回転
ポインタがなければ場面はゆっくり自分で回り、動きを減らす設定では止まります。
使いどころキオスクの待機画面、アクセシビリティ設定を守る紹介セクション
prefers-reduced-motionmatchMediaanimation-duration

<main class="stage">
<div class="ar is-demo" id="ar">
<div class="ar__scene" id="scene" role="img" aria-label="ポインタがなければ自分で回る箱、動きを減らす設定では止まる">
<div class="ar__prism ar__prism--auto" id="prism">
<span class="ar__side ar__side--a"></span>
<span class="ar__side ar__side--b"></span>
<span class="ar__side ar__side--c"></span>
<span class="ar__side ar__side--d"></span>
</div>
</div>
<p class="ar__state" id="state">
<span class="ar__mode ar__mode--auto">ポインタなし・自動で回転</span>
<span class="ar__mode ar__mode--hand">マウスに合わせて回転</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포인터가 없을 때의 자동 회전 — 포인터가 있는 기기(hover: hover and pointer: fine)면 사람이 돌리고,
// 없으면 CSS 애니메이션이 스스로 천천히 돌린다. 움직임 최소화 설정에서는 회전을 멈추고 한 면을 세워 둔다.
// 격자 미리보기(.is-demo)는 자동 회전 쪽을 그대로 보여 준다 — 미리보기 장면이 곧 실제 자동 상태다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
@include stage($stage-orange);
.ar { display: grid; justify-items: center; gap: $sp-4; width: 100%; }
.ar__scene {
display: grid; place-items: center;
width: clamp(112px, 30vw, 144px); height: clamp(96px, 26vw, 124px);
perspective: 520px;
}
.ar__scene:focus-visible { outline: 3px solid $subject-ink; outline-offset: $sp-2; border-radius: $r-sm; }
.ar__prism {
--s: clamp(64px, 17vw, 84px);
position: relative; width: var(--s); height: var(--s);
transform-style: preserve-3d;
transform: rotateX(-10deg) rotateY(0deg);
}
.ar__side {
position: absolute; inset: 0;
border-radius: $r-sm;
border: 2px solid rgba($subject-cream, .5);
}
.ar__side--a { background: $subject-cream; transform: translateZ(calc(var(--s) / 2)); }
.ar__side--b { background: $subject-blue; transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
.ar__side--c { background: $subject-ink; transform: rotateY(180deg) translateZ(calc(var(--s) / 2)); }
.ar__side--d { background: $stage-yellow; transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.ar__state {
position: relative; margin: 0; min-height: 16px;
color: $subject-ink; font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); text-align: center;
}
// 실제 화면에서는 지금 맡고 있는 쪽 하나만 보인다 — 사람이 돌리기 시작하면 JS 가 글자를 바꾼다
.ar__mode--hand { display: none; }
// 자동 회전은 클래스 하나로 붙였다 뗀다 — 사람이 돌리는 동안에는 이 클래스가 없다
.ar__prism--auto {
animation-name: arTurn;
animation-duration: 8s;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes arTurn {
0% { transform: rotateX(-10deg) rotateY(0deg); }
100% { transform: rotateX(-10deg) rotateY(360deg); }
}
// ── 격자 미리보기: 2초 창 안에 한 바퀴를 다 보여 준다(설정 초의 약수라 gif 이음매가 맞는다).
// 실제 자동 회전은 8초로 훨씬 느리다 — 같은 동작이고 속도만 다르다
.ar.is-demo .ar__prism--auto {
animation-duration: $duration;
}
@media (max-width: 340px) {
.ar { gap: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
// 자동 회전은 장식이다 — 멈추고 한 면을 정면으로 세운다
.ar__prism--auto { animation: none !important; transform: rotateX(-10deg) rotateY(-28deg) !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);
}
.ar {
display: grid;
justify-items: center;
gap: 16px;
width: 100%;
}
.ar__scene {
display: grid;
place-items: center;
width: clamp(112px, 30vw, 144px);
height: clamp(96px, 26vw, 124px);
perspective: 520px;
}
.ar__scene:focus-visible {
outline: 3px solid #17141a;
outline-offset: 8px;
border-radius: 8px;
}
.ar__prism {
--s: clamp(64px, 17vw, 84px);
position: relative;
width: var(--s);
height: var(--s);
transform-style: preserve-3d;
transform: rotateX(-10deg) rotateY(0deg);
}
.ar__side {
position: absolute;
inset: 0;
border-radius: 8px;
border: 2px solid rgba(255, 247, 230, 0.5);
}
.ar__side--a {
background: #fff7e6;
transform: translateZ(calc(var(--s) / 2));
}
.ar__side--b {
background: #2f6df6;
transform: rotateY(90deg) translateZ(calc(var(--s) / 2));
}
.ar__side--c {
background: #17141a;
transform: rotateY(180deg) translateZ(calc(var(--s) / 2));
}
.ar__side--d {
background: #ffd23f;
transform: rotateY(-90deg) translateZ(calc(var(--s) / 2));
}
.ar__state {
position: relative;
margin: 0;
min-height: 16px;
color: #17141a;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
text-align: center;
}
.ar__mode--hand {
display: none;
}
.ar__prism--auto {
animation-name: arTurn;
animation-duration: 8s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes arTurn {
0% {
transform: rotateX(-10deg) rotateY(0deg);
}
100% {
transform: rotateX(-10deg) rotateY(360deg);
}
}
.ar.is-demo .ar__prism--auto {
animation-duration: 2s;
}
@media (max-width: 340px) {
.ar {
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ar__prism--auto {
animation: none !important;
transform: rotateX(-10deg) rotateY(-28deg) !important;
}
}var ar = document.getElementById('ar');
var scene = document.getElementById('scene');
var prism = document.getElementById('prism');
var state = document.getElementById('state');
var AUTO_LABEL = 'ポインタなし・自動で回転';
var HAND_LABEL = 'マウスに合わせて回転';
// 포인터가 아예 없는 기기인지 먼저 묻는다 — 있으면 사람이 돌리고, 없으면 스스로 돈다
var fine = window.matchMedia('(hover: hover) and (pointer: fine)');
function handOver(deg) {
ar.classList.remove('is-demo');
prism.classList.remove('ar__prism--auto');
prism.style.transform = 'rotateX(-10deg) rotateY(' + deg.toFixed(1) + 'deg)';
state.textContent = HAND_LABEL;
}
function backToAuto() {
prism.style.transform = '';
prism.classList.add('ar__prism--auto');
state.textContent = AUTO_LABEL;
}
if (fine.matches) {
scene.addEventListener('pointermove', function (e) {
var r = scene.getBoundingClientRect();
handOver(Math.max(-1, Math.min(1, (e.clientX - r.left) / r.width * 2 - 1)) * 42);
});
scene.addEventListener('pointerleave', backToAuto);
}
// 키보드 경로: 좌우 화살표가 12도씩 돌리고, Escape 가 자동 회전으로 돌려놓는다
scene.setAttribute('tabindex', '0');
var keyDeg = 0;
scene.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { backToAuto(); return; }
var turn = e.key === 'ArrowLeft' ? -12 : e.key === 'ArrowRight' ? 12 : 0;
if (!turn) { return; }
e.preventDefault();
keyDeg = Math.max(-42, Math.min(42, keyDeg + turn));
handOver(keyDeg);
});// AutoRotateReduce — vanilla 판(run/290/demo/09-pointer-3d-auto-reduce/index.html) 의 React 포트.
// 포인터가 있는 기기(hover: hover and pointer: fine)면 사람이 마우스를 따라 돌리고, 없으면 CSS 애니메이션이 스스로 돈다.
// 사람이 돌리는 동안에는 자동 회전 클래스(prismAuto)를 떼고 inline transform 을 직접 쓴다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AutoRotateReduce.module.scss";
export type AutoRotateReduceProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// 전역 DOM 타입에 기대지 않는다 — 포인터·키보드 이벤트에서 실제로 읽는 것만 적는다
type PtrEvt = { clientX: number };
type KeyEvt = { key: string; preventDefault(): void };
const AUTO_LABEL = "포인터 없음 · 스스로 회전";
const HAND_LABEL = "마우스를 따라 회전";
const SCENE_LABEL = "포인터가 없으면 스스로 도는 상자, 움직임 최소화 설정에서는 멈춘다";
export default function AutoRotateReduce({
duration = 2000,
easing = "linear",
color = "#fff7e6",
}: AutoRotateReduceProps) {
const sceneRef = useRef<HTMLDivElement | null>(null);
const prismRef = useRef<HTMLDivElement | null>(null);
const keyDegRef = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [auto, setAuto] = useState(true);
const [label, setLabel] = useState(AUTO_LABEL);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 사람이 잡은 순간: 자동 회전 클래스를 떼고 각도를 직접 쓴다
function handOver(deg: number): void {
const prism = prismRef.current;
if (!prism) return;
setDemo(false);
setAuto(false);
prism.style.transform = "rotateX(-10deg) rotateY(" + deg.toFixed(1) + "deg)";
setLabel(HAND_LABEL);
}
// 손을 놓은 순간: inline transform 을 지우고 자동 회전 클래스를 다시 붙인다 (is-demo 는 그대로 둔다 — vanilla 와 같다)
function backToAuto(): void {
const prism = prismRef.current;
if (!prism) return;
prism.style.transform = "";
setAuto(true);
setLabel(AUTO_LABEL);
}
// 포인터가 아예 없는 기기인지 먼저 묻는다 — 있으면 사람이 돌리고, 없으면 스스로 돈다
useEffect(() => {
const fine = window.matchMedia("(hover: hover) and (pointer: fine)");
const sceneBox = sceneRef.current as Box<HTMLDivElement> | null;
if (!fine.matches || !sceneBox) return;
const scene = sceneBox.current;
function onMove(event: PtrEvt): void {
const r = scene.getBoundingClientRect();
handOver(Math.max(-1, Math.min(1, ((event.clientX - r.left) / r.width) * 2 - 1)) * 42);
}
function onLeave(): void {
backToAuto();
}
scene.addEventListener("pointermove", onMove);
scene.addEventListener("pointerleave", onLeave);
return () => {
scene.removeEventListener("pointermove", onMove);
scene.removeEventListener("pointerleave", onLeave);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 키보드 경로: 좌우 화살표가 12도씩 돌리고, Escape 가 자동 회전으로 돌려놓는다
function onKeyDown(event: KeyEvt): void {
if (event.key === "Escape") {
backToAuto();
return;
}
const turn = event.key === "ArrowLeft" ? -12 : event.key === "ArrowRight" ? 12 : 0;
if (!turn) return;
event.preventDefault();
keyDegRef.current = Math.max(-42, Math.min(42, keyDegRef.current + turn));
handOver(keyDegRef.current);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const prismCls = [styles.prism, auto ? styles.prismAuto : ""].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}
onKeyDown={onKeyDown}
>
<div className={prismCls} ref={prismRef}>
<span className={`${styles.side} ${styles.sideA}`}></span>
<span className={`${styles.side} ${styles.sideB}`}></span>
<span className={`${styles.side} ${styles.sideC}`}></span>
<span className={`${styles.side} ${styles.sideD}`}></span>
</div>
</div>
<p className={styles.state}>
{demo ? (
<>
<span>{AUTO_LABEL}</span>
<span className={styles.modeHand}>{HAND_LABEL}</span>
</>
) : (
label
)}
</p>
</div>
</main>
);
}09 ポインタがないときの自動回転 — 計測 累積面積 7.72% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · マウス 追従 アニメーション css で作る3D場面9選
このデモが属する記事
マウス 追従 アニメーション css で作る3D場面9選マウス追従アニメーションとは、ポインタの位置に反応して要素が回る CSS 3D 回転の場面です。「マウス 追従 アニメーション css」の9つの場面は perspective と preserve-3d、素の JavaScript のポイン この記事のデモをすべて見る


