GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS 3D Cube Animation Loaders, 9 Patterns

A css 3d cube animation isn't a keyframe tween: Frame one shows one flat face. Each threshold swaps a cube face, settles wireframe vertices; the last

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine computed 3D loaders below all share the same two CSS properties -- perspective on a parent and transform-style: preserve-3d on its children -- and the same one JavaScript number driving every frame, but each reaches a different geometric idea of "done": a face index (01), a lerp fraction (02), a depth rank (03), a tilt angle (04), a radius shrink (05), a fill threshold (06), a flip angle (07), a height rise (08), and a precession cone (09). The order below moves from the simplest case -- one number picking one of four faces -- toward the one where a single value has to drive two axes of rotation at once.

Demo

Each of the nine tiles above is a small DOM scene: a handful of absolutely positioned elements inside one perspective parent, rotated and translated in 3D by a render(p) function that runs on every requestAnimationFrame tick. Click a tile, press Space or Enter on it, or resize the window, and it recomputes its own transforms from the current progress value -- nothing here is a single CSS @keyframes block looping on its own; the loop, the easing, and the geometry are all plain JavaScript reading one number.

How this set differs from the canvas-physics loaders and the pointer-tilt buttons

The nine canvas-physics loaders compute a fluid surface, a particle swarm, or a cellular automaton and draw the result as pixels with ctx.fillRect and ctx.arc -- there's no DOM element being rotated, just a 2D bitmap being repainted every frame. The nine below never touch a canvas context at all: every face, vertex, card, and ring is a real DOM element, and the "3D" comes entirely from perspective and rotateX/rotateY/translateZ values a script computes and assigns to style.transform.

The pointer-tilt button set is the closer relative in rendering method -- both use CSS 3D transforms on real elements -- but the input source is the opposite. Those nine buttons read clientX/clientY on every mousemove and tilt toward wherever the pointer currently sits, so the shape is a function of where a mouse or finger is right now. These nine loaders never read pointer position at all; the only input is a 0-to-1 progress value running on a timer, so item 04's rings converge and item 09's inner ring widens its wobble on their own, with or without anyone's cursor anywhere near the tile.

What the cumulative-area and intensity numbers below actually count

The numbers below come from each demo's own screen-recording pass, not an estimate: cumulative area is how much of each tile changed across the whole loop, average intensity is the average per-pixel byte difference where it changed, and moved frames counts how many of the 23 frame-to-frame deltas actually moved a pixel.

# Pattern Cumulative area Avg. intensity Moved frames
01 Cube Face-threshold Swap 8.577% 92.8 23/23
02 Wireframe Vertex-lerp Snap 4.867% 87.1 23/23
03 Card-stack Depth Cycle 5.401% 95.2 23/23
04 Ring Orbit-tilt Converge 15.808% 102.8 23/23
05 Sphere Point-converge 6.127% 150.6 23/23
06 Prism Face-fill Rotate 16.692% 142.6 23/23
07 Flip-panel Depth Sort 12.239% 217.6 23/23
08 Terrain-grid Rise Perspective 7.95% 141.8 23/23
09 Gyroscope Ring Precession 7.29% 88.9 23/23

Every one of the nine moved on all 23 measured deltas, but the area column swings from 4.867% up to 16.692% -- loaders are small on-screen elements by design, so a small footprint is normal here, not a defect; what the table is really showing is that a css rotating cube animation like item 01 or a spring-flat panel deck like item 07 still redraws on nearly every frame even while covering under a fifth of its own tile.

Nine ways the same 0-to-1 value reaches a face, a vertex, or an orbit

Every block below is an exact, unedited slice of that pattern's own source file, pulled straight from the demo above it rather than typed out separately -- open one in the zip further down and the file on disk reads identically.

01Loader Whose Hexahedron Advances a Face Each Time Percent Crosses a Threshold

A depth-sort re-ranks all four faces by the cosine of their stored normal angle plus the current rotation every frame, so the z-index always matches which face is actually nearest the camera, and a percent value crossing FACE_THRESHOLD steps the cube to the next 90-degree face. It suits file-conversion progress and multi-step install indicators, where a fixed handful of stages already maps onto a handful of faces.

Loader Whose Hexahedron Advances a Face Each Time Percent Crosses a Threshold
Open in a new tab

01 · Loader Whose Hexahedron Advances a Face Each Time Percent Crosses a Threshold — Measured area 8.577% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FACE_THRESHOLDNORMALSbackface-visibility
var FACE_THRESHOLD = 25; // percent threshold that swaps one face -- variant 01 target
var t0 = 0, raf = 0, paused = false, lastP = 0;

// Painter's algorithm -- uses the cosine of (face normal angle + current rotation) as depth, so z-index gets assigned back-to-front.
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 + '%';
}

02Loader Whose Wireframe Vertices Settle Into Place With the Progress Value

Eight vertices start at a fixed scattered position and lerp toward a cube corner by Math.pow(p, LERP_EASE_POW), with the twelve connecting SVG lines redrawn between them every frame so the outline sharpens as the value climbs. It fits 3D-asset loading and map-data fetch indicators, where a shape still assembling itself is a more honest read than a bar that's already full.

Loader Whose Wireframe Vertices Settle Into Place With the Progress Value
Open in a new tab

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 · See the password

LERP_EASE_POWTARGETStransform-style
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) + '%';
}

03Loader Whose Back Card Recomputes Depth Order to Move to the Front With Each Step

rankOf() computes each card's front-to-back rank as (i - s) % CARD_COUNT, so the entire stack order rebuilds from one continuous number every frame instead of an array being physically reshuffled. It suits multi-step checkout progress and onboarding displays, where the card currently on top should always read as the active step.

Loader Whose Back Card Recomputes Depth Order to Move to the Front With Each Step
Open in a new tab

03 · Loader Whose Back Card Recomputes Depth Order to Move to the Front With Each Step — Measured area 5.401% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ZGAPSHRINKOPACITY_STEP
// Compute card i's current depth rank (0 = frontmost) as a continuous modular function of progress -- it doesn't accumulate state,
// the whole order is rebuilt from a single p every frame (same idea as the painter's algorithm).
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) + '%';
}

04Loader Whose Stacked Rings Narrow Their Tilt Gap as the Value Rises

Four rings start at four different tilt angles and each interpolates toward 0 degrees in proportion to progress, so what reads as four separate rings converges into one flat ring exactly at full progress. It fits sync-progress displays and multi-layer loading states, where several parallel processes should visibly merge into a single finished state.

Loader Whose Stacked Rings Narrow Their Tilt Gap as the Value Rises
Open in a new tab

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 · See the password

INITIAL_TILTSPIN_TURNSperspective
var INITIAL_TILT = [72, 54, 36, 18]; // each ring starts at its own tilt (deg) -- converges toward 0 as the value rises
var SPIN_TURNS = 0.6;                 // how many turns the ring stack spins over one loop

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); // interpolated toward the front-facing angle (0deg) in proportion to progress
    rings[i].style.transform = 'rotateY(' + spinY + 'deg) rotateX(' + tilt + 'deg)';
  }
  pctEl.textContent = Math.round(p * 100) + '%';
}

05Loader Whose Sphere-Surface Points Converge to a Single Point

A fixed Fibonacci sphere distribution -- the golden-angle constant, not Math.random -- sets each point's base position, and every point scales toward the origin by (1 - p) so the whole cloud collapses to one point at full progress. It fits data-sync completion badges and app-initialization screens, where a scattered field settling into one point reads as everything having landed.

Loader Whose Sphere-Surface Points Converge to a Single Point
Open in a new tab

05 · Loader Whose Sphere-Surface Points Converge to a Single Point — Measured area 6.127% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GOLDEN_ANGLESPHERE_Rtransform-style
function render(p) {
  lastP = p;
  var rotY = (p * 360 * ROT_TURNS) % 360;
  var shrink = 1 - p; // convergence ratio toward the center (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) + '%';
}

06Loader Whose Rotating Polygon Prism Fills Faces in the Order They Pass

fillAmount() gives each of six prism faces its own threshold (i / PRISM_SIDES) and a short fade-in span, so a css rotating cube animation search would miss this one entirely -- the fill boundary stays visible on the rotating prism instead of the whole shape changing color at once. It suits survey-progress steps and inventory-fill indicators, where how much is filled matters more than how much time is left.

Loader Whose Rotating Polygon Prism Fills Faces in the Order They Pass
Open in a new tab

06 · Loader Whose Rotating Polygon Prism Fills Faces in the Order They Pass — Measured area 16.692% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PRISM_SIDESFILL_SPANtransform-style
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) + '%';
}

07Loader That Recomputes Front-to-Back Order Every Time a Panel Flips

currentIndex() and depthRank() both derive from the same p, so the one panel currently mid-flip between 0 and 180 degrees is always the one nearest the camera, and every neighbor's z-offset follows its distance from that index. It fits tutorial-step transitions and card-style walkthrough progress, where each step should visibly flip past the last.

Loader That Recomputes Front-to-Back Order Every Time a Panel Flips
Open in a new tab

07 · Loader That Recomputes Front-to-Back Order Every Time a Panel Flips — Measured area 12.239% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RANK_ZGAPSTEPbackface-visibility
// Panel i's rotation angle -- linear 0-to-180deg only inside its own span; 0 before the span, locked at 180 after.
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;
}
// Panel i's depth rank -- distance to the panel currently flipping. Farther means pushed further back.
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) + '%';
}

08Loader Whose Grid Points Rise Into a Perspective Terrain by Value

Each grid point's height starts at zero and rises by rise = weight * p * HEIGHT_SCALE, with a fixed rotateX() tilt giving the flat grid an oblique camera angle so the rising points read as terrain rather than a bar chart. It fits map-data loading and dashboard initial-load backgrounds, where a loading state can double as a preview of what's coming.

Loader Whose Grid Points Rise Into a Perspective Terrain by Value
Open in a new tab

08 · Loader Whose Grid Points Rise Into a Perspective Terrain by Value — Measured area 7.95% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRID_COLSHEIGHT_SCALETILT
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) + '%';
}

09Loader Whose Inner Ring Widens Its Precession Angle With the Value

The inner ring's rotation axis traces a cone whose radius (PRECESSION_FACTOR * p) grows with progress, split across the X and Z axes by the cone phase's cosine and sine so the axis itself sweeps a circle instead of tilting on one plane. It suits long-wait loading screens and background-sync indicators, where a loader needs to keep feeling alive well past the first few seconds.

Loader Whose Inner Ring Widens Its Precession Angle With the Value
Open in a new tab

09 · Loader Whose Inner Ring Widens Its Precession Angle With the Value — Measured area 7.29% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PRECESSION_FACTORPRECESSION_TURNSOUTER_TILT
// The cone the inner ring's rotation axis traces -- the precession angle (grows with progress) is split into X- and Z-axis
// components via the cone phase's cos/sin, so the axis itself sweeps a circle, faking precession.
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) + '%';
}

Where FACE_THRESHOLD turns into a trap that never finishes on time

The trap in item 01 is the clamp inside Math.min(faces.length - 1, Math.floor(pct / FACE_THRESHOLD)). With four faces, the last index (3) is reached as soon as pct crosses 3 * FACE_THRESHOLD -- at the default 25 that's pct 75, so the final face sits still for the last 25 points of the loop. Push FACE_THRESHOLD down to look "more responsive," and the clamp bites earlier: at 18 the last face locks in at pct 54, so it now sits still for 46 points instead of 25 -- a lower threshold swaps the first three faces faster but makes the loader look frozen for nearly half the loop, the opposite of the intent. The fix isn't a smaller FACE_THRESHOLD, it's matching faces.length so every face gets an even share: FACE_THRESHOLD = Math.ceil(100 / faces.length).

Three values worth bending first, and what reduced-motion removes

Variant Changed value What it should feel like
Adjust the cube face-swap threshold value 01 FACE_THRESHOLD 25 → 18 Lowering the threshold value should make the shape swap to the next face at a smaller progress value, so face swaps happen more often.
Adjust the wireframe vertex-lerp speed value 02 LERP_EASE_POW 1.8 → 1.2 Lowering the vertex-lerp exponent value should make vertices reach their target coordinates sooner, sharpening the outline earlier in the progress range.
Adjust the gyroscope precession-angle factor value 09 PRECESSION_FACTOR 26 → 34 Raising the factor value should widen the inner ring's precession angle further at the same progress value, making the wobble more pronounced.

All nine read prefers-reduced-motion once, when the page first loads, not on every frame -- when that media query reports reduce, the loop never starts, and a single still frame draws instead at a fixed progress value. Every tile also carries role="application", tabindex="0", and an aria-label, and the same handler answers a click, an Enter key, a Space key (both toggle the loop), and an R key (restart), plus a resize listener that redraws the tile at its new size -- so a keyboard-only visitor can replay, pause, or restart any of the nine without a mouse. The nine source files, one per pattern, sit behind gfc9k6p7 in the same folder as this article's demo -- open one before pushing FACE_THRESHOLD toward the trap above, since a face that never rotates in reads fine in the measurement table but can leave a real loading state looking stuck.

Three spots where one of these nine loaders actually earns its keep

Item 01's face-swap cube is the one to reach for on a file-conversion progress screen or a multi-step install wizard -- a fixed handful of stages already maps onto a handful of faces, and the depth-sort keeps the visible face honest without a single extra DOM node per stage.

Item 03's card-stack cycle and item 07's flip-panel deck both suit checkout and onboarding flows, where the surface people are staring at is already card-shaped; the difference is mostly how final each step should feel -- item 03 keeps every card in a fanned stack the whole time, while item 07 fully commits one panel to the front and locks its neighbors at 0 or 180 degrees.

Item 09's gyroscope precession is worth reaching for on a background-sync indicator or a long-wait screen specifically because it never looks finished -- the wobble keeps widening for as long as the loop runs, which reads as "still working" far better than a bar that quietly stalls at 94%.

Where can you find more 3D loader CSS examples like these?

A css spinner codepen turns up fast if you search for one, and it's a fine place to start, but most of what comes back is a single element on a CSS @keyframes loop -- there's no depth sort, no lerp curve, and no precession cone underneath it, because a keyframe animation only interpolates one property between two fixed values. Item 06's rotating prism and item 09's gyroscope only work the way they do because a plain JavaScript number recomputes the whole transform every frame, not because a property eased from one value to another. If you're after a working 3d loader css pattern you can drop straight into a project, each of the nine above is exactly that -- open the source under its pattern, copy the file, and swap in whatever progress source your own upload or request actually reports.

Questions worth answering before you wire up a 3D loader

Can item 07 double as a 3d card flip animation css pattern for a single card, not a full panel deck?

Yes -- set PANEL_COUNT to 1 and flipAngle() still linearly interpolates 0 to 180 degrees across the whole progress range, so you get a single flipping card with no depth-rank math running against neighbors that no longer exist.

Why does item 01's cube look different from item 06's prism even though both fill by threshold?

Item 01 swaps its whole visible face in one instant once a threshold is crossed, while item 06 fades each prism face in gradually over FILL_SPAN once its own threshold passes -- one is a hard cut, the other a short cross-fade, and both are threshold comparisons on the same progress number.

Do any of these nine need a library, WebGL, or a CDN script to run?

No -- all nine run on perspective, transform-style: preserve-3d, and plain DOM elements moved by style.transform in a requestAnimationFrame loop; there's no Three.js, no canvas WebGL context, and no external script tag in any of the nine files behind this article.

Sources: MDN — perspective · MDN — transform-style · MDN — rotate3d() · MDN — prefers-reduced-motion · MDN — Using the progressbar role

Related reading: 9 CSS Loading Animation Patterns, Canvas Physics · 9 CSS 3D Cube Effects — One Line, Copy-Paste · 3D Hover Effect CSS: 9 Pointer-Following Scenes -- the first runs the same nine-pattern format but on a 2D canvas instead of DOM transforms, the second is the closer CSS-3D relative with its own cube carousel built from a different set of rotation values, and the third drives its nine scenes from pointer position instead of a timed progress number the way item 04 and item 09 above drive theirs from time alone.

Enter the archive password

The password is inside this article. You will find it as you read.