01값이 문턱을 넘을 때마다 다음 면으로 넘어가는 정육면체 로더
진행률 값을 미리 정한 문턱 목록과 비교해 그 값이 넘은 문턱 수만큼 정육면체를 회전시키고, 매 순간 관찰자와의 거리 순서로 면을 다시 정렬해 뒤쪽 면이 앞을 가리지 않게 한다.
쓰임파일 변환 진행률, 설치 단계 표시
FACE_THRESHOLDNORMALSbackface-visibility

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</p>
<div class="ld__field" id="field" role="application" aria-label="정육면체 면 전환 진행 실험판" tabindex="0">
<div class="c3__scene">
<div class="c3__cube" id="c3cube">
<div class="c3__face c3__face--front">1</div>
<div class="c3__face c3__face--right">2</div>
<div class="c3__face c3__face--back">3</div>
<div class="c3__face c3__face--left">4</div>
</div>
</div>
<b class="ld__badge" aria-hidden="true"><span class="ld__badgeNum" id="pct">0%</span><span class="ld__badgeUnit">진행</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 정육면체 면 전환 로더 — 진행률(0~1)을 문턱 목록과 비교해 넘은 문턱 수만큼 큐브를 90deg 씩 돌리고,
// 매 프레임 화가 알고리즘(면 법선 각 + 현재 회전각의 cos)으로 z-index 를 다시 매긴다.
// 395(캔버스 rAF 물리)·393(포인터 tilt) 과 다른 축 — CSS perspective/preserve-3d 위에 문턱·깊이정렬 계산을 얹는다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-ink);
.ld {
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 360px;
}
.ld__hint {
margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: rgba($subject-cream, .78);
text-align: center;
}
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
display: grid; place-items: center;
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.c3__scene {
perspective: clamp(280px, 60vw, 420px);
}
.c3__cube {
position: relative;
width: 92px; height: 92px;
transform-style: preserve-3d;
}
.c3__face {
position: absolute; inset: 0;
display: grid; place-items: center;
border-radius: $r-sm;
font-weight: 700; font-size: 26px; line-height: 1;
color: $subject-cream;
background: $color;
backface-visibility: hidden;
box-shadow: $shadow-press;
}
.c3__face--front { transform: translateZ(46px); }
.c3__face--right { transform: rotateY(90deg) translateZ(46px); background: darken($color, 8%); }
.c3__face--back { transform: rotateY(180deg) translateZ(46px); }
.c3__face--left { transform: rotateY(-90deg) translateZ(46px); background: darken($color, 8%); }
.ld__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.ld__badgeNum { font-size: 13px; color: $stage-yellow; }
.ld__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.ld { gap: $sp-1; }
.ld__field { height: 124px; }
.c3__cube { width: 64px; height: 64px; }
.c3__face--front, .c3__face--back { transform: translateZ(32px); }
.c3__face--right { transform: rotateY(90deg) translateZ(32px); background: darken($color, 8%); }
.c3__face--left { transform: rotateY(-90deg) translateZ(32px); background: darken($color, 8%); }
.c3__face--back { transform: rotateY(180deg) translateZ(32px); }
.ld__badge { font-size: 10px; padding: 0 $sp-1; }
.ld__badgeNum { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field { cursor: default !important; }
.ld.is-still .ld__badge { opacity: .92 !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);
}
.ld {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 360px;
}
.ld__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: rgba(255, 247, 230, 0.78);
text-align: center;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
display: grid;
place-items: center;
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.c3__scene {
perspective: clamp(280px, 60vw, 420px);
}
.c3__cube {
position: relative;
width: 92px;
height: 92px;
transform-style: preserve-3d;
}
.c3__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 8px;
font-weight: 700;
font-size: 26px;
line-height: 1;
color: #fff7e6;
background: #0a4fe8;
backface-visibility: hidden;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.c3__face--front {
transform: translateZ(46px);
}
.c3__face--right {
transform: rotateY(90deg) translateZ(46px);
background: rgb(3.2604116027%, 25.757251661%, 75.6415491817%);
}
.c3__face--back {
transform: rotateY(180deg) translateZ(46px);
}
.c3__face--left {
transform: rotateY(-90deg) translateZ(46px);
background: rgb(3.2604116027%, 25.757251661%, 75.6415491817%);
}
.ld__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ld__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.ld__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.ld {
gap: 4px;
}
.ld__field {
height: 124px;
}
.c3__cube {
width: 64px;
height: 64px;
}
.c3__face--front, .c3__face--back {
transform: translateZ(32px);
}
.c3__face--right {
transform: rotateY(90deg) translateZ(32px);
background: rgb(3.2604116027%, 25.757251661%, 75.6415491817%);
}
.c3__face--left {
transform: rotateY(-90deg) translateZ(32px);
background: rgb(3.2604116027%, 25.757251661%, 75.6415491817%);
}
.c3__face--back {
transform: rotateY(180deg) translateZ(32px);
}
.ld__badge {
font-size: 10px;
padding: 0 4px;
}
.ld__badgeNum {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ld.is-still .ld__field {
cursor: default !important;
}
.ld.is-still .ld__badge {
opacity: 0.92 !important;
}
}(function () {
'use strict';
var root = document.getElementById('ld');
var field = document.getElementById('field');
var pctEl = document.getElementById('pct');
var cube = document.getElementById('c3cube');
var faces = Array.prototype.slice.call(cube.querySelectorAll('.c3__face'));
var NORMALS = [0, 90, 180, 270]; // front·right·back·left 면 법선 각
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2; // 로더 한 바퀴(초) — 캡처 창(2초)의 약수라 gif 가 끊기지 않는다
var REDUCE_P = 0.7;
var FACE_THRESHOLD = 25; // 면 하나를 넘길 진행률 문턱(%) — 변형 01 대상
var t0 = 0, raf = 0, paused = false, lastP = 0;
// 화가 알고리즘 — 각 면의 (법선각 + 현재 회전각) 코사인을 깊이로 써서 뒤에서 앞 순서로 z-index 를 매긴다.
function depthSort(rotY) {
var withDepth = faces.map(function (el, i) {
return { el: el, depth: Math.cos((NORMALS[i] + rotY) * Math.PI / 180) };
});
withDepth.sort(function (a, b) { return a.depth - b.depth; });
withDepth.forEach(function (o, idx) { o.el.style.zIndex = String(idx); });
}
function render(p) {
lastP = p;
var pct = Math.round(p * 100);
var idx = Math.min(faces.length - 1, Math.floor(pct / FACE_THRESHOLD));
var rotY = idx * 90;
cube.style.transform = 'rotateY(' + rotY + 'deg)';
depthSort(rotY);
pctEl.textContent = pct + '%';
}
function frame(now) {
if (!paused) { var t = (now - t0) / 1000; render((t % LOOP) / LOOP); }
raf = requestAnimationFrame(frame);
}
function togglePause() {
paused = !paused;
root.classList.toggle('is-paused', paused);
render(reduce ? REDUCE_P : lastP);
}
function restart() {
t0 = performance.now();
render(reduce ? REDUCE_P : 0);
}
field.addEventListener('click', function () { togglePause(); });
field.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePause(); }
else if (e.key === 'r' || e.key === 'R') { e.preventDefault(); restart(); }
});
window.addEventListener('resize', function () {
if (paused || reduce) render(reduce ? REDUCE_P : lastP);
});
t0 = performance.now();
if (reduce) {
root.classList.add('is-still');
render(REDUCE_P);
} else {
raf = requestAnimationFrame(frame);
}
})();// CubeFaceSwap — vanilla 판(run/399/demo/01-loader-3d-cube-face-percent-swap/index.html) 의 React 포트.
// 진행률(0~1)이 FACE_THRESHOLD 문턱을 넘을 때마다 큐브를 90deg 돌리고, 매 프레임 화가 알고리즘
// (면 법선 각 + 현재 회전각의 cos) 으로 z-index 를 다시 매긴다. 움직임은 rAF 루프가 다 계산하므로
// @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CubeFaceSwap.module.scss";
export type CubeFaceSwapProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const NORMALS = [0, 90, 180, 270]; // front·right·back·left 면 법선 각
const LOOP = 2; // 로더 한 바퀴(초) — 캡처 창(2초)의 약수라 gif 가 끊기지 않는다
const REDUCE_P = 0.7;
const FACE_THRESHOLD = 25; // 면 하나를 넘길 진행률 문턱(%) — 변형 01 대상
const FIELD_LABEL = "정육면체 면 전환 진행 실험판";
export default function CubeFaceSwap({
duration = "2s", // vanilla LOOP
easing = "linear", // vanilla $easing
color = "#0a4fe8", // vanilla $color: $subject-blue-deep
}: CubeFaceSwapProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cubeRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const facesRef = useRef<HTMLDivElement[]>([]) as Box<HTMLDivElement[]>;
const pausedRef = useRef(false) as Box<boolean>;
const lastPRef = useRef(0) as Box<number>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 화가 알고리즘 — 각 면의 (법선각 + 현재 회전각) 코사인을 깊이로 써서 뒤에서 앞 순서로 z-index 를 매긴다.
function depthSort(rotY: number): void {
const faces = facesRef.current;
const withDepth = faces.map((el, i) => ({ el, depth: Math.cos((NORMALS[i] + rotY) * Math.PI / 180) }));
withDepth.sort((a, b) => a.depth - b.depth);
withDepth.forEach((o, idx) => { o.el.style.zIndex = String(idx); });
}
function render(p: number): void {
lastPRef.current = p;
const pct = Math.round(p * 100);
const faces = facesRef.current;
const idx = Math.min(faces.length - 1, Math.floor(pct / FACE_THRESHOLD));
const rotY = idx * 90;
const cube = cubeRef.current;
if (cube) cube.style.transform = "rotateY(" + rotY + "deg)";
depthSort(rotY);
if (pctRef.current) pctRef.current.textContent = pct + "%";
}
function togglePause(): void {
pausedRef.current = !pausedRef.current;
const root = rootRef.current;
if (root) root.classList.toggle("is-paused", pausedRef.current);
render(reduced() ? REDUCE_P : lastPRef.current);
}
function restart(): void {
t0Ref.current = performance.now();
render(reduced() ? REDUCE_P : 0);
}
function onKey(e: KeyEvt): void {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); togglePause(); }
else if (e.key === "r" || e.key === "R") { e.preventDefault(); restart(); }
}
function onClick(): void {
togglePause();
}
function onResize(): void {
if (pausedRef.current || reduced()) render(reduced() ? REDUCE_P : lastPRef.current);
}
function frame(now: number): void {
if (!pausedRef.current) {
const t = (now - t0Ref.current) / 1000;
render((t % LOOP) / LOOP);
}
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const cube = cubeRef.current;
if (cube) facesRef.current = Array.prototype.slice.call(cube.children) as HTMLDivElement[];
t0Ref.current = performance.now();
window.addEventListener("resize", onResize);
if (reduced()) {
const root = rootRef.current;
if (root) root.classList.add("is-still");
render(REDUCE_P);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>클릭하거나 스페이스바를 누르면 멈추거나 다시 갑니다</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onClick={onClick}
onKeyDown={onKey}
>
<div className={styles.scene}>
<div className={styles.cube} ref={cubeRef}>
<div className={styles.face + " " + styles.faceFront}>1</div>
<div className={styles.face + " " + styles.faceRight}>2</div>
<div className={styles.face + " " + styles.faceBack}>3</div>
<div className={styles.face + " " + styles.faceLeft}>4</div>
</div>
</div>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={pctRef}>0%</span><span className={styles.badgeUnit}>진행</span></b>
</div>
</div>
</main>
);
}01 값이 문턱을 넘을 때마다 다음 면으로 넘어가는 정육면체 로더 — 측정 누적면적 8.577% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 로딩바 애니메이션 9가지 — 3D 계산형 로더
이 데모가 속한 글
css 로딩바 애니메이션 9가지 — 3D 계산형 로더css 로딩바 애니메이션도 아홉 개 모두 트윈이 아니라 계산으로 돕니다. 로딩이 시작되는 첫 프레임에는 평평한 정면 한 장만 보이지만, 값이 문턱을 넘을 때마다 정육면체 면이 넘어가고 와이어프레임 정점이 제자리로 모 이 글의 데모 전부 보기


