Aduok Code

Realistic 3D Cube Loader with Pure HTML & CSS

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.

What you will learn

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.

01
Part One
Design Tokens

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.

CSS
/* 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 */
}
Why calc() for --cube-half?

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.

02
Part Two
HTML Structure

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.

HTML
<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>
ElementRoleAnimated
.cubeAnimation host — carries the spin keyframe, sets rotateX(-30deg) tilt, perspective-3d rootYes — continuous rotateY spin via @keyframes
.cube__topTop face cap — rotateX(90deg) + translateZ closes the top of the cube geometryNo — rides passively with the parent spin
.cube__top::afterGlow shadow — a blurred pseudo-element beneath the cube that simulates a light poolNo — static; opacity tied to parent
.cube__facesFace container — preserve-3d groups all four spans in one 3D coordinate spaceNo — passive container
.cube__faceOne side panel — rotateY(90deg × --face-index) + translateZ(--cube-half) positions itNo — positioned once, spins with .cube
03
Part Three
The 3D Scene

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.

CSS
.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.

04
Part Four
The Four Side Faces

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.

CSS
.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 */
  );
}
Why the gradient runs dark-at-top to light-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.

05
Part Five
The Top Cap

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.

CSS
.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 */
}
06
Part Six
The Glow Shadow

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.

CSS
.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.

07
Part Seven
The Spin Animation

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.

CSS
/* 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;
}
08
Part Eight
Reduced Motion Handling

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.

CSS
@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 */
  }
}
animation-play-state: paused vs removing the animation

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 / PropertyDefaultEffect
--cube-size80pxOverall cube dimensions. All derived values update automatically via calc()
--cube-halfcalc(--cube-size / 2)translateZ offset for each face. Must equal exactly half of --cube-size for correct geometry
--spin-duration4sTime for one full 360deg revolution. Lower for a faster spin, higher for a slow dramatic turn
--spin-easelinearEasing on the spin keyframe. linear keeps it uniform — ease-in-out gives a pulsing feel
rotateX tilt on .cube-30degViewing angle. -30deg shows a comfortable slice of the top. -45deg gives an isometric view
Gradient dark stophsl(205, 18%, 22%)Colour at the top edge of each face. Darker reads as deeper shadow
Gradient light stophsl(195, 97%, 65%)Colour at the bottom edge — the highlighted near edge. Shift hue to change the overall tone
--face-top backgroundhsl(205, 20%, 22%)Top cap colour. Match to the dark gradient stop so it reads as a shadowed surface
--shadow-colorhsl(200, 60%, 48%)Glow pool colour. Match to a midpoint of the face gradient for a coherent colour story
Glow translateZ multiplier-1.125How far below the cube the glow sits. Increase magnitude to push it further down
Glow filter blur12pxSoftness of the light pool. Larger radius reads as a more diffuse ambient light source
Glow opacity0.7Overall 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.

HTML — 3d-cube-loader.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/3d-cube-loader.html

The 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.

Want to see it in action?Watch the full build on YouTube