css 로딩바 애니메이션 9가지 — 3D 계산형 로더
css 로딩바 애니메이션도 아홉 개 모두 트윈이 아니라 계산으로 돕니다. 로딩이 시작되는 첫 프레임에는 평평한 정면 한 장만 보이지만, 값이 문턱을 넘을 때마다 정육면체 면이 넘어가고 와이어프레임 정점이 제자리로 모여들며, 마지막 프레임에서는 안쪽 고리의 세차각이 가장 크게 벌어진
아홉 개 모두 perspective·transform-style: preserve-3d 위에서 render(p) 하나가 진행률 p(0~1)를 받아 좌표나 각도를 다시 계산합니다. 클릭·스페이스바(정지 토글)·R(재시작)·창 크기 조절 이벤트 네 가지를 그대로 공유하고, Math.random() 은 한 번도 부르지 않은 채 고정 상수와 삼각함수로만 움직임을 만듭니다.
01 loader-3d-cube-face-percent-swap
02 loader-3d-wireframe-vertex-snap
03 loader-3d-card-stack-depth-cycle
04 loader-3d-ring-orbit-tilt-stack
05 loader-3d-sphere-point-converge
06 loader-3d-prism-face-fill-rotate
07 loader-3d-flip-panel-depth-sort
08 loader-3d-terrain-grid-rise-perspective
09 loader-3d-gyroscope-ring-precession
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 값이 문턱을 넘을 때마다 다음 면으로 넘어가는 정육면체 로더
- 02 진행률 값만큼 정점이 제자리로 모이는 와이어프레임 로더
- 03 값이 오를 때마다 뒤 카드가 깊이 순서를 바꿔 앞으로 넘어오는 카드 스택 로더
- 04 값이 오를수록 고리 사이 기울기 차이가 좁혀지는 궤도 로더
- 05 구 표면의 점들이 한 점으로 모이는 수렴 로더
- 06 회전하며 넘는 면부터 색이 채워지는 다각기둥 로더
- 07 패널이 뒤집힐 때마다 앞뒤 순서를 다시 계산하는 플립 로더
- 08 격자점이 값만큼 솟아 지형처럼 보이는 원근 로더
- 09 값이 오를수록 안쪽 고리의 세차각이 벌어지는 자이로스코프 로더
아홉 개는 값이 도형의 어느 부분을 바꾸는가로 나뉩니다. 01·06 은 값을 면 인덱스로 바꿔 큐브·다각기둥의 면을 하나씩 넘기고, 02·05·08 은 값을 좌표 보간 비율로 바꿔 정점·점·격자가 목표 위치로 모이거나 솟아오르며, 03·07 은 값을 깊이 순위로 바꿔 카드·패널을 화가 알고리즘으로 다시 정렬하고, 04·09 는 값을 각도로 바꿔 고리를 좁히거나 세차운동으로 벌립니다.
395 캔버스 rAF 로더·393 포인터 tilt 버튼과 이 아홉 개가 갈라지는 지점
395 의 아홉 로더는 <canvas> 2D 컨텍스트 위에서 굴절·물결 같은 픽셀 단위 계산을 직접 그려 넣는 rAF 물리 데모였고, 393 의 아홉 버튼은 포인터가 움직이는 좌표를 실시간으로 읽어 그 순간의 마우스 위치에 반응해 기울어집니다. 이 편은 그 둘과 다른 세 번째 축입니다 — 좌표를 직접 그리지도, 입력 이벤트에 반응해 기울지도 않고, CSS perspective·transform-style: preserve-3d 가 이미 그려 둔 3D 공간 위에 진행률 p 하나가 면 전환 문턱(01)·정점 lerp 곡선(02)·세차각(09) 같은 계산을 얹습니다.
css 로딩 애니메이션 치고는 입력에 반응하지 않는 대신, 클릭·스페이스바로 멈췄다가 다시 가도 같은 계산이 정확히 같은 프레임을 재현합니다. 화면에 보이는 회전은 브라우저의 트랜지션 보간이 아니라 매 프레임 render(p) 가 새로 정하는 좌표입니다.
01값이 문턱을 넘을 때마다 다음 면으로 넘어가는 정육면체 로더
진행률 pct 를 FACE_THRESHOLD(25) 로 나눈 몫만큼 정육면체를 90도씩 돌리고, 매 프레임 네 면의 법선각(NORMALS)과 현재 회전각의 코사인을 깊이로 삼아 화가 알고리즘으로 z-index 를 다시 매겨 backface-visibility: hidden 뒤판이 앞면을 가리지 않게 합니다. 파일 변환·설치 단계처럼 몇 단계로 딱 나뉘는 진행률에 씁니다.

<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 포함 · 비밀번호 보기
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 + '%';
}
02진행률 값만큼 정점이 제자리로 모이는 와이어프레임 로더
여덟 정점을 흩어둔 SCATTER 좌표에서 정육면체 꼭짓점 TARGETS 까지 Math.pow(p, LERP_EASE_POW) 비율로 옮기고, 옮겨진 정점끼리 선을 이어 큐브 안에서 윤곽이 값이 오를수록 또렷해집니다. 3D 에셋·지도 데이터를 불러오는 화면에 어울립니다.

<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">
<svg class="c3__svg" id="svg">
<line class="c3__edge" id="e0"></line><line class="c3__edge" id="e1"></line>
<line class="c3__edge" id="e2"></line><line class="c3__edge" id="e3"></line>
<line class="c3__edge" id="e4"></line><line class="c3__edge" id="e5"></line>
<line class="c3__edge" id="e6"></line><line class="c3__edge" id="e7"></line>
<line class="c3__edge" id="e8"></line><line class="c3__edge" id="e9"></line>
<line class="c3__edge" id="e10"></line><line class="c3__edge" id="e11"></line>
</svg>
<div class="c3__vtx" id="v0"></div><div class="c3__vtx" id="v1"></div>
<div class="c3__vtx" id="v2"></div><div class="c3__vtx" id="v3"></div>
<div class="c3__vtx" id="v4"></div><div class="c3__vtx" id="v5"></div>
<div class="c3__vtx" id="v6"></div><div class="c3__vtx" id="v7"></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)을 지수 곡선(ease)으로 눌러 보간하고, 매 프레임 getBoundingClientRect() 로
// preserve-3d 가 투영한 실제 화면 좌표를 읽어 SVG 선을 다시 그린다.
// 395(캔버스 rAF 물리)·393(포인터 tilt) 과 다른 축 — 3D 투영 결과의 실측 좌표로 2D 선을 그린다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.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: $subject-ink;
text-align: center;
}
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.c3__scene {
position: absolute; inset: 0;
perspective: clamp(320px, 70vw, 480px);
}
.c3__cube {
position: absolute; left: 50%; top: 50%;
width: 0; height: 0;
transform-style: preserve-3d;
}
.c3__vtx {
position: absolute; left: 0; top: 0;
width: 9px; height: 9px; margin: -4.5px 0 0 -4.5px;
border-radius: 50%;
background: $color;
will-change: transform, opacity;
}
.c3__svg {
position: absolute; inset: 0;
width: 100%; height: 100%;
pointer-events: none;
}
.c3__edge { stroke: $color; stroke-width: 1.6; }
.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; }
.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: #ffd23f;
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: #17141a;
text-align: center;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.c3__scene {
position: absolute;
inset: 0;
perspective: clamp(320px, 70vw, 480px);
}
.c3__cube {
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
transform-style: preserve-3d;
}
.c3__vtx {
position: absolute;
left: 0;
top: 0;
width: 9px;
height: 9px;
margin: -4.5px 0 0 -4.5px;
border-radius: 50%;
background: #17141a;
will-change: transform, opacity;
}
.c3__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.c3__edge {
stroke: #17141a;
stroke-width: 1.6;
}
.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;
}
.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 vtx = [];
for (var vi = 0; vi < 8; vi++) vtx.push(document.getElementById('v' + vi));
var lines = [];
for (var li = 0; li < 12; li++) lines.push(document.getElementById('e' + li));
// 정육면체 모서리(정점 인덱스 비트: 0=x·1=y·2=z, -/+)
var EDGES = [[0,1],[0,2],[0,4],[1,3],[1,5],[2,3],[2,6],[3,7],[4,5],[4,6],[5,7],[6,7]];
var HALF = 42;
var TARGETS = [];
for (var ti = 0; ti < 8; ti++) {
TARGETS.push([(ti & 1) ? HALF : -HALF, (ti & 2) ? HALF : -HALF, (ti & 4) ? HALF : -HALF]);
}
// 시작 산점 좌표 — 고정 배열(실호출 Math.random 없음)
var SCATTER = [
[-70, -55, 10], [60, -65, -20], [-50, 60, 30], [75, 50, -10],
[-65, -30, -40], [55, -20, 45], [-40, 65, -25], [65, 70, 15]
];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2;
var REDUCE_P = 0.7;
var LERP_EASE_POW = 1.8; // 정점 수렴 곡선 지수 — 변형 02 대상 (클수록 막판에 몰려 맺힌다)
var ROT_TURNS = 1; // 한 루프 동안 큐브가 도는 회전수
var t0 = 0, raf = 0, paused = false, lastP = 0;
function ease(p) { return Math.pow(p, LERP_EASE_POW); }
function updateLines(e) {
var fr = field.getBoundingClientRect();
for (var i = 0; i < 12; i++) {
var a = vtx[EDGES[i][0]].getBoundingClientRect();
var b = vtx[EDGES[i][1]].getBoundingClientRect();
var ax = a.left + a.width / 2 - fr.left;
var ay = a.top + a.height / 2 - fr.top;
var bx = b.left + b.width / 2 - fr.left;
var by = b.top + b.height / 2 - fr.top;
var ln = lines[i];
ln.setAttribute('x1', ax); ln.setAttribute('y1', ay);
ln.setAttribute('x2', bx); ln.setAttribute('y2', by);
ln.setAttribute('stroke-opacity', String(0.15 + 0.85 * e));
}
}
function render(p) {
lastP = p;
var e = ease(p);
var rotY = (p * 360 * ROT_TURNS) % 360;
cube.style.transform = 'rotateX(18deg) rotateY(' + rotY + 'deg)';
for (var i = 0; i < 8; i++) {
var sx = SCATTER[i][0], sy = SCATTER[i][1], sz = SCATTER[i][2];
var tx = TARGETS[i][0], ty = TARGETS[i][1], tz = TARGETS[i][2];
var cx = sx + (tx - sx) * e;
var cy = sy + (ty - sy) * e;
var cz = sz + (tz - sz) * e;
vtx[i].style.transform = 'translate3d(' + cx + 'px,' + cy + 'px,' + cz + 'px)';
vtx[i].style.opacity = String(0.25 + 0.75 * e);
}
updateLines(e);
pctEl.textContent = Math.round(p * 100) + '%';
}
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);
}
})();// WireframeVertexSnap — vanilla 판(run/399/demo/02-loader-3d-wireframe-vertex-snap/index.html) 의 React 포트.
// 시작 산점 좌표(고정 배열)에서 목표 정육면체 모서리까지 진행률을 지수 곡선(ease)으로 보간하고,
// 매 프레임 getBoundingClientRect() 로 preserve-3d 가 투영한 실제 화면 좌표를 읽어 SVG 선을 다시 그린다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./WireframeVertexSnap.module.scss";
export type WireframeVertexSnapProps = {
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 EDGES: [number, number][] = [[0,1],[0,2],[0,4],[1,3],[1,5],[2,3],[2,6],[3,7],[4,5],[4,6],[5,7],[6,7]];
const HALF = 42;
const TARGETS: [number, number, number][] = [0,1,2,3,4,5,6,7].map((i) => [
(i & 1) ? HALF : -HALF, (i & 2) ? HALF : -HALF, (i & 4) ? HALF : -HALF,
]);
const SCATTER: [number, number, number][] = [
[-70, -55, 10], [60, -65, -20], [-50, 60, 30], [75, 50, -10],
[-65, -30, -40], [55, -20, 45], [-40, 65, -25], [65, 70, 15],
];
const LOOP = 2;
const REDUCE_P = 0.7;
const LERP_EASE_POW = 1.8; // 정점 수렴 곡선 지수 — 변형 02 대상
const ROT_TURNS = 1;
const FIELD_LABEL = "와이어프레임 정점 수렴 진행 실험판";
export default function WireframeVertexSnap({
duration = "2s",
easing = "linear",
color = "#17141a",
}: WireframeVertexSnapProps) {
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 vtxRef = useRef<(HTMLDivElement | null)[]>([null, null, null, null, null, null, null, null]) as Box<(HTMLDivElement | null)[]>;
const lineRef = useRef<(SVGLineElement | null)[]>(new Array(12).fill(null)) as Box<(SVGLineElement | null)[]>;
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;
}
function ease(p: number): number {
return Math.pow(p, LERP_EASE_POW);
}
function updateLines(e: number): void {
const field = fieldRef.current;
const vtx = vtxRef.current;
const lines = lineRef.current;
if (!field) return;
const fr = field.getBoundingClientRect();
for (let i = 0; i < 12; i++) {
const av = vtx[EDGES[i][0]];
const bv = vtx[EDGES[i][1]];
const ln = lines[i];
if (!av || !bv || !ln) continue;
const a = av.getBoundingClientRect();
const b = bv.getBoundingClientRect();
const ax = a.left + a.width / 2 - fr.left;
const ay = a.top + a.height / 2 - fr.top;
const bx = b.left + b.width / 2 - fr.left;
const by = b.top + b.height / 2 - fr.top;
ln.setAttribute("x1", String(ax));
ln.setAttribute("y1", String(ay));
ln.setAttribute("x2", String(bx));
ln.setAttribute("y2", String(by));
ln.setAttribute("stroke-opacity", String(0.15 + 0.85 * e));
}
}
function render(p: number): void {
lastPRef.current = p;
const e = ease(p);
const cube = cubeRef.current;
const rotY = (p * 360 * ROT_TURNS) % 360;
if (cube) cube.style.transform = "rotateX(18deg) rotateY(" + rotY + "deg)";
const vtx = vtxRef.current;
for (let i = 0; i < 8; i++) {
const el = vtx[i];
if (!el) continue;
const [sx, sy, sz] = SCATTER[i];
const [tx, ty, tz] = TARGETS[i];
const cx = sx + (tx - sx) * e;
const cy = sy + (ty - sy) * e;
const cz = sz + (tz - sz) * e;
el.style.transform = "translate3d(" + cx + "px," + cy + "px," + cz + "px)";
el.style.opacity = String(0.25 + 0.75 * e);
}
updateLines(e);
if (pctRef.current) pctRef.current.textContent = Math.round(p * 100) + "%";
}
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(() => {
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}>
<svg className={styles.svg}>
{EDGES.map((_, i) => (
<line key={i} className={styles.edge} ref={(el: SVGLineElement | null) => { lineRef.current[i] = el; }} />
))}
</svg>
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
<div key={i} className={styles.vtx} ref={(el: HTMLDivElement | null) => { vtxRef.current[i] = el; }} />
))}
</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>
);
}02 · 진행률 값만큼 정점이 제자리로 모이는 와이어프레임 로더 — 측정 누적면적 4.867% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function render(p) {
lastP = p;
var e = ease(p);
var rotY = (p * 360 * ROT_TURNS) % 360;
cube.style.transform = 'rotateX(18deg) rotateY(' + rotY + 'deg)';
for (var i = 0; i < 8; i++) {
var sx = SCATTER[i][0], sy = SCATTER[i][1], sz = SCATTER[i][2];
var tx = TARGETS[i][0], ty = TARGETS[i][1], tz = TARGETS[i][2];
var cx = sx + (tx - sx) * e;
var cy = sy + (ty - sy) * e;
var cz = sz + (tz - sz) * e;
vtx[i].style.transform = 'translate3d(' + cx + 'px,' + cy + 'px,' + cz + 'px)';
vtx[i].style.opacity = String(0.25 + 0.75 * e);
}
updateLines(e);
pctEl.textContent = Math.round(p * 100) + '%';
}
03값이 오를 때마다 뒤 카드가 깊이 순서를 바꿔 앞으로 넘어오는 카드 스택 로더
카드마다 진행률 p×5 에서 구한 깊이 순위로 ZGAP(22px)만큼 뒤로, SHRINK(9%)만큼 작게, OPACITY_STEP(15%)만큼 흐리게 배치해 맨 뒤 카드가 매 단계 맨 앞으로 넘어오는 순환을 만듭니다. 다단계 결제·온보딩 단계 표시에 씁니다.

<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__deck" id="deck">
<div class="c3__card" id="c0">1</div>
<div class="c3__card" id="c1">2</div>
<div class="c3__card" id="c2">3</div>
<div class="c3__card" id="c3">4</div>
<div class="c3__card" id="c4">5</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 *;
// 카드 스택 깊이 순환 로더 — 카드마다 깊이 값(rankPos, 0=맨 앞)을 진행률의 연속 함수로 계산해
// translateZ·translateY·scale·opacity·z-index 를 매 프레임 다시 매긴다(화가 알고리즘 계열, 이산 스냅이 아니라 연속 순환).
// 395(캔버스 rAF 물리)·393(포인터 tilt) 과 다른 축 — 깊이 순위 자체를 진행률의 모듈러 함수로 계산한다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.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: $subject-ink;
text-align: center;
}
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: darken($stage-orange, 6%);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
display: grid; place-items: center;
cursor: pointer;
perspective: clamp(320px, 70vw, 480px);
}
.ld__field:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.c3__deck { position: relative; width: 116px; height: 74px; transform-style: preserve-3d; }
.c3__card {
position: absolute; inset: 0;
display: grid; place-items: center;
border-radius: $r-sm;
background: $color;
color: $subject-ink;
font-weight: 700; font-size: 20px; line-height: 1;
box-shadow: $shadow-raised;
will-change: transform, opacity;
}
.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__deck { width: 84px; height: 54px; }
.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: #ff4d1f;
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: #17141a;
text-align: center;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(100%, 20.6603641457%, 0.1568627451%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
display: grid;
place-items: center;
cursor: pointer;
perspective: clamp(320px, 70vw, 480px);
}
.ld__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.c3__deck {
position: relative;
width: 116px;
height: 74px;
transform-style: preserve-3d;
}
.c3__card {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 20px;
line-height: 1;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform, opacity;
}
.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__deck {
width: 84px;
height: 54px;
}
.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 cards = [];
for (var ci = 0; ci < 5; ci++) cards.push(document.getElementById('c' + ci));
var CARD_COUNT = 5;
var ZGAP = 22; // 깊이 한 칸당 뒤로 물러나는 거리(px)
var YOFF = 10; // 깊이 한 칸당 아래로 내려가는 거리(px)
var SHRINK = 0.09; // 깊이 한 칸당 축소 비율
var OPACITY_STEP = 0.15;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2;
var REDUCE_P = 0.7;
var t0 = 0, raf = 0, paused = false, lastP = 0;
// 카드 i 의 현재 깊이 순위(0=맨 앞)를 진행률의 연속 모듈러 함수로 계산 — 상태를 누적하지 않고
// 매 프레임 p 하나로부터 전체 순서를 다시 구한다(화가 알고리즘과 같은 원리).
function rankOf(i, s) {
var r = (i - s) % CARD_COUNT;
if (r < 0) r += CARD_COUNT;
return r;
}
function render(p) {
lastP = p;
var s = p * CARD_COUNT;
for (var i = 0; i < CARD_COUNT; i++) {
var rank = rankOf(i, s);
var z = -rank * ZGAP;
var y = rank * YOFF;
var scale = 1 - rank * SHRINK;
var op = 1 - rank * OPACITY_STEP;
cards[i].style.transform = 'translateY(' + y + 'px) translateZ(' + z + 'px) scale(' + scale + ')';
cards[i].style.opacity = String(Math.max(0.25, op));
cards[i].style.zIndex = String(Math.round((CARD_COUNT - rank) * 1000));
}
pctEl.textContent = Math.round(p * 100) + '%';
}
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);
}
})();// CardStackDepthCycle — vanilla 판(run/399/demo/03-loader-3d-card-stack-depth-cycle/index.html) 의 React 포트.
// 카드 i 의 깊이 순위를 진행률의 연속 모듈러 함수로 계산해 매 프레임 transform·opacity·z-index 를 다시 매긴다.
// 395(캔버스 rAF 물리)·393(포인터 tilt) 과 다른 축 — 깊이 순위 자체를 진행률의 모듈러 함수로 계산한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./CardStackDepthCycle.module.scss";
export type CardStackDepthCycleProps = {
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 CARD_COUNT = 5;
const ZGAP = 22;
const YOFF = 10;
const SHRINK = 0.09;
const OPACITY_STEP = 0.15;
const LOOP = 2;
const REDUCE_P = 0.7;
const FIELD_LABEL = "카드 스택 깊이 순환 진행 실험판";
function rankOf(i: number, s: number): number {
let r = (i - s) % CARD_COUNT;
if (r < 0) r += CARD_COUNT;
return r;
}
export default function CardStackDepthCycle({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: CardStackDepthCycleProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const cardsRef = useRef<(HTMLDivElement | null)[]>([null, null, null, null, null]) as Box<(HTMLDivElement | null)[]>;
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;
}
function render(p: number): void {
lastPRef.current = p;
const s = p * CARD_COUNT;
const cards = cardsRef.current;
for (let i = 0; i < CARD_COUNT; i++) {
const el = cards[i];
if (!el) continue;
const rank = rankOf(i, s);
const z = -rank * ZGAP;
const y = rank * YOFF;
const scale = 1 - rank * SHRINK;
const op = Math.max(0.25, 1 - rank * OPACITY_STEP);
el.style.transform = "translateY(" + y + "px) translateZ(" + z + "px) scale(" + scale + ")";
el.style.opacity = String(op);
el.style.zIndex = String(Math.round((CARD_COUNT - rank) * 1000));
}
if (pctRef.current) pctRef.current.textContent = Math.round(p * 100) + "%";
}
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(() => {
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.deck}>
{[1, 2, 3, 4, 5].map((n, i) => (
<div key={n} className={styles.card} ref={(el: HTMLDivElement | null) => { cardsRef.current[i] = el; }}>{n}</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>
);
}03 · 값이 오를 때마다 뒤 카드가 깊이 순서를 바꿔 앞으로 넘어오는 카드 스택 로더 — 측정 누적면적 5.401% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 카드 i 의 현재 깊이 순위(0=맨 앞)를 진행률의 연속 모듈러 함수로 계산 — 상태를 누적하지 않고
// 매 프레임 p 하나로부터 전체 순서를 다시 구한다(화가 알고리즘과 같은 원리).
function rankOf(i, s) {
var r = (i - s) % CARD_COUNT;
if (r < 0) r += CARD_COUNT;
return r;
}
function render(p) {
lastP = p;
var s = p * CARD_COUNT;
for (var i = 0; i < CARD_COUNT; i++) {
var rank = rankOf(i, s);
var z = -rank * ZGAP;
var y = rank * YOFF;
var scale = 1 - rank * SHRINK;
var op = 1 - rank * OPACITY_STEP;
cards[i].style.transform = 'translateY(' + y + 'px) translateZ(' + z + 'px) scale(' + scale + ')';
cards[i].style.opacity = String(Math.max(0.25, op));
cards[i].style.zIndex = String(Math.round((CARD_COUNT - rank) * 1000));
}
pctEl.textContent = Math.round(p * 100) + '%';
}
04값이 오를수록 고리 사이 기울기 차이가 좁혀지는 궤도 로더
고리 네 개를 72·54·36·18도로 다르게 기울여 두고 INITIAL_TILT[i] * (1-p) 로 정면(0도) 쪽으로 좁히면서 SPIN_TURNS(0.6바퀴)만큼 같이 돌려, 값이 다 차면 네 고리가 한 각도로 겹칩니다. 동기화 진행·다중 계층 로딩에 씁니다.

<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__orbit">
<div class="c3__ring c3__ring--0" id="r0"></div>
<div class="c3__ring c3__ring--1" id="r1"></div>
<div class="c3__ring c3__ring--2" id="r2"></div>
<div class="c3__ring c3__ring--3" id="r3"></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 *;
// 궤도 고리 기울기 수렴 로더 — 고리마다 다른 시작 기울기(rotateX)를 두고, 진행률 값이 오르는
// 비율만큼 각 고리의 기울기를 0(정면) 쪽으로 선형 보간한다. 링마다 수렴 속도가 달라 겹치는 순간이 갈린다.
// 395(캔버스 rAF 물리)·393(포인터 tilt) 과 다른 축 — 고리마다 다른 시작 기울기를 진행률에 비례해 개별 보간한다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.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: $subject-ink;
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;
perspective: clamp(320px, 70vw, 480px);
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.c3__orbit { position: relative; width: 10px; height: 10px; transform-style: preserve-3d; }
.c3__ring {
position: absolute; left: 50%; top: 50%;
border-radius: 50%;
border-style: solid;
border-color: $color;
transform-style: preserve-3d;
will-change: transform;
}
.c3__ring--0 { width: 168px; height: 168px; margin: -84px 0 0 -84px; border-width: 3px; opacity: 1; }
.c3__ring--1 { width: 138px; height: 138px; margin: -69px 0 0 -69px; border-width: 3px; opacity: .82; border-color: mix($color, $subject-cream, 80%); }
.c3__ring--2 { width: 108px; height: 108px; margin: -54px 0 0 -54px; border-width: 3px; opacity: .64; border-color: mix($color, $subject-cream, 62%); }
.c3__ring--3 { width: 78px; height: 78px; margin: -39px 0 0 -39px; border-width: 3px; opacity: .48; border-color: mix($color, $subject-cream, 46%); }
.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__ring--0 { width: 108px; height: 108px; margin: -54px 0 0 -54px; }
.c3__ring--1 { width: 90px; height: 90px; margin: -45px 0 0 -45px; }
.c3__ring--2 { width: 72px; height: 72px; margin: -36px 0 0 -36px; }
.c3__ring--3 { width: 52px; height: 52px; margin: -26px 0 0 -26px; }
.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: #ffffff;
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: #17141a;
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;
perspective: clamp(320px, 70vw, 480px);
}
.ld__field:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.c3__orbit {
position: relative;
width: 10px;
height: 10px;
transform-style: preserve-3d;
}
.c3__ring {
position: absolute;
left: 50%;
top: 50%;
border-radius: 50%;
border-style: solid;
border-color: #0a4fe8;
transform-style: preserve-3d;
will-change: transform;
}
.c3__ring--0 {
width: 168px;
height: 168px;
margin: -84px 0 0 -84px;
border-width: 3px;
opacity: 1;
}
.c3__ring--1 {
width: 138px;
height: 138px;
margin: -69px 0 0 -69px;
border-width: 3px;
opacity: 0.82;
border-color: rgb(23.137254902%, 44.1568627451%, 90.8235294118%);
}
.c3__ring--2 {
width: 108px;
height: 108px;
margin: -54px 0 0 -54px;
border-width: 3px;
opacity: 0.64;
border-color: rgb(40.431372549%, 56.0156862745%, 90.6823529412%);
}
.c3__ring--3 {
width: 78px;
height: 78px;
margin: -39px 0 0 -39px;
border-width: 3px;
opacity: 0.48;
border-color: rgb(55.8039215686%, 66.5568627451%, 90.5568627451%);
}
.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__ring--0 {
width: 108px;
height: 108px;
margin: -54px 0 0 -54px;
}
.c3__ring--1 {
width: 90px;
height: 90px;
margin: -45px 0 0 -45px;
}
.c3__ring--2 {
width: 72px;
height: 72px;
margin: -36px 0 0 -36px;
}
.c3__ring--3 {
width: 52px;
height: 52px;
margin: -26px 0 0 -26px;
}
.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 rings = [document.getElementById('r0'), document.getElementById('r1'), document.getElementById('r2'), document.getElementById('r3')];
var INITIAL_TILT = [72, 54, 36, 18]; // 고리마다 다른 시작 기울기(deg) — 값이 오를수록 0 으로 수렴
var SPIN_TURNS = 0.6; // 한 루프 동안 고리 묶음이 도는 회전수
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2;
var REDUCE_P = 0.7;
var t0 = 0, raf = 0, paused = false, lastP = 0;
function render(p) {
lastP = p;
var spinY = (p * 360 * SPIN_TURNS) % 360;
for (var i = 0; i < rings.length; i++) {
var tilt = INITIAL_TILT[i] * (1 - p); // 정면 각도(0deg) 쪽으로 진행률 비례 보간
rings[i].style.transform = 'rotateY(' + spinY + 'deg) rotateX(' + tilt + 'deg)';
}
pctEl.textContent = Math.round(p * 100) + '%';
}
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);
}
})();// RingOrbitTiltStack — vanilla 판(run/399/demo/04-loader-3d-ring-orbit-tilt-stack/index.html) 의 React 포트.
// 고리마다 다른 시작 기울기(rotateX)를 진행률 비율만큼 0(정면) 으로 개별 보간한다.
// 395(캔버스 rAF 물리)·393(포인터 tilt) 과 다른 축 — 고리마다 다른 시작 기울기를 진행률에 비례해 개별 보간한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RingOrbitTiltStack.module.scss";
export type RingOrbitTiltStackProps = {
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 INITIAL_TILT = [72, 54, 36, 18];
const SPIN_TURNS = 0.6;
const LOOP = 2;
const REDUCE_P = 0.7;
const FIELD_LABEL = "궤도 고리 기울기 수렴 진행 실험판";
export default function RingOrbitTiltStack({
duration = "2s",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8",
}: RingOrbitTiltStackProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const ringsRef = useRef<(HTMLDivElement | null)[]>([null, null, null, null]) as Box<(HTMLDivElement | null)[]>;
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 };
const ringClasses = [styles.ring0, styles.ring1, styles.ring2, styles.ring3];
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function render(p: number): void {
lastPRef.current = p;
const spinY = (p * 360 * SPIN_TURNS) % 360;
const rings = ringsRef.current;
for (let i = 0; i < rings.length; i++) {
const el = rings[i];
if (!el) continue;
const tilt = INITIAL_TILT[i] * (1 - p);
el.style.transform = "rotateY(" + spinY + "deg) rotateX(" + tilt + "deg)";
}
if (pctRef.current) pctRef.current.textContent = Math.round(p * 100) + "%";
}
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(() => {
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.orbit}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className={styles.ring + " " + ringClasses[i]} ref={(el: HTMLDivElement | null) => { ringsRef.current[i] = el; }} />
))}
</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>
);
}04 · 값이 오를수록 고리 사이 기울기 차이가 좁혀지는 궤도 로더 — 측정 누적면적 15.808% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var INITIAL_TILT = [72, 54, 36, 18]; // 고리마다 다른 시작 기울기(deg) — 값이 오를수록 0 으로 수렴
var SPIN_TURNS = 0.6; // 한 루프 동안 고리 묶음이 도는 회전수
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2;
var REDUCE_P = 0.7;
var t0 = 0, raf = 0, paused = false, lastP = 0;
function render(p) {
lastP = p;
var spinY = (p * 360 * SPIN_TURNS) % 360;
for (var i = 0; i < rings.length; i++) {
var tilt = INITIAL_TILT[i] * (1 - p); // 정면 각도(0deg) 쪽으로 진행률 비례 보간
rings[i].style.transform = 'rotateY(' + spinY + 'deg) rotateX(' + tilt + 'deg)';
}
pctEl.textContent = Math.round(p * 100) + '%';
}
05구 표면의 점들이 한 점으로 모이는 수렴 로더
황금각(GOLDEN_ANGLE)으로 구 표면에 고르게 흩은 점 28개를 SPHERE_R(78px) 반지름에서 중심까지 1-p 배율로 당겨 한 점으로 수렴시킵니다. 데이터 동기화 완료·앱 초기화 화면에 씁니다.

<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__sphere" id="sphere"></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,0,0) 쪽으로 선형 수축시킨다. 값이 다 차면 모든 점이 한 점에 겹친다.
// 395(캔버스 rAF 물리)·393(포인터 tilt) 과 다른 축 — 구면 분포 공식으로 점을 배치하고 중심 수렴 비율을 계산한다.
$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);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.c3__scene { position: absolute; inset: 0; perspective: clamp(320px, 70vw, 480px); }
.c3__sphere { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform-style: preserve-3d; }
.c3__pt {
position: absolute; left: 0; top: 0;
width: 7px; height: 7px; margin: -3.5px 0 0 -3.5px;
border-radius: 50%;
background: $color;
will-change: transform;
}
.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; }
.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);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.c3__scene {
position: absolute;
inset: 0;
perspective: clamp(320px, 70vw, 480px);
}
.c3__sphere {
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
transform-style: preserve-3d;
}
.c3__pt {
position: absolute;
left: 0;
top: 0;
width: 7px;
height: 7px;
margin: -3.5px 0 0 -3.5px;
border-radius: 50%;
background: #0a4fe8;
will-change: transform;
}
.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;
}
.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 sphere = document.getElementById('sphere');
var POINT_COUNT = 28;
var SPHERE_R = 78;
var GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); // 피보나치 구 분포 각(고정 상수, 무작위 없음)
var ROT_TURNS = 0.5;
// 피보나치 구 분포 — 점 i 의 구면 좌표를 결정적으로 계산한다.
var BASE = [];
for (var i = 0; i < POINT_COUNT; i++) {
var y = 1 - (i / (POINT_COUNT - 1)) * 2;
var r = Math.sqrt(Math.max(0, 1 - y * y));
var theta = GOLDEN_ANGLE * i;
BASE.push([Math.cos(theta) * r * SPHERE_R, y * SPHERE_R, Math.sin(theta) * r * SPHERE_R]);
}
var pts = [];
for (var pi = 0; pi < POINT_COUNT; pi++) {
var el = document.createElement('div');
el.className = 'c3__pt';
sphere.appendChild(el);
pts.push(el);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2;
var REDUCE_P = 0.7;
var t0 = 0, raf = 0, paused = false, lastP = 0;
function render(p) {
lastP = p;
var rotY = (p * 360 * ROT_TURNS) % 360;
var shrink = 1 - p; // 중심(0,0,0) 으로의 수렴 비율
sphere.style.transform = 'rotateY(' + rotY + 'deg) rotateX(14deg)';
for (var i = 0; i < POINT_COUNT; i++) {
var b = BASE[i];
var x = b[0] * shrink, y = b[1] * shrink, z = b[2] * shrink;
pts[i].style.transform = 'translate3d(' + x + 'px,' + y + 'px,' + z + 'px)';
}
pctEl.textContent = Math.round(p * 100) + '%';
}
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);
}
})();// SpherePointConverge — vanilla 판(run/399/demo/05-loader-3d-sphere-point-converge/index.html) 의 React 포트.
// 피보나치 구 분포 공식(고정, 무작위 없음)으로 점을 구 표면에 배치하고, 진행률 값이 오를수록
// 각 점의 좌표를 중심(0,0,0) 쪽으로 선형 수축시킨다.
// 395(캔버스 rAF 물리)·393(포인터 tilt) 과 다른 축 — 구면 분포 공식으로 점을 배치하고 중심 수렴 비율을 계산한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SpherePointConverge.module.scss";
export type SpherePointConvergeProps = {
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 POINT_COUNT = 28;
const SPHERE_R = 78;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const ROT_TURNS = 0.5;
const LOOP = 2;
const REDUCE_P = 0.7;
const FIELD_LABEL = "구면 점 수렴 진행 실험판";
const BASE: [number, number, number][] = Array.from({ length: POINT_COUNT }, (_, i) => {
const y = 1 - (i / (POINT_COUNT - 1)) * 2;
const r = Math.sqrt(Math.max(0, 1 - y * y));
const theta = GOLDEN_ANGLE * i;
return [Math.cos(theta) * r * SPHERE_R, y * SPHERE_R, Math.sin(theta) * r * SPHERE_R];
});
export default function SpherePointConverge({
duration = "2s",
easing = "linear",
color = "#0a4fe8",
}: SpherePointConvergeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const sphereRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const ptsRef = useRef<(HTMLDivElement | null)[]>(new Array(POINT_COUNT).fill(null)) as Box<(HTMLDivElement | null)[]>;
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;
}
function render(p: number): void {
lastPRef.current = p;
const rotY = (p * 360 * ROT_TURNS) % 360;
const shrink = 1 - p;
const sphere = sphereRef.current;
if (sphere) sphere.style.transform = "rotateY(" + rotY + "deg) rotateX(14deg)";
const pts = ptsRef.current;
for (let i = 0; i < POINT_COUNT; i++) {
const el = pts[i];
if (!el) continue;
const [bx, by, bz] = BASE[i];
const x = bx * shrink, y = by * shrink, z = bz * shrink;
el.style.transform = "translate3d(" + x + "px," + y + "px," + z + "px)";
}
if (pctRef.current) pctRef.current.textContent = Math.round(p * 100) + "%";
}
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(() => {
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.sphere} ref={sphereRef}>
{BASE.map((_, i) => (
<div key={i} className={styles.pt} ref={(el: HTMLDivElement | null) => { ptsRef.current[i] = el; }} />
))}
</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>
);
}05 · 구 표면의 점들이 한 점으로 모이는 수렴 로더 — 측정 누적면적 6.127% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
var POINT_COUNT = 28;
var SPHERE_R = 78;
var GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); // 피보나치 구 분포 각(고정 상수, 무작위 없음)
var ROT_TURNS = 0.5;
// 피보나치 구 분포 — 점 i 의 구면 좌표를 결정적으로 계산한다.
var BASE = [];
for (var i = 0; i < POINT_COUNT; i++) {
var y = 1 - (i / (POINT_COUNT - 1)) * 2;
var r = Math.sqrt(Math.max(0, 1 - y * y));
var theta = GOLDEN_ANGLE * i;
BASE.push([Math.cos(theta) * r * SPHERE_R, y * SPHERE_R, Math.sin(theta) * r * SPHERE_R]);
}
06회전하며 넘는 면부터 색이 채워지는 다각기둥 로더
육각기둥(PRISM_SIDES=6)을 계속 회전시키며 면 i 의 문턱 i / PRISM_SIDES 를 넘은 뒤 FILL_SPAN 폭 동안 선형으로 불투명도를 채워, 회전할 때마다 채워진 경계가 눈에 보입니다. 설문 진행·재고 채우기 표시에 씁니다.

<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__prism" id="prism"></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 *;
// 회전 다각기둥 면 채움 로더 — 육각기둥을 일정 속도로 계속 회전시키며, 진행률 값이
// 면마다 정해진 문턱(면 순번 / 전체 면수)을 넘는 순간부터 그 면에 색이 번져 채워진다.
// 채워진 면은 회전이 계속돼도 그대로 유지되어, 한 바퀴 돌 때마다 채워진 경계가 눈에 보인다.
// 01(문턱 하나로 앞면 인덱스를 스왑)·02(정점 좌표 보간)·03(연속 랭크 함수)·04(고리별 개별 기울기 보간)
// ·05(구면 분포 중심 수렴) 과 다른 축 — 면마다 독립된 문턱 램프 함수로 채움 정도를 계산한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.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-ink, .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);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.c3__scene { position: absolute; inset: 0; perspective: clamp(320px, 70vw, 480px); }
.c3__prism { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform-style: preserve-3d; }
.c3__face {
position: absolute; left: -44px; top: -48px;
width: 88px; height: 96px;
border-radius: $r-xs;
background: rgba($subject-ink, .06);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .3);
}
.c3__fill {
position: absolute; inset: 0;
border-radius: $r-xs;
background: $color;
opacity: 0;
will-change: opacity;
}
.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; }
.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: #ffd23f;
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(23, 20, 26, 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);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.c3__scene {
position: absolute;
inset: 0;
perspective: clamp(320px, 70vw, 480px);
}
.c3__prism {
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
transform-style: preserve-3d;
}
.c3__face {
position: absolute;
left: -44px;
top: -48px;
width: 88px;
height: 96px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.06);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.3);
}
.c3__fill {
position: absolute;
inset: 0;
border-radius: 4px;
background: #0a4fe8;
opacity: 0;
will-change: opacity;
}
.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;
}
.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 prism = document.getElementById('prism');
var PRISM_SIDES = 6;
var RADIUS = 76;
var ROT_TURNS = 1;
var FILL_SPAN = 1 / (PRISM_SIDES * 2); // 문턱을 넘은 뒤 색이 번지는 데 걸리는 진행률 폭
var faces = [];
var fills = [];
for (var i = 0; i < PRISM_SIDES; i++) {
var face = document.createElement('div');
face.className = 'c3__face';
face.style.transform = 'rotateY(' + (i * (360 / PRISM_SIDES)) + 'deg) translateZ(' + RADIUS + 'px)';
var fill = document.createElement('div');
fill.className = 'c3__fill';
face.appendChild(fill);
prism.appendChild(face);
faces.push(face);
fills.push(fill);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2;
var REDUCE_P = 0.7;
var t0 = 0, raf = 0, paused = false, lastP = 0;
// 면 i 의 문턱은 i / PRISM_SIDES — 진행률이 그 문턱을 넘으면 FILL_SPAN 동안 선형으로 번져 채워지고,
// 그 뒤로는 값이 줄지 않는 한(루프가 돌아 p 가 다시 작아지기 전까지) 채워진 채로 유지된다.
function fillAmount(i, p) {
var threshold = i / PRISM_SIDES;
var raw = (p - threshold) / FILL_SPAN;
if (raw < 0) return 0;
if (raw > 1) return 1;
return raw;
}
function render(p) {
lastP = p;
var rotY = (p * 360 * ROT_TURNS) % 360;
prism.style.transform = 'rotateX(-14deg) rotateY(' + rotY + 'deg)';
for (var i = 0; i < PRISM_SIDES; i++) {
fills[i].style.opacity = String(fillAmount(i, p));
}
pctEl.textContent = Math.round(p * 100) + '%';
}
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);
}
})();// PrismFaceFillRotate — vanilla 판(run/399/demo/06-loader-3d-prism-face-fill-rotate/index.html) 의 React 포트.
// 면마다 독립된 문턱(면 순번 / 전체 면수)을 넘으면 FILL_SPAN 폭 동안 선형으로 색이 번지는 램프 함수를 쓴다.
// 01·02·03·04·05 와 다른 축 — 면마다 독립된 문턱 램프 함수로 채움 정도를 계산한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./PrismFaceFillRotate.module.scss";
export type PrismFaceFillRotateProps = {
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 PRISM_SIDES = 6;
const RADIUS = 76;
const ROT_TURNS = 1;
const FILL_SPAN = 1 / (PRISM_SIDES * 2);
const LOOP = 2;
const REDUCE_P = 0.7;
const FIELD_LABEL = "회전 다각기둥 면 채움 진행 실험판";
function fillAmount(i: number, p: number): number {
const threshold = i / PRISM_SIDES;
const raw = (p - threshold) / FILL_SPAN;
if (raw < 0) return 0;
if (raw > 1) return 1;
return raw;
}
export default function PrismFaceFillRotate({
duration = "2s",
easing = "linear",
color = "#0a4fe8",
}: PrismFaceFillRotateProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const prismRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const fillsRef = useRef<(HTMLDivElement | null)[]>(new Array(PRISM_SIDES).fill(null)) as Box<(HTMLDivElement | null)[]>;
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;
}
function render(p: number): void {
lastPRef.current = p;
const rotY = (p * 360 * ROT_TURNS) % 360;
const prism = prismRef.current;
if (prism) prism.style.transform = "rotateX(-14deg) rotateY(" + rotY + "deg)";
const fills = fillsRef.current;
for (let i = 0; i < PRISM_SIDES; i++) {
const el = fills[i];
if (!el) continue;
el.style.opacity = String(fillAmount(i, p));
}
if (pctRef.current) pctRef.current.textContent = Math.round(p * 100) + "%";
}
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(() => {
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.prism} ref={prismRef}>
{Array.from({ length: PRISM_SIDES }, (_, i) => (
<div
key={i}
className={styles.face}
style={{ transform: "rotateY(" + (i * (360 / PRISM_SIDES)) + "deg) translateZ(" + RADIUS + "px)" }}
>
<div className={styles.fill} ref={(el: HTMLDivElement | null) => { fillsRef.current[i] = el; }} />
</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>
);
}06 · 회전하며 넘는 면부터 색이 채워지는 다각기둥 로더 — 측정 누적면적 16.692% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 면 i 의 문턱은 i / PRISM_SIDES — 진행률이 그 문턱을 넘으면 FILL_SPAN 동안 선형으로 번져 채워지고,
// 그 뒤로는 값이 줄지 않는 한(루프가 돌아 p 가 다시 작아지기 전까지) 채워진 채로 유지된다.
function fillAmount(i, p) {
var threshold = i / PRISM_SIDES;
var raw = (p - threshold) / FILL_SPAN;
if (raw < 0) return 0;
if (raw > 1) return 1;
return raw;
}
function render(p) {
lastP = p;
var rotY = (p * 360 * ROT_TURNS) % 360;
prism.style.transform = 'rotateX(-14deg) rotateY(' + rotY + 'deg)';
for (var i = 0; i < PRISM_SIDES; i++) {
fills[i].style.opacity = String(fillAmount(i, p));
}
pctEl.textContent = Math.round(p * 100) + '%';
}
07패널이 뒤집힐 때마다 앞뒤 순서를 다시 계산하는 플립 로더
패널 다섯 장을 STEP(1/5) 구간마다 하나씩 0→180도로 뒤집으면서, 그 순간 지금 뒤집는 패널까지의 거리로 전 패널의 깊이(RANK_ZGAP)를 다시 정렬해 뒷면이 늘 자연스럽게 겹칩니다. css 카드 뒤집기 애니메이션을 튜토리얼 단계 넘기기·카드형 안내 진행에 붙이기 좋습니다.

<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__stack" id="stack"></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→180도로 뒤집히고, 동시에 모든 패널의
// 깊이 순위가 "지금 뒤집는 패널까지의 거리"로 다시 매겨져 뒤집히는 패널이 항상 맨 앞에 자연스럽게 겹친다.
// 01(문턱 인덱스 스왑)·02(정점 좌표 보간)·03(연속 순환 랭크)·04(고리별 개별 기울기)·05(구면 중심 수렴)
// ·06(면별 독립 문턱 램프) 과 다른 축 — 회전 각도와 거리 기반 깊이 순위 두 값을 함께 계산한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
@include stage($stage-orange);
.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, .82);
text-align: center;
}
.ld__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: darken($stage-orange, 6%);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.c3__scene { position: absolute; inset: 0; perspective: clamp(320px, 70vw, 480px); }
.c3__stack { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform-style: preserve-3d; }
.c3__panel {
position: absolute; left: -58px; top: -66px;
width: 116px; height: 132px;
transform-style: preserve-3d;
will-change: transform;
}
.c3__panelFace,
.c3__panelBack {
position: absolute; inset: 0;
border-radius: $r-sm;
backface-visibility: hidden;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px;
}
.c3__panelFace { background: $color; color: $subject-ink; }
.c3__panelBack { background: $subject-blue-deep; color: $subject-cream; transform: rotateY(180deg); }
.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; }
.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: #ff4d1f;
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.82);
text-align: center;
}
.ld__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(100%, 20.6603641457%, 0.1568627451%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.c3__scene {
position: absolute;
inset: 0;
perspective: clamp(320px, 70vw, 480px);
}
.c3__stack {
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
transform-style: preserve-3d;
}
.c3__panel {
position: absolute;
left: -58px;
top: -66px;
width: 116px;
height: 132px;
transform-style: preserve-3d;
will-change: transform;
}
.c3__panelFace,
.c3__panelBack {
position: absolute;
inset: 0;
border-radius: 8px;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 13px;
}
.c3__panelFace {
background: #fff7e6;
color: #17141a;
}
.c3__panelBack {
background: #0a4fe8;
color: #fff7e6;
transform: rotateY(180deg);
}
.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;
}
.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 stack = document.getElementById('stack');
var PANEL_COUNT = 5;
var STEP = 1 / PANEL_COUNT;
var RANK_ZGAP = 22;
var RANK_YOFF = 4;
var panels = [];
for (var i = 0; i < PANEL_COUNT; i++) {
var panel = document.createElement('div');
panel.className = 'c3__panel';
var face = document.createElement('div');
face.className = 'c3__panelFace';
face.textContent = String(i + 1);
var back = document.createElement('div');
back.className = 'c3__panelBack';
back.textContent = String(i + 1);
panel.appendChild(face);
panel.appendChild(back);
stack.appendChild(panel);
panels.push(panel);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2;
var REDUCE_P = 0.7;
var t0 = 0, raf = 0, paused = false, lastP = 0;
// 지금 뒤집는 패널 인덱스 — 진행률을 패널 구간(STEP)으로 나눈 정수부.
function currentIndex(p) {
var idx = Math.floor(p * PANEL_COUNT);
if (idx > PANEL_COUNT - 1) idx = PANEL_COUNT - 1;
if (idx < 0) idx = 0;
return idx;
}
// 패널 i 의 회전 각도 — 자기 구간 안에서만 0→180도로 선형 보간, 구간 전은 0, 구간 후는 180 고정.
function flipAngle(i, p) {
var start = i * STEP, end = start + STEP;
if (p < start) return 0;
if (p >= end) return 180;
var local = (p - start) / STEP;
return local * 180;
}
// 패널 i 의 깊이 순위 — "지금 뒤집는 패널까지의 거리". 거리가 멀수록 뒤로 밀린다.
function depthRank(i, p) {
var cur = currentIndex(p);
return Math.abs(cur - i);
}
function render(p) {
lastP = p;
for (var i = 0; i < PANEL_COUNT; i++) {
var angle = flipAngle(i, p);
var rank = depthRank(i, p);
panels[i].style.transform = 'translateZ(' + (-rank * RANK_ZGAP) + 'px) translateY(' + (rank * RANK_YOFF) + 'px) rotateY(' + angle + 'deg)';
panels[i].style.zIndex = String(PANEL_COUNT - rank);
}
pctEl.textContent = Math.round(p * 100) + '%';
}
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);
}
})();// FlipPanelDepthSort — vanilla 판(run/399/demo/07-loader-3d-flip-panel-depth-sort/index.html) 의 React 포트.
// 패널마다 회전 각도(자기 구간 안에서 0→180도 선형)와 깊이 순위("지금 뒤집는 패널까지의 거리")를
// 매 프레임 p 하나로부터 순수 함수로 계산한다 — 01~06 과 다른 축.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./FlipPanelDepthSort.module.scss";
export type FlipPanelDepthSortProps = {
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 PANEL_COUNT = 5;
const STEP = 1 / PANEL_COUNT;
const RANK_ZGAP = 22;
const RANK_YOFF = 4;
const LOOP = 2;
const REDUCE_P = 0.7;
const FIELD_LABEL = "플립 패널 깊이 재정렬 진행 실험판";
function currentIndex(p: number): number {
let idx = Math.floor(p * PANEL_COUNT);
if (idx > PANEL_COUNT - 1) idx = PANEL_COUNT - 1;
if (idx < 0) idx = 0;
return idx;
}
function flipAngle(i: number, p: number): number {
const start = i * STEP, end = start + STEP;
if (p < start) return 0;
if (p >= end) return 180;
const local = (p - start) / STEP;
return local * 180;
}
function depthRank(i: number, p: number): number {
const cur = currentIndex(p);
return Math.abs(cur - i);
}
export default function FlipPanelDepthSort({
duration = "2s",
easing = "linear",
color = "#fff7e6",
}: FlipPanelDepthSortProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const panelsRef = useRef<(HTMLDivElement | null)[]>(new Array(PANEL_COUNT).fill(null)) as Box<(HTMLDivElement | null)[]>;
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;
}
function render(p: number): void {
lastPRef.current = p;
const panels = panelsRef.current;
for (let i = 0; i < PANEL_COUNT; i++) {
const el = panels[i];
if (!el) continue;
const angle = flipAngle(i, p);
const rank = depthRank(i, p);
el.style.transform = "translateZ(" + (-rank * RANK_ZGAP) + "px) translateY(" + (rank * RANK_YOFF) + "px) rotateY(" + angle + "deg)";
el.style.zIndex = String(PANEL_COUNT - rank);
}
if (pctRef.current) pctRef.current.textContent = Math.round(p * 100) + "%";
}
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(() => {
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.stack}>
{Array.from({ length: PANEL_COUNT }, (_, i) => (
<div key={i} className={styles.panel} ref={(el: HTMLDivElement | null) => { panelsRef.current[i] = el; }}>
<div className={styles.panelFace}>{i + 1}</div>
<div className={styles.panelBack}>{i + 1}</div>
</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>
);
}07 · 패널이 뒤집힐 때마다 앞뒤 순서를 다시 계산하는 플립 로더 — 측정 누적면적 12.239% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 패널 i 의 회전 각도 — 자기 구간 안에서만 0→180도로 선형 보간, 구간 전은 0, 구간 후는 180 고정.
function flipAngle(i, p) {
var start = i * STEP, end = start + STEP;
if (p < start) return 0;
if (p >= end) return 180;
var local = (p - start) / STEP;
return local * 180;
}
// 패널 i 의 깊이 순위 — "지금 뒤집는 패널까지의 거리". 거리가 멀수록 뒤로 밀린다.
function depthRank(i, p) {
var cur = currentIndex(p);
return Math.abs(cur - i);
}
function render(p) {
lastP = p;
for (var i = 0; i < PANEL_COUNT; i++) {
var angle = flipAngle(i, p);
var rank = depthRank(i, p);
panels[i].style.transform = 'translateZ(' + (-rank * RANK_ZGAP) + 'px) translateY(' + (rank * RANK_YOFF) + 'px) rotateY(' + angle + 'deg)';
panels[i].style.zIndex = String(PANEL_COUNT - rank);
}
pctEl.textContent = Math.round(p * 100) + '%';
}
08격자점이 값만큼 솟아 지형처럼 보이는 원근 로더
가로 GRID_COLS(7)×세로 5 격자점마다 두 사인·코사인 물결을 겹쳐 고정 높이 값을 미리 정해 두고, 진행률 p 만큼 HEIGHT_SCALE(54px)로 끌어올리며 TILT(58도)로 눕혀 원근감을 냅니다. 지도 데이터 불러오기·대시보드 초기 로딩 배경에 씁니다.

<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__grid" id="grid"></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 *;
// 지형 격자 원근 로더 — 격자점마다 고정된(무작위 없음) 높이 가중치를 갖고 있다가, 진행률 값이
// 오르는 비율만큼 각 점의 높이를 끌어올린다. 점들은 카메라와의 거리(Z)가 다른 자리에 미리 배치돼
// 있어 CSS 원근 투영이 가까운 점은 크게, 먼 점은 작게 그려 지형처럼 입체감을 만든다.
// 01(문턱 인덱스 스왑)·02(정점 좌표 보간)·03(연속 순환 랭크)·04(고리별 개별 기울기)·05(구면 중심 수렴)
// ·06(면별 독립 문턱 램프)·07(회전각+거리 깊이 순위) 과 다른 축 — 고정 가중치 필드 값을 원근 투영과 결합한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-paper);
.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-ink, .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);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.c3__scene { position: absolute; inset: 0; perspective: clamp(280px, 60vw, 420px); }
.c3__grid { position: absolute; left: 50%; top: 62%; width: 0; height: 0; transform-style: preserve-3d; }
.c3__pt {
position: absolute; left: 0; top: 0;
width: 7px; height: 7px; margin: -3.5px 0 0 -3.5px;
border-radius: 50%;
background: $color;
will-change: transform;
}
.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; }
.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: #ffffff;
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(23, 20, 26, 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);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.c3__scene {
position: absolute;
inset: 0;
perspective: clamp(280px, 60vw, 420px);
}
.c3__grid {
position: absolute;
left: 50%;
top: 62%;
width: 0;
height: 0;
transform-style: preserve-3d;
}
.c3__pt {
position: absolute;
left: 0;
top: 0;
width: 7px;
height: 7px;
margin: -3.5px 0 0 -3.5px;
border-radius: 50%;
background: #0a4fe8;
will-change: transform;
}
.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;
}
.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 grid = document.getElementById('grid');
var GRID_COLS = 7;
var GRID_ROWS = 5;
var COL_GAP = 20;
var ROW_GAP = 24;
var HEIGHT_SCALE = 54;
var ROT_TURNS = 0.35;
var TILT = 58;
// 격자점마다 고정된 높이 가중치를 결정적으로 계산한다(무작위 없음) — 두 개의 sin/cos 물결을 겹친 값.
function weightOf(col, row) {
return (Math.sin(col * 1.1 + row * 0.6) + Math.cos(row * 1.3 - col * 0.4) + 2) / 4;
}
var pts = [];
var base = [];
for (var row = 0; row < GRID_ROWS; row++) {
for (var col = 0; col < GRID_COLS; col++) {
var x = (col - (GRID_COLS - 1) / 2) * COL_GAP;
var z = row * ROW_GAP;
var w = weightOf(col, row);
base.push([x, z, w]);
var el = document.createElement('div');
el.className = 'c3__pt';
el.style.zIndex = String(row);
grid.appendChild(el);
pts.push(el);
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2;
var REDUCE_P = 0.7;
var t0 = 0, raf = 0, paused = false, lastP = 0;
function render(p) {
lastP = p;
var rotY = (p * 360 * ROT_TURNS) % 360;
grid.style.transform = 'rotateX(' + TILT + 'deg) rotateZ(' + rotY + 'deg)';
for (var i = 0; i < pts.length; i++) {
var b = base[i];
var rise = b[2] * p * HEIGHT_SCALE;
pts[i].style.transform = 'translate3d(' + b[0] + 'px,' + (-rise) + 'px,' + b[1] + 'px)';
}
pctEl.textContent = Math.round(p * 100) + '%';
}
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);
}
})();// TerrainGridRisePerspective — vanilla 판(run/399/demo/08-loader-3d-terrain-grid-rise-perspective/index.html) 의 React 포트.
// 격자점마다 고정된(무작위 없음) 높이 가중치를 결정적으로 계산해 진행률만큼 끌어올리고,
// 점마다 다른 Z 값을 CSS 원근 투영에 맡겨 가까운 점은 크게 먼 점은 작게 보이게 한다.
// 01~07 과 다른 축 — 고정 가중치 필드 값을 원근 투영과 결합한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./TerrainGridRisePerspective.module.scss";
export type TerrainGridRisePerspectiveProps = {
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 GRID_COLS = 7;
const GRID_ROWS = 5;
const COL_GAP = 20;
const ROW_GAP = 24;
const HEIGHT_SCALE = 54;
const ROT_TURNS = 0.35;
const TILT = 58;
const LOOP = 2;
const REDUCE_P = 0.7;
const FIELD_LABEL = "지형 격자 원근 진행 실험판";
function weightOf(col: number, row: number): number {
return (Math.sin(col * 1.1 + row * 0.6) + Math.cos(row * 1.3 - col * 0.4) + 2) / 4;
}
const BASE: { x: number; z: number; w: number; row: number }[] = [];
for (let row = 0; row < GRID_ROWS; row++) {
for (let col = 0; col < GRID_COLS; col++) {
const x = (col - (GRID_COLS - 1) / 2) * COL_GAP;
const z = row * ROW_GAP;
BASE.push({ x, z, w: weightOf(col, row), row });
}
}
export default function TerrainGridRisePerspective({
duration = "2s",
easing = "linear",
color = "#0a4fe8",
}: TerrainGridRisePerspectiveProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const gridRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
const ptsRef = useRef<(HTMLDivElement | null)[]>(new Array(BASE.length).fill(null)) as Box<(HTMLDivElement | null)[]>;
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;
}
function render(p: number): void {
lastPRef.current = p;
const rotY = (p * 360 * ROT_TURNS) % 360;
const grid = gridRef.current;
if (grid) grid.style.transform = "rotateX(" + TILT + "deg) rotateZ(" + rotY + "deg)";
const pts = ptsRef.current;
for (let i = 0; i < BASE.length; i++) {
const el = pts[i];
if (!el) continue;
const b = BASE[i];
const rise = b.w * p * HEIGHT_SCALE;
el.style.transform = "translate3d(" + b.x + "px," + (-rise) + "px," + b.z + "px)";
}
if (pctRef.current) pctRef.current.textContent = Math.round(p * 100) + "%";
}
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(() => {
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.grid} ref={gridRef}>
{BASE.map((b, i) => (
<div key={i} className={styles.pt} style={{ zIndex: b.row }} ref={(el: HTMLDivElement | null) => { ptsRef.current[i] = el; }} />
))}
</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>
);
}08 · 격자점이 값만큼 솟아 지형처럼 보이는 원근 로더 — 측정 누적면적 7.95% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function render(p) {
lastP = p;
var rotY = (p * 360 * ROT_TURNS) % 360;
grid.style.transform = 'rotateX(' + TILT + 'deg) rotateZ(' + rotY + 'deg)';
for (var i = 0; i < pts.length; i++) {
var b = base[i];
var rise = b[2] * p * HEIGHT_SCALE;
pts[i].style.transform = 'translate3d(' + b[0] + 'px,' + (-rise) + 'px,' + b[1] + 'px)';
}
pctEl.textContent = Math.round(p * 100) + '%';
}
09값이 오를수록 안쪽 고리의 세차각이 벌어지는 자이로스코프 로더
안쪽 고리의 회전축이 그리는 원뿔 위상(PRECESSION_TURNS=1.5바퀴)의 cos·sin 을 X·Z 축에 나눠 실어, PRECESSION_FACTOR(26)×p 만큼 벌어지는 세차각이 OUTER_TILT(22도) 바깥 고리와의 차이로 눈에 보이는 흔들림을 만듭니다. 장기 대기 로딩·백그라운드 동기화 표시에 씁니다.

<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__gyro" id="gyro">
<div class="c3__ring c3__ring--outer" id="outer"></div>
<div class="c3__ring c3__ring--inner" id="inner"></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 *;
// 자이로스코프 세차 고리 로더 — 안쪽 고리의 회전축이 진행률 값에 비례해 원뿔 모양으로 벌어지며
// 도는 세차운동(precession)을 계산한다. 바깥 고리는 고정 기울기로 계속 돌고, 안쪽 고리는
// 진행률만큼 커지는 세차각을 cos/sin 으로 두 축(X·Z)에 나눠 실어 원뿔을 그리며 돈다.
// 값이 다 차면 세차각이 가장 크게 벌어져 바깥 고리와의 차이가 가장 눈에 띈다.
// 01(문턱 인덱스 스왑)·02(정점 좌표 보간)·03(연속 순환 랭크)·04(고리별 개별 기울기)·05(구면 중심 수렴)
// ·06(면별 독립 문턱 램프)·07(회전각+거리 깊이 순위)·08(고정 가중치 필드+원근) 과 다른 축
// — 세차각을 원뿔 위상(phase)의 삼각함수로 두 축에 나눠 계산한다.
$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);
cursor: pointer;
}
.ld__field:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.c3__scene { position: absolute; inset: 0; perspective: clamp(320px, 70vw, 480px); }
.c3__gyro { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform-style: preserve-3d; }
.c3__ring {
position: absolute; left: -60px; top: -60px;
width: 120px; height: 120px;
border-radius: 50%;
transform-style: preserve-3d;
}
.c3__ring--outer { box-shadow: inset 0 0 0 3px rgba($subject-ink, .28); }
.c3__ring--inner {
left: -42px; top: -42px; width: 84px; height: 84px;
box-shadow: inset 0 0 0 3px $color;
}
.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; }
.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);
cursor: pointer;
}
.ld__field:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.c3__scene {
position: absolute;
inset: 0;
perspective: clamp(320px, 70vw, 480px);
}
.c3__gyro {
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
transform-style: preserve-3d;
}
.c3__ring {
position: absolute;
left: -60px;
top: -60px;
width: 120px;
height: 120px;
border-radius: 50%;
transform-style: preserve-3d;
}
.c3__ring--outer {
box-shadow: inset 0 0 0 3px rgba(23, 20, 26, 0.28);
}
.c3__ring--inner {
left: -42px;
top: -42px;
width: 84px;
height: 84px;
box-shadow: inset 0 0 0 3px #0a4fe8;
}
.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;
}
.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 outer = document.getElementById('outer');
var inner = document.getElementById('inner');
var OUTER_TILT = 22;
var SPIN_TURNS = 1;
var PRECESSION_TURNS = 1.5;
var PRECESSION_FACTOR = 26; // 진행률이 다 찼을 때 세차각이 벌어지는 최대 각도(도) — 변형 03 대상
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LOOP = 2;
var REDUCE_P = 0.7;
var t0 = 0, raf = 0, paused = false, lastP = 0;
// 안쪽 고리의 회전축이 그리는 원뿔 — 세차각(진행률에 비례해 커진다)을 원뿔 위상의 cos/sin 으로
// X축·Z축 두 방향에 나눠 실어, 축 자체가 원을 그리며 벌어지는 세차운동을 흉내 낸다.
function render(p) {
lastP = p;
var spinY = (p * 360 * SPIN_TURNS) % 360;
var maxAngle = PRECESSION_FACTOR * p;
var conePhase = (p * 360 * PRECESSION_TURNS) % 360;
var rad = conePhase * Math.PI / 180;
var tiltX = OUTER_TILT + maxAngle * Math.cos(rad);
var tiltZ = maxAngle * Math.sin(rad);
outer.style.transform = 'rotateY(' + spinY + 'deg) rotateX(' + OUTER_TILT + 'deg)';
inner.style.transform = 'rotateY(' + spinY + 'deg) rotateX(' + tiltX + 'deg) rotateZ(' + tiltZ + 'deg)';
pctEl.textContent = Math.round(p * 100) + '%';
}
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);
}
})();// GyroscopeRingPrecession — vanilla 판(run/399/demo/09-loader-3d-gyroscope-ring-precession/index.html) 의 React 포트.
// 안쪽 고리의 회전축이 그리는 원뿔 — 세차각(진행률에 비례해 커진다)을 원뿔 위상의 cos/sin 으로
// X축·Z축 두 방향에 나눠 실어, 축 자체가 원을 그리며 벌어지는 세차운동을 흉내 낸다.
// 01~08 과 다른 축 — 세차각을 원뿔 위상의 삼각함수로 두 축에 나눠 계산한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./GyroscopeRingPrecession.module.scss";
export type GyroscopeRingPrecessionProps = {
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 OUTER_TILT = 22;
const SPIN_TURNS = 1;
const PRECESSION_TURNS = 1.5;
const PRECESSION_FACTOR = 26;
const LOOP = 2;
const REDUCE_P = 0.7;
const FIELD_LABEL = "자이로스코프 세차 고리 진행 실험판";
export default function GyroscopeRingPrecession({
duration = "2s",
easing = "linear",
color = "#0a4fe8",
}: GyroscopeRingPrecessionProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const outerRef = useRef<HTMLDivElement | null>(null);
const innerRef = useRef<HTMLDivElement | null>(null);
const pctRef = useRef<HTMLSpanElement | null>(null);
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;
}
function render(p: number): void {
lastPRef.current = p;
const spinY = (p * 360 * SPIN_TURNS) % 360;
const maxAngle = PRECESSION_FACTOR * p;
const conePhase = (p * 360 * PRECESSION_TURNS) % 360;
const rad = (conePhase * Math.PI) / 180;
const tiltX = OUTER_TILT + maxAngle * Math.cos(rad);
const tiltZ = maxAngle * Math.sin(rad);
const outer = outerRef.current;
const inner = innerRef.current;
if (outer) outer.style.transform = "rotateY(" + spinY + "deg) rotateX(" + OUTER_TILT + "deg)";
if (inner) inner.style.transform = "rotateY(" + spinY + "deg) rotateX(" + tiltX + "deg) rotateZ(" + tiltZ + "deg)";
if (pctRef.current) pctRef.current.textContent = Math.round(p * 100) + "%";
}
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(() => {
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.gyro}>
<div className={styles.ring + " " + styles.ringOuter} ref={outerRef} />
<div className={styles.ring + " " + styles.ringInner} ref={innerRef} />
</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>
);
}09 · 값이 오를수록 안쪽 고리의 세차각이 벌어지는 자이로스코프 로더 — 측정 누적면적 7.29% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 안쪽 고리의 회전축이 그리는 원뿔 — 세차각(진행률에 비례해 커진다)을 원뿔 위상의 cos/sin 으로
// X축·Z축 두 방향에 나눠 실어, 축 자체가 원을 그리며 벌어지는 세차운동을 흉내 낸다.
function render(p) {
lastP = p;
var spinY = (p * 360 * SPIN_TURNS) % 360;
var maxAngle = PRECESSION_FACTOR * p;
var conePhase = (p * 360 * PRECESSION_TURNS) % 360;
var rad = conePhase * Math.PI / 180;
var tiltX = OUTER_TILT + maxAngle * Math.cos(rad);
var tiltZ = maxAngle * Math.sin(rad);
outer.style.transform = 'rotateY(' + spinY + 'deg) rotateX(' + OUTER_TILT + 'deg)';
inner.style.transform = 'rotateY(' + spinY + 'deg) rotateX(' + tiltX + 'deg) rotateZ(' + tiltZ + 'deg)';
pctEl.textContent = Math.round(p * 100) + '%';
}
면·정점·깊이 값이 한 루프 동안 화면을 얼마나 바꾸는지 — 아홉 항목 실측
| 항목 | 누적면적 | 강도_평균 | 움직인프레임 |
|---|---|---|---|
| 01 정육면체 면 전환 | 8.6% | 92.8 | 23/23 |
| 02 와이어프레임 정점 스냅 | 4.9% | 87.1 | 23/23 |
| 03 카드 스택 깊이 순환 | 5.4% | 95.2 | 23/23 |
| 04 고리 궤도 기울기 | 15.8% | 102.8 | 23/23 |
| 05 구면 점 수렴 | 6.1% | 150.6 | 23/23 |
| 06 다각기둥 면 채움 | 16.7% | 142.6 | 23/23 |
| 07 플립 패널 깊이 정렬 | 12.2% | 217.6 | 23/23 |
| 08 격자 지형 상승 | 8.0% | 141.8 | 23/23 |
| 09 자이로 세차 | 7.3% | 88.9 | 23/23 |
아홉 개 모두 23/23 프레임이 움직여 멈춰 있는 순간이 없습니다. 누적면적은 06(16.7%)·04(15.8%)가 가장 넓은데 육각기둥 여섯 면 전체와 고리 네 개가 화면을 크게 덮기 때문이고, 02(4.9%)가 가장 좁은 건 가는 선 12개짜리 와이어프레임이라 로더 요소 자체가 작아서입니다 — 컴포넌트 급 로더는 원래 면적이 작은 게 정상이고, 그 대신 화가 알고리즘·lerp 곡선 같은 계산 한 겹으로 품질을 잽니다. 강도는 07(217.6)이 가장 세게 튀는데, 패널이 뒤집히는 순간 대비가 큰 두 면이 한 프레임에 같이 움직이기 때문입니다.
대시보드 스켈레톤 대신 이 css 로딩 애니메이션 아홉 개를 어디에 붙이나?
검색·파일 업로드처럼 몇 초 안에 끝나는 로딩엔 01 정육면체 면 전환이나 03 카드 스택처럼 단계가 딱 떨어지는 쪽이 자연스럽습니다. 서버가 진짜 진행률 값을 안 줘도, 문턱 네 개(01)나 카드 다섯 장(03)이 순서대로 넘어가는 모습만으로 사용자는 뭔가 진행되고 있다는 걸 읽습니다.
반대로 지도 타일·3D 에셋처럼 실제로 몇 초씩 걸리는 로딩엔 08 격자 지형처럼 값이 오를수록 점점 솟아오르는 쪽이 진행률 없는 스피너보다 이탈을 줄입니다. 온보딩 튜토리얼의 단계 넘기기에는 07 플립 패널을 진행 표시 겸 스텝 인디케이터로 같이 씁니다.
FACE_THRESHOLD·PRECESSION_FACTOR 값이 1.0 에 끝내 닿지 못해 깨지는 지점
t0 = performance.now() 뒤 p = (t % LOOP) / LOOP 는 연속된 값이라, t 가 LOOP(2초)의 배수에 닿는 찰나 p 는 1 이 아니라 그대로 다음 루프의 0 으로 넘어갑니다. 04 는 tilt = INITIAL_TILT[i] * (1 - p) 로 고리 기울기를 0도에 접근시키지만 p 가 정확히 1 에 닿는 프레임이 없어, 네 고리는 매 루프 끝에서 0.0x도의 미세한 기울기 차이를 남긴 채 다시 벌어집니다. 09 도 같은 이유로 maxAngle = PRECESSION_FACTOR * p 가 26에 끝내 닿지 못하고 25점대에서 리셋되어, 배지 숫자가 100% 를 스치듯 지나갈 뿐 정확히 찍지는 않습니다. 소스를 내려받아 이 지점을 직접 고쳐 보려는 분은 gfc9k6p7 을 입력해 압축을 풀면 아홉 개 원본을 한 번에 받을 수 있습니다.
reduced-motion 설정 하나로 아홉 개가 동시에 멈추는 이유 — 접근성
prefers-reduced-motion 이 reduce 이면 raf 루프 자체를 돌리지 않고 render(REDUCE_P)(0.7)로 고정 프레임 한 장만 그립니다. 클릭·스페이스바·R 키는 여전히 동작하지만 reduce 상태에서는 togglePause·restart 가 다시 render(REDUCE_P)만 호출해 화면은 항상 같은 자세로 돌아옵니다 — 로더가 멎어 있다는 오해를 막으려 0%·100% 양 끝이 아니라 70% 지점 한 장으로 고정한 선택입니다. role="application"·aria-label 은 스크린리더가 각 로더를 무슨 실험판인지 읽게 해 줍니다.
FACE_THRESHOLD·LERP_EASE_POW·PRECESSION_FACTOR 세 값만 바꿔도 css 3d 회전 인상이 달라진다
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 정육면체 면 전환 문턱 조정 | 01 FACE_THRESHOLD 25 → 18 | 문턱 값을 낮추면 더 적은 진행률 값으로도 다음 면으로 넘어가 면 전환이 더 잦아지는 쪽에 가깝습니다 |
| 와이어프레임 정점 lerp 속도 조정 | 02 LERP_EASE_POW 1.8 → 1.2 | 보간 속도 지수를 낮추면 정점이 목표 좌표에 더 빨리 도착해 도형 윤곽이 이른 진행률에서 먼저 또렷해지는 쪽에 가깝습니다 — 지수가 클수록 시작이 느린 곡선이라 낮출수록 초반 이동이 빨라집니다 |
| 자이로스코프 세차각 계수 조정 | 09 PRECESSION_FACTOR 26 → 34 | 계수를 높이면 같은 진행률에서도 안쪽 고리 세차각이 더 크게 벌어져 흔들림이 더 두드러지는 쪽에 가깝습니다 |
정육면체 말고 다른 3D 회전·틸트 로더는 어디서 보나?
포인터를 따라 기울어지는 카드·버튼이 궁금하면 css 버튼 입체감 9가지 — 클릭 효과까지 커스텀 코드 를, 캐러셀처럼 옆으로 넘기는 큐브가 필요하면 CSS 3D 큐브 캐러셀 틸트 9종 — 한 줄 복붙으로 를, 마우스를 따라 통째로 도는 장면이 필요하면 마우스를 따라오는 CSS 3D 회전 장면 9종 을 엽니다. rAF 물리 계산으로 그리는 캔버스 쪽 로더 아홉 개는 캔버스 로딩 애니메이션 효과 9가지 — 코드 그대로 에 있습니다.
3D 로더 zip 을 열기 전에 자주 나오는 질문
css 카드 뒤집기 애니메이션(07)만 따로 떼어 써도 되나요?
네, 07 은 PANEL_COUNT·STEP·RANK_ZGAP 세 값과 flipAngle·depthRank 두 함수만 있으면 다른 로더 없이도 그대로 동작합니다. HTML 구조(패널 다섯 장)와 CSS perspective·preserve-3d만 같이 가져가면 됩니다.
9개 중 성능이 가장 무거운 건 무엇인가요?
강도_평균 기준으로는 07(217.6)이 가장 세게 움직이는데, 패널이 뒤집히는 순간 두 면이 동시에 대비 크게 바뀌기 때문입니다. DOM 요소 개수로 보면 02 가 정점 8개·선 12개를 매 프레임 좌표 계산으로 다시 그려 가장 무겁습니다.
아홉 로더 중 하나만 프로젝트에 붙이고 나머지 여덟은 빼도 문제없나요?
네, 아홉 개는 서로 참조하지 않는 독립된 IIFE 라 로더 블록 하나와 그 항목의 스크립트만 옮기면 됩니다. 다만 demo-tokens.scss 팔레트 변수는 아홉 개가 공유하므로 색을 그대로 쓰려면 같이 가져갑니다.









