Blue and violet 3D cube folding as a CSS loader animation
Blog

CSS Cube Flipping Loader: Responsive Animation Tutorial

Build a smooth cube flipping loader with modern CSS—no Sass, images, or JavaScript required. Try the interactive demo, change its color and speed, pause the motion, or download the complete HTML, CSS, and JavaScript source.

Quick answer: Make four square panels with CSS pseudo-elements or child elements, then animate each panel with a short staggered 3D transform. The example below uses CSS keyframes and a small JavaScript layer only for the demo controls.

Try the cube flipping loader

Use the controls to select a color, adjust the animation speed, or pause the loader. The animation respects the visitor’s reduced-motion preference.

How a cube flipping loader works

The loader is made from four square faces. Each face rotates in 3D around its lower-right corner, while small delays make the panels fold one after another. The parent cube rotates slightly to make the movement read as a continuous loop. Everything is drawn by the browser, so there are no image files or external animation libraries to maintain.

Build it step by step

  1. Add a wrapper with four empty spans. Each span becomes one colored panel.
  2. Give the panels a shared size and color with CSS custom properties.
  3. Use transform-origin, perspective(), and keyframes to fold each panel in 3D.
  4. Apply a short stagger with animation-delay so the faces do not move together.
  5. Keep the loader inside its own component instead of positioning it over the entire page.

The code samples below are plain HTML, CSS, and JavaScript. Unlike the old Sass example, this version runs directly in a browser with no build step and uses unprefixed properties supported by current browsers.

Make the loader accessible and responsive

Expose a short accessible status such as role="status" and an aria-label so assistive technology can identify what the animation means. Do not announce every frame. Provide a pause control for a persistent animation, and honor prefers-reduced-motion so people who request less motion see a slower transition. If you use this as a page-loading overlay, remove the overlay once the page is ready and avoid trapping keyboard focus.

The demo adapts to smaller screens and the animation uses CSS only. Its optional controls use a few JavaScript event listeners to change the CSS variables and pause the animation; remove the controls and script if you only need a decorative loader. For another compact indicator, see our percentage loading circle tutorial.

Common questions

Does this cube loader require JavaScript?

No. The animation itself uses CSS keyframes. JavaScript is only used by the demo controls for color, speed, and pause/resume.

Can I change the cube color and size?

Yes. Set the --swc-color custom property to change its color. Change the cube width and height together to scale the four panels proportionally.

How do I use this as a full-page loader?

Place the loader inside a fixed, full-viewport overlay and keep the overlay hidden from keyboard and screen-reader interaction after loading finishes. Remove it when your application reports that its essential content is ready; do not rely on an arbitrary timeout.

Why does the animation appear flat?

Check that the parent retains perspective and that each face has the correct transform origin. Also make sure another theme rule is not overriding the loader’s transform or animation declarations.

Download the complete source

The ZIP includes a standalone HTML page, a separate stylesheet, and the small script for the demo controls. Extract the files into one folder and open index.html in a current browser.

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="color-scheme" content="light">
  <title>CSS Cube Flipping Loader Demo</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <main class="demo">
    <div class="stage" id="loader-stage">
      <div class="cube-loader" role="status" aria-label="Loading">
        <span class="cube-loader__face"></span>
        <span class="cube-loader__face"></span>
        <span class="cube-loader__face"></span>
        <span class="cube-loader__face"></span>
      </div>
      <p class="loading-label" aria-hidden="true">Loading</p>
    </div>
    <div class="controls" aria-label="Loader controls">
      <label for="color">Cube color</label>
      <input id="color" type="color" value="#5b6cff" aria-label="Choose cube color">
      <label class="speed-label" for="speed">Speed <output id="speed-value">1.2s</output></label>
      <input id="speed" type="range" min="0.6" max="2.4" step="0.1" value="1.2" aria-label="Animation speed">
      <button id="toggle" type="button" aria-pressed="false">Pause animation</button>
    </div>
  </main>
  <script src="script.js"></script>
</body>
</html>
styles.css
.cube-loader {
  --cube-size: 64px;
  --cube-color: #5b6cff;
  --cube-speed: 1.2s;
  position: relative;
  width: var(--cube-size);
  height: var(--cube-size);
  transform: rotate(45deg);
  animation: cube-rock var(--cube-speed) ease-in-out infinite;
}

.cube-loader__face {
  position: absolute;
  width: 50%;
  height: 50%;
  background: var(--cube-color);
  border: 1px solid rgb(255 255 255 / 65%);
  transform-origin: 100% 100%;
  animation: cube-fold var(--cube-speed) ease-in-out infinite;
}

.cube-loader__face:nth-child(1) { inset: 0 auto auto 0; }
.cube-loader__face:nth-child(2) { inset: 0 0 auto auto; animation-delay: calc(var(--cube-speed) * .12); }
.cube-loader__face:nth-child(3) { inset: auto auto 0 0; animation-delay: calc(var(--cube-speed) * .24); }
.cube-loader__face:nth-child(4) { inset: auto 0 0 auto; animation-delay: calc(var(--cube-speed) * .36); }

@keyframes cube-rock {
  50% { transform: rotate(135deg) scale(.92); }
}

@keyframes cube-fold {
  0%, 15% { transform: perspective(100px) rotateX(-180deg); opacity: 0; }
  35%, 70% { transform: perspective(100px) rotateX(0); opacity: 1; }
  90%, 100% { transform: perspective(100px) rotateY(180deg); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .cube-loader, .cube-loader__face { animation-duration: 3.6s; }
}
script.js
const stage = document.querySelector('#loader-stage');
const loader = document.querySelector('.cube-loader');
const colorInput = document.querySelector('#color');
const speedInput = document.querySelector('#speed');
const speedValue = document.querySelector('#speed-value');
const toggleButton = document.querySelector('#toggle');

colorInput.addEventListener('input', (event) => {
  loader.style.setProperty('--cube-color', event.currentTarget.value);
});

speedInput.addEventListener('input', (event) => {
  const seconds = Number(event.currentTarget.value);
  loader.style.setProperty('--cube-speed', `${seconds}s`);
  speedValue.value = `${seconds.toFixed(1)}s`;
});

toggleButton.addEventListener('click', () => {
  const paused = stage.classList.toggle('is-paused');
  toggleButton.setAttribute('aria-pressed', String(paused));
  toggleButton.textContent = paused ? 'Resume animation' : 'Pause animation';
});