css 3d 回転ローダー9種 — 値で面切替
css 3d 回転を使うローダーは、キーフレームで揺れるだけの飾りではありません。ローダーの最初のフレームでは平らな面が一枚だけ見えるが、値がしきい値を超えるたびに六面体の面が切り替わり、ワイヤーフレームの頂点が定位置へ集まり、最後のフレームでは内側リングの歳差角が最大に広がった分だけ値が満ちたと読める —
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 値がしきい値を超えるたびに次の面へ切り替わる六面体ローダー
- 02 進捗値の分だけ頂点が定位置に集まるワイヤーフレームローダー
- 03 値が上がるたびに奥のカードが奥行き順を変えて手前に来るカードスタックローダー
- 04 値が上がるほどリング間の傾き差が狭まる軌道ローダー
- 05 球面上の点が一点に集まる収束ローダー
- 06 回転しながら通過した面から色が満ちる多角柱ローダー
- 07 パネルが反転するたびに前後順を再計算するフリップローダー
- 08 格子点が値の分だけ盛り上がり地形に見える遠近ローダー
- 09 値が上がるほど内側リングの歳差角が広がるジャイロスコープローダー
下の九つの立体ローダーはすべて同じ二つのCSSプロパティ — 親要素の perspective と子要素の transform-style: preserve-3d — と、毎フレーム同じ一つの数値で動いています。ただし「完了」という状態の表し方はパターンごとに違い、面のインデックス(01)、補間の割合(02)、奥行きの順位(03)、傾きの角度(04)、半径の収縮(05)、塗りのしきい値(06)、反転角度(07)、高さの上昇(08)、歳差円錐(09)と、それぞれ別の幾何学で「値が満ちた」を表現します。並び順は、一つの数値が四つの面のどれかを選ぶだけの単純なケースから、一つの値が同時に二軸の回転を動かさなければならないケースへと進みます。
デモ
上の九つのタイルはそれぞれ小さなDOMの場面です。一つの perspective 親要素の中に数個の絶対配置された要素が入っていて、毎フレームの requestAnimationFrame で呼ばれる render(p) 関数がそれらを3D回転・移動させています。タイルをクリックするか、フォーカスした状態でSpaceキーかEnterキーを押すか、ウィンドウをリサイズすると、そのタイルは現在の進捗値から自分の変形をもう一度計算し直します — ここには単独でループする CSS の @keyframes は一つもなく、ループもイージングも幾何学もすべて一つの数値を読むプレーンなJavaScriptです。
キャンバス物理ローダーとポインター追従ボタンとの違い
九つのキャンバス物理ローダーは、流体の表面や粒子の群れ、セルオートマトンを計算して ctx.fillRect や ctx.arc でピクセルとして描いています — 回転しているDOM要素は一つもなく、毎フレーム塗り直される2Dビットマップがあるだけです。これから紹介する九つはキャンバスのコンテキストに一切触れません。面も頂点もカードもリングもすべて実在するDOM要素で、「3D」らしさは perspective と、スクリプトが計算して style.transform に代入する rotateX・rotateY・translateZ の値だけから生まれています。
描画方法がいちばん近い親戚はポインター追従ボタンの九種で、こちらも実在するDOM要素にCSSの3D変形をかけています。ただし入力の出どころは正反対です。あのボタン九種は mousemove のたびに clientX と clientY を読み、ポインターが今いる方向へ傾きます。つまり形はポインターが今どこにあるかの関数です。これから紹介する九つのローダーはポインター位置を一切読みません。入力はタイマーで進む0から1の進捗値ただ一つなので、04番のリングの収束も09番の内側リングの歳差角の広がりも、カーソルがタイル付近にあろうとなかろうと同じように進みます。
下の累積面積と強度の数値が実際に測っているもの
下の数値はそれぞれのデモを実際に画面収録して測った実測値で、目分量ではありません。累積面積はループ全体でタイルの何%が変化したか、平均強度は変化した箇所のピクセル差分バイト値の平均、動いたフレームは23回のフレーム間差分のうち実際にピクセルが動いた回数を数えています。
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 立方体 面しきい値切替 | 8.577% | 92.8 | 23/23 |
| 02 | ワイヤーフレーム 頂点補間スナップ | 4.867% | 87.1 | 23/23 |
| 03 | カードスタック 奥行き循環 | 5.401% | 95.2 | 23/23 |
| 04 | リング軌道傾き収束 | 15.808% | 102.8 | 23/23 |
| 05 | 球面点収束 | 6.127% | 150.6 | 23/23 |
| 06 | 角柱 面塗り回転 | 16.692% | 142.6 | 23/23 |
| 07 | フリップパネル 奥行きソート | 12.239% | 217.6 | 23/23 |
| 08 | 地形グリッド 遠近隆起 | 7.95% | 141.8 | 23/23 |
| 09 | ジャイロスコープ リング歳差 | 7.29% | 88.9 | 23/23 |
九つとも23回の測定差分すべてで動いていますが、面積は4.867%から16.692%までかなり幅があります。ローダーはもともと画面上で小さい要素なので、面積が小さいこと自体は欠陥ではありません。この表が本当に示しているのは、css アニメーション 回転 3d のように見える01番の立方体も、07番のパネル反転も、タイルの5分の1にも満たない面積のままほぼ毎フレーム再描画され続けているという点です。
一つの0〜1の値が面・頂点・軌道へ変わる九つの方法
以下のコードブロックはそれぞれ、そのパターン自身のソースファイルから切り出した無編集の断片で、上のデモとは別に書き起こしたものではありません。記事の終わりにあるzip内のファイルを開いても同じ内容です。
01パーセントがしきい値を超えるたびに面が進む六面体ローダー
深度ソートは毎フレーム、保存しておいた法線角と現在の回転角を足したもののコサインで四つの面を並べ直すので、z-indexは常にカメラにいちばん近い面と一致します。パーセント値が FACE_THRESHOLD を超えるたびに立方体は次の90度の面へ進みます。ファイル変換の進捗表示や複数ステップのインストール表示に向いていて、決まった数の段階がすでにあるなら、それがそのまま面の数と対応します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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進捗の値でワイヤーフレームの頂点が定位置へ集まるローダー
八つの頂点は散らばった固定位置から始まり、Math.pow(p, LERP_EASE_POW) の割合で立方体の角へ補間で近づきます。頂点をつなぐ12本のSVG線も毎フレーム引き直されるので、値が上がるほど輪郭がはっきりしていきます。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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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奥のカードが深度順位を計算し直して手前へ進むローダー
rankOf() は各カードの手前からの順位を (i - s) % CARD_COUNT という一つの連続した値として計算するので、スタックの順序は配列を物理的に並べ替えるのではなく、毎フレーム一つの数値から丸ごと再構築されます。複数ステップのチェックアウト進捗やオンボーディング表示に向いていて、一番手前にあるカードが常に「今のステップ」として読めます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// カード 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積み重なったリングが値とともに傾きの差を縮めるローダー
四つのリングはそれぞれ別の傾き角から始まり、進捗に比例して0度へ向けて個別に補間されるので、最初はばらばらの四つのリングに見えていたものが、進捗が満ちた瞬間にぴったり一枚のリングへ収束します。同期進捗の表示や複数レイヤーの読み込み表示に向いていて、並行して走っている複数の処理が一つの完了状態へ合流したように見せられます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var INITIAL_TILT = [72, 54, 36, 18]; // 各リングは個別の傾き(度)から始まり -- 値が上がるほど0度へ収束する
var SPIN_TURNS = 0.6; // リングの束が1ループで何回転するか
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); // 進捗に比例して正面向き角度(0度)へ補間する
rings[i].style.transform = 'rotateY(' + spinY + 'deg) rotateX(' + tilt + 'deg)';
}
pctEl.textContent = Math.round(p * 100) + '%';
}
05球面上の点が一点へ収束するローダー
固定されたフィボナッチ球分布 — Math.random ではなく黄金角の定数 — が各点の基準位置を決め、すべての点が原点へ向けて (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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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) + '%';
}
06回転する多角柱が通過した順に面を塗りつぶすローダー
fillAmount() は角柱の六つの面それぞれに独自のしきい値(i / PRISM_SIDES)と短いフェードイン区間を与えているので、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__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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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パネルが反転するたびに前後の順序を計算し直すローダー
currentIndex() と depthRank() はどちらも同じ p から導かれるので、0度から180度の間で反転中の一枚のパネルが常にカメラにいちばん近く配置され、隣のパネルはそのインデックスからの距離に応じて奥へ退きます。チュートリアルのステップ遷移やカード形式のウォークスルー進捗に向いていて、各ステップが目に見えて一枚ずつ反転していきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// パネル 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グリッドの点が値に応じて遠近感のある地形として隆起するローダー
各グリッド点の高さは0から始まり、rise = weight * p * HEIGHT_SCALE の式で上がっていきます。固定の rotateX() の傾きが平らなグリッドに斜めの視点を与えているので、隆起していく点群は棒グラフというより地形のように見えます。地図データの読み込みやダッシュボードの初期読み込み背景に向いていて、読み込み中の状態がそのままこれから表示される内容のプレビューにもなります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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_FACTOR * p)が広がる円錐を描きます。円錐の位相のコサインとサインでX軸とZ軸に振り分けているので、軸そのものが円を描くように動きます。長時間の読み込み画面やバックグラウンド同期の表示に向いていて、最初の数秒を過ぎてもまだ生きている感じを保ちたい場面によく合います。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 内側リングの回転軸が描く円錐 -- 歳差角(進捗とともに増える)をコーン位相の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) + '%';
}
FACE_THRESHOLDが時間内に終わらない落とし穴になって壊れる理由
01番の落とし穴は Math.min(faces.length - 1, Math.floor(pct / FACE_THRESHOLD)) の中にあるクランプです。面は四つしかないので、最後のインデックス(3)は pct が 3 * FACE_THRESHOLD を超えた瞬間に到達します。既定値の25ならpctが75の時点で、ループの残り25ポイントの間は最後の面が静止します。「もっと反応をよくしよう」と FACE_THRESHOLD を下げると、このクランプはむしろ早く効いてしまいます。18まで下げると最後の面はpct 54で固定され、静止する幅は25ポイントから46ポイントへ広がります。しきい値を下げると最初の三面の切り替えは速くなりますが、最後の面はループの半分近く止まって見えるようになり、狙いとは正反対の結果になります。直すべきなのは FACE_THRESHOLD をさらに下げることではなく、面の数に合わせて FACE_THRESHOLD = Math.ceil(100 / faces.length) とし、四面すべてに均等な取り分を与えることです。
最初に触ってみる価値がある三つの値と、reduced-motion が取り除くもの
| 変形 | 変更する値 | どう感じるはずか |
|---|---|---|
| 立方体の面切替しきい値を調整する | 01 FACE_THRESHOLD 25 → 18 | しきい値を下げると、より小さい進捗値で次の面へ切り替わるはずなので、面の切り替えがより頻繁に起きて感じられます。 |
| ワイヤーフレームの頂点補間速度を調整する | 02 LERP_EASE_POW 1.8 → 1.2 | 頂点補間の指数を下げると、頂点がより早く目標座標へ到達するはずなので、進捗の早い段階から輪郭がはっきりして感じられます。 |
| ジャイロスコープの歳差角係数を調整する | 09 PRECESSION_FACTOR 26 → 34 | 係数を上げると、同じ進捗値でも内側リングの歳差角がより大きく広がるはずなので、揺れがより強く感じられます。 |
九つとも prefers-reduced-motion はページが最初に読み込まれた瞬間に一度だけ読み、毎フレーム読むことはありません。このメディアクエリが reduce を返した場合、ループは一度も始まらず、代わりに固定の進捗値で一枚の静止フレームだけが描かれます。すべてのタイルには role="application"、tabindex="0"、aria-label も付いていて、クリック・Enterキー・Spaceキー(どちらもループの再生・停止を切り替える)・Rキー(再スタート)を同じハンドラーが受け取り、さらにリサイズ時にはそのサイズでタイルを描き直すリスナーもあります。マウスを使わないアクセシビリティの利用者でも、九つのどれでも再生・一時停止・再スタートができます。パターンごとに一つずつある九つのソースファイルは、この記事のデモと同じフォルダの中で gfc9k6p7 の先に置かれています。上の落とし穴に向けて FACE_THRESHOLD を下げる前に一度開いてみてください。回り込まない面は測定表の上では問題なく見えても、実際の読み込み画面では止まって見えることがあります。
この九つを実際にどこで使うか
01番の面切替立方体は、ファイル変換の進捗画面や複数ステップのインストールウィザードに向いています。決まった数の段階がすでにあるなら、それがそのまま面の数と対応しますし、深度ソートのおかげで段階ごとに余分なDOMノードを増やさずに見えている面を正しく保てます。
03番のカードスタック循環と07番のフリップパネルは、どちらもチェックアウトやオンボーディングの流れに向いています。人が見つめている面がもともとカード状だからです。違いは各ステップがどれだけ「確定した」感じにしたいかで、03番はカードを扇状のスタックのまま保ち続けるのに対し、07番は一枚のパネルを完全に手前へ寄せて隣のパネルを0度か180度のどちらかに固定します。
09番のジャイロスコープ歳差は、バックグラウンド同期の表示や長時間の読み込み画面で選ぶ価値があります。ループが続く限り揺れが広がり続けるので、94%で静かに止まってしまうバーよりも「まだ動いている」ことをずっとよく伝えられるからです。
css ローディング アニメーション の実例を他でも探すなら
css スピナー で検索すればすぐに見つかりますし、それも出発点としては悪くありません。ただし出てくるものの大半は一つの要素が CSS の @keyframes 一本でループしているだけです。深度ソートも補間カーブも歳差円錐もその下にはありません。@keyframes は一つのプロパティを二つの固定値の間で補間するだけだからです。06番の回転する角柱も09番のジャイロスコープも、プロパティが値から値へイージングするのではなく、毎フレームプレーンなJavaScriptの数値が変形全体を計算し直しているからこそあの動きになります。すぐプロジェクトに組み込める動く css 3d 回転 のパターンを探しているなら、上の九つがまさにそれです。各パターンの下にあるソースを開いてファイルをコピーし、自分のアップロードやリクエストが実際に返す進捗の値に差し替えてください。
3Dローダーを組む前に答えておきたい、よくある質問
07番を一枚だけのカードで使う3Dカードフリップとしても使えますか?
はい。PANEL_COUNT を1にしても flipAngle() は進捗全体を通じて0度から180度へ線形に補間し続けるので、存在しなくなった隣のパネルとの深度順位計算を気にせず、一枚だけ反転するカードとして使えます。
01番の立方体と06番の角柱はどちらもしきい値で塗るのに、なぜ見え方が違うのですか?
01番はしきい値を超えた瞬間に見えている面全体を一気に切り替えますが、06番はそれぞれの面がしきい値を超えてから FILL_SPAN の間だけ徐々にフェードインします。片方は瞬間的な切り替え、もう片方は短いクロスフェードで、どちらも同じ進捗値に対するしきい値の比較です。
この九つにライブラリやWebGL、CDNスクリプトは必要ですか?
いいえ。九つとも perspective、transform-style: preserve-3d、そして requestAnimationFrame ループの中で style.transform を動かすプレーンなDOM要素だけで動いています。この記事の裏にある九つのファイルのどこにも Three.js もキャンバスのWebGLコンテキストも外部scriptタグもありません。
出典: MDN — perspective · MDN — transform-style · MDN — rotate3d() · MDN — prefers-reduced-motion · MDN — progressbar role
関連記事: ローディング アニメーション js 9パターン — 物理演算で作る · 【一行でコピペOK】CSS 3D アニメーション9選 · マウス 追従 アニメーション css で作る3D場面9選 — 一つ目は同じ九種構成をDOM変形の代わりに2Dキャンバスで動かしたもの、二つ目はCSS 3Dの近い親戚で別の回転値の組み合わせで立方体カルーセルを作ったもの、三つ目は上の04番や09番が時間だけから動くのとは逆に、九つの場面をポインター位置から動かしています。









