Introduction
In this article we build a spinning 3D cube loader — a tactile, hardware-inspired loading indicator built entirely from HTML and CSS. The cube has four side faces constructed from absolutely positioned spans rotated around a shared Y axis using translateZ, a flat top cap that closes the cube geometry, a rotating conic-gradient animation that drives the continuous spin, and a blurred pseudo-element glow shadow beneath the cube that pulses with the rotation. No JavaScript, no SVG, no canvas — just a wrapper div, a faces container, four span elements, and carefully layered CSS transforms.
The entire 3D effect is powered by two CSS properties working in tandem: transform-style: preserve-3d, which tells the browser to place child elements in a shared 3D space rather than flattening them, and translateZ, which pushes each face outward along the Z axis by half the cube width so the four panels meet at their edges. The spin animation rotates the parent container, and all four faces rotate with it as a rigid unit.
How transform-style: preserve-3d creates a shared 3D coordinate space for child elements — how to position four faces of a cube using rotateY and translateZ — how to build a vertical gradient on each face that simulates light hitting a curved surface — how to close the cube geometry with a top cap using rotateX — how to create a glowing drop shadow using a blurred pseudo-element — and how to respect the prefers-reduced-motion media query so users who are sensitive to animation get a static fallback.
Step 1 — CSS Custom Properties
All size-sensitive values are expressed as custom properties so changing --cube-size automatically updates every derived measurement — the half-width used in translateZ, the top cap dimensions, and the glow shadow footprint. Keeping a single source of truth prevents the three places that reference half the cube width from drifting out of sync when you resize.
/* all size math derives from these two tokens */
:root {
--cube-size: 80px; /* overall cube width and height */
--cube-half: calc(var(--cube-size) / 2); /* translateZ distance for each face */
--face-top: hsl(205, 20%, 22%); /* dark slate — unlit top cap */
--face-dark: hsl(205, 18%, 22%); /* darkest stop on the face gradient */
--face-light: hsl(195, 97%, 65%); /* brightest stop — light blue peak */
--shadow-color: hsl(200, 60%, 48%); /* glow colour beneath the cube */
--shadow-dark: hsl(210, 15%, 20%); /* dark ring colour in the glow */
--spin-duration: 4s; /* one full revolution */
--spin-ease: linear; /* constant speed — no easing */
}Each face must be pushed outward along Z by exactly half the cube width so that adjacent faces share an edge. If you hardcode 40px and later change --cube-size to 100px, the faces will gap or overlap. Deriving --cube-half with calc() keeps the geometry self-consistent at any size — change one token and the whole cube updates correctly.
Step 2 — The Markup
The component needs two layers of nesting. The outer .cube element is the animation host — it carries the spin keyframe and sets up the 3D perspective tilt. The inner .cube__faces container holds all four side spans and is itself a preserve-3d context so the spans share the same 3D coordinate space as their parent. The .cube__top element is a sibling to .cube__faces, positioned independently so it can be rotated on its own X axis without affecting the face container.
<div class="cube" role="status" aria-label="Loading">
<!-- top cap — rotated 90deg on X axis to close the top of the cube -->
<div class="cube__top"></div>
<!-- face container — preserve-3d groups all four spans in one 3D space -->
<div class="cube__faces">
<!-- each span is one side face — --face-index drives the rotateY offset -->
<span class="cube__face" style="--face-index: 0"></span>
<span class="cube__face" style="--face-index: 1"></span>
<span class="cube__face" style="--face-index: 2"></span>
<span class="cube__face" style="--face-index: 3"></span>
</div>
</div>| Element | Role | Animated |
|---|---|---|
| .cube | Animation host — carries the spin keyframe, sets rotateX(-30deg) tilt, perspective-3d root | Yes — continuous rotateY spin via @keyframes |
| .cube__top | Top face cap — rotateX(90deg) + translateZ closes the top of the cube geometry | No — rides passively with the parent spin |
| .cube__top::after | Glow shadow — a blurred pseudo-element beneath the cube that simulates a light pool | No — static; opacity tied to parent |
| .cube__faces | Face container — preserve-3d groups all four spans in one 3D coordinate space | No — passive container |
| .cube__face | One side panel — rotateY(90deg × --face-index) + translateZ(--cube-half) positions it | No — positioned once, spins with .cube |
Step 3 — Setting Up the 3D Scene
The .cube element is the root of the 3D scene. Three properties make the 3D work. transform-style: preserve-3d tells the browser that child elements live in 3D space alongside their parent rather than being flattened onto the parent surface. The initial transform: rotateX(-30deg) tilts the whole cube toward the viewer so you can see the top face — without this tilt the cube would appear as a flat square. The spin animation then rotates the Y axis continuously, which is the rotation that makes the cube appear to spin in place.
.cube {
position: relative;
width: var(--cube-size);
height: var(--cube-size);
/* the two properties that make 3D work */
transform-style: preserve-3d;
/* tilt toward viewer so the top face is visible, then spin the Y axis */
transform: rotateX(-30deg) rotateY(0deg);
animation: spinCube var(--spin-duration) var(--spin-ease) infinite;
}
@keyframes spinCube {
from { transform: rotateX(-30deg) rotateY(0deg); }
to { transform: rotateX(-30deg) rotateY(360deg); }
}
/* face container inherits the 3D space */
.cube__faces {
position: absolute;
inset: 0;
transform-style: preserve-3d;
}transform-style: preserve-3d must be declared on every ancestor in the chain between the animation root and the 3D children — not just the top-level element. If any intermediate container is missing it, the browser flattens the subtree at that point and the 3D positioning collapses.
Step 4 — Positioning the Four Side Faces
Each face span is an absolutely positioned element that fills the parent container. A two-step transform places each face in its correct position on the cube. First, rotateY(calc(90deg × --face-index)) rotates the face around the Y axis by 0, 90, 180, or 270 degrees — one quarter turn per face. Second, translateZ(--cube-half) pushes the now-rotated face outward along its own local Z axis by half the cube width. Because the face has already been rotated, its local Z axis now points outward from its assigned side of the cube, so the translateZ correctly moves it to the cube wall position.
.cube__face {
position: absolute;
width: 100%;
height: 100%;
/* step 1: rotate to the correct side (0°, 90°, 180°, 270°) */
/* step 2: push outward along the face's own local Z axis */
transform:
rotateY(calc(90deg * var(--face-index)))
translateZ(var(--cube-half));
/* vertical gradient simulates light hitting the curved surface from above */
background: linear-gradient(
to bottom,
hsl(205, 18%, 22%) 0%, /* --face-dark: deep shadow at top */
hsl(202, 28%, 35%) 8%,
hsl(200, 42%, 44%) 18%,
hsl(199, 55%, 52%) 30%,
hsl(198, 65%, 57%) 43%,
hsl(197, 74%, 60%) 55%,
hsl(196, 82%, 62%) 67%,
hsl(196, 88%, 63%) 78%,
hsl(195, 93%, 64%) 88%,
hsl(195, 97%, 65%) 100% /* --face-light: bright blue at bottom */
);
}The cube is tilted with rotateX(-30deg) so the top edge is further from the viewer and the bottom edge is closer. Physically, a surface closer to a viewer catches more ambient light. The gradient reinforces this by placing the lightest colour at the bottom — the near edge — and the darkest at the top — the far edge. This makes the cube read as a solid object lit from in front rather than a flat coloured square.
Step 5 — Closing the Cube with the Top Cap
The top face is a separate div rather than a fifth span in the faces container. It uses rotateX(90deg) to rotate it flat — perpendicular to the four vertical faces — and then translateZ(--cube-half) to lift it up to the correct height so it sits flush with the top edges of the four side faces. Its background colour is the same dark slate as the dark end of the face gradient, giving the impression that the top is in deep shadow because it faces away from the viewer.
.cube__top {
position: absolute;
width: var(--cube-size);
height: var(--cube-size);
background: var(--face-top); /* dark slate — in shadow */
/* rotate flat on X axis, then lift to the top edge height */
transform: rotateX(90deg) translateZ(var(--cube-half));
transform-style: preserve-3d; /* needed for ::after to 3D-position */
}Step 6 — The Blurred Glow Shadow
The glow beneath the cube is a ::after pseudo-element on the top cap, pushed downward by a negative translateZ so it appears on the ground plane below the cube. It uses a solid background colour matching the light-blue face gradient midpoint, combined with a large filter: blur and a layered box-shadow to create a soft light pool effect. Placing the glow on the top cap pseudo-element means it automatically rotates with the cube, keeping the light pool centred under the cube at all times.
.cube__top::after {
content: "";
position: absolute;
inset: 0;
background: var(--shadow-color); /* light blue glow centre */
/* push the pseudo-element down past the bottom of the cube */
/* negative translateZ moves it below the cube on the ground plane */
transform: translateZ(calc(var(--cube-size) * -1.125));
/* blur creates the soft light pool spread */
filter: blur(12px);
/* layered box-shadow alternates dark ring and blue glow for depth */
box-shadow:
0 0 12px var(--shadow-dark),
0 0 24px var(--shadow-color),
0 0 36px var(--shadow-dark),
0 0 48px var(--shadow-color);
opacity: 0.7;
}Placing the glow shadow on a pseudo-element of the top cap rather than using a separate div means it inherits the cube's 3D transform context automatically. As the cube spins, the glow stays perfectly centred beneath it without any additional positioning logic.
Step 7 — The Continuous Spin Keyframe
The spin is a single @keyframes rule that increments rotateY from 0deg to 360deg while holding rotateX constant at -30deg throughout. Both transforms must be declared together on every keyframe — if you omit rotateX from the to frame, the browser interpolates it back to 0deg during the animation and the cube will straighten up as it spins. The animation runs with linear easing and infinite iteration so the spin is perfectly uniform with no acceleration or deceleration between cycles.
/* both rotateX and rotateY must be present on every keyframe */
/* omitting rotateX from any frame causes it to interpolate away */
@keyframes spinCube {
from {
transform: rotateX(-30deg) rotateY(0deg);
}
to {
transform: rotateX(-30deg) rotateY(360deg);
}
}
/* applied on .cube — the single animation drives the whole scene */
.cube {
animation: spinCube var(--spin-duration) var(--spin-ease) infinite;
}Step 8 — Respecting prefers-reduced-motion
Some users configure their operating system to reduce or eliminate motion because continuous animation causes discomfort or vestibular disturbance. The @media (prefers-reduced-motion: reduce) block pauses the animation and sets a fixed static rotation so the cube is still visible as a recognisable 3D object — it just does not spin. The aria-label on the outer element already communicates the loading state to screen readers regardless of whether the animation is running.
@media (prefers-reduced-motion: reduce) {
/* pause the spin — cube is static but still visible as a 3D form */
.cube {
animation-play-state: paused;
transform: rotateX(-30deg) rotateY(45deg); /* diagonal static view */
}
}Using animation-play-state: paused rather than removing the animation property entirely means the cube snaps to the from keyframe position rather than reverting to its default un-animated transform. Overriding the transform separately then gives you control over exactly which angle the static cube rests at — in this case a 45-degree diagonal that makes the 3D geometry clearly readable even without motion.
Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --cube-size | 80px | Overall cube dimensions. All derived values update automatically via calc() |
| --cube-half | calc(--cube-size / 2) | translateZ offset for each face. Must equal exactly half of --cube-size for correct geometry |
| --spin-duration | 4s | Time for one full 360deg revolution. Lower for a faster spin, higher for a slow dramatic turn |
| --spin-ease | linear | Easing on the spin keyframe. linear keeps it uniform — ease-in-out gives a pulsing feel |
| rotateX tilt on .cube | -30deg | Viewing angle. -30deg shows a comfortable slice of the top. -45deg gives an isometric view |
| Gradient dark stop | hsl(205, 18%, 22%) | Colour at the top edge of each face. Darker reads as deeper shadow |
| Gradient light stop | hsl(195, 97%, 65%) | Colour at the bottom edge — the highlighted near edge. Shift hue to change the overall tone |
| --face-top background | hsl(205, 20%, 22%) | Top cap colour. Match to the dark gradient stop so it reads as a shadowed surface |
| --shadow-color | hsl(200, 60%, 48%) | Glow pool colour. Match to a midpoint of the face gradient for a coherent colour story |
| Glow translateZ multiplier | -1.125 | How far below the cube the glow sits. Increase magnitude to push it further down |
| Glow filter blur | 12px | Softness of the light pool. Larger radius reads as a more diffuse ambient light source |
| Glow opacity | 0.7 | Overall intensity of the glow. Lower for subtlety, raise to 1 for a strong neon effect |
Full Source Code
Save the following as 3d-cube-loader.html and open in any modern browser. transform-style: preserve-3d requires Chrome 12+, Safari 4+, or Firefox 10+. filter: blur on the glow pseudo-element requires Chrome 18+, Safari 6+, or Firefox 35+. On browsers that do not support 3D transforms the element degrades to a flat rectangle — it remains visible and the aria-label communicates the loading state to assistive technology regardless.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/3d-cube-loader.htmlThe entire 3D cube loader — four side faces, a closed top cap, a spinning glow shadow, and a continuous rotation animation — is driven by two CSS transforms, one @keyframes rule, and a handful of custom properties. Not a single line of JavaScript or a single external asset.