04Loader Whose Stacked Rings Narrow Their Tilt Gap as the Value Rises
Each ring starts stacked at its own tilt angle, and as the progress value rises every ring's tilt is nudged toward a shared front-facing angle, so at full value all rings overlap at the same angle.
Best forSync-progress displays, multi-layer loading states
INITIAL_TILTSPIN_TURNSperspective

<main class="stage">
<div class="ld" id="ld">
<p class="ld__hint">Click or press the space bar to pause or restart</p>
<div class="ld__field" id="field" role="application" aria-label="Orbit-ring tilt-convergence test panel" 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">Progress</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 Loader Whose Stacked Rings Narrow Their Tilt Gap as the Value Rises — Measured area 15.808% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS 3D Cube Animation Loaders, 9 Patterns
The post this demo lives in
CSS 3D Cube Animation Loaders, 9 PatternsA css 3d cube animation isn't a keyframe tween: Frame one shows one flat face. Each threshold swaps a cube face, settles See all demos in this post
If you like this effect - similar demos
Loaders05 Icon progress ring — an indicator whose circular progress value fills with the pulled distance and morphs into a checkmark once completePull to Refresh CSS: 9 Loading Patterns
Loaders01 Halo Ring AI Loader Whose Hue Value Turns Toward DoneAI Loading Animation: 9 Patterns by One Value
Loaders03 QR Code Whose Pulse Ring Narrows by the Time Left ValueQR Code Loading Animation — 9 CSS Expiry Patterns