04値が上がるほどリング間の傾き差が狭まる軌道ローダー
リングごとに異なる傾き角度で重ねておき、進捗値が上がる割合だけ各リングの傾きを正面角度へ寄せ、値が満ちると全リングが同じ角度で重なる。
使いどころ同期進行表示、多層ローディング
INITIAL_TILTSPIN_TURNSperspective

<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 に同梱 · css 3d 回転ローダー9種 — 値で面切替
このデモが属する記事
css 3d 回転ローダー9種 — 値で面切替css 3d 回転を使うローダーは、キーフレームで揺れるだけの飾りではありません。ローダーの最初のフレームでは平らな面が一枚だけ見えるが、値がしきい値を超えるたびに六面体の面が切り替わり、ワイヤーフレームの頂点が定位置へ集まり、最後のフレー この記事のデモをすべて見る

