02Loader Whose Wireframe Vertices Settle Into Place With the Progress Value
Vertices start scattered at random positions and move toward their target coordinates by a fraction equal to the progress value, with lines connecting the moved vertices so the outline sharpens as the value rises.
Best for3D asset loading, map-data fetch indicators
LERP_EASE_POWTARGETStransform-style

<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="Wireframe vertex convergence test panel" 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">Progress</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 Loader Whose Wireframe Vertices Settle Into Place With the Progress Value — Measured area 4.867% · 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 QR Code Whose Modules Fill In With the Progress ValueQR Code Loading Animation — 9 CSS Expiry Patterns
Loaders05 Extraction progress and form values changing together — while the extraction progress value fills, a form value changes along with itDownload Progress Bar: 9 Loading UI Patterns