Pure CSS Loaders & patterns #loop #loader #sass-loop #music #chart

3D equalizer in pure CSS

Seven real cuboids bouncing like an audio meter. Walls are squashed with scaleY and each lid rides down by the same amount, so nothing but transforms ever changes.

  • 110 lines of CSS
  • 11 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. Each bar is a real cuboid: a front wall, a right wall and a lid, all sized once and never resized again — only transform ever changes.
  2. The walls are squashed with scaleY() from a transform-origin: bottom center, so growing or shrinking a bar never touches layout — it is one compositor-only transform.
  3. The lid rides translateY down by exactly the height the walls lost (var(--h) * (1 - level)), so it always stays glued to the top of the shrinking walls instead of floating.
  4. All three faces' keyframes share the exact same percentage stops and easing — that shared timing is what keeps the lid and both walls moving as one solid box instead of drifting apart.
  5. Each bar gets its own hue, delay and duration so neighbours never bounce in sync, which is what sells it as a real audio meter instead of one shape copy-pasted seven times.

Key techniques

  • scaleY walls + translateY lid (no layout)
  • shared keyframe stops keep lid and walls glued
  • Sass @each builds the keyframes from one map
  • per-bar delay and duration

Copy-paste code

HTML 11 lines

<div class="scene">
  <div class="equalizer" role="img" aria-label="Equalizer animation">
    <div class="bar" style="--hn:70"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:96"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:118"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:88"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:108"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:80"><i></i><i></i><i></i></div>
    <div class="bar" style="--hn:60"><i></i><i></i><i></i></div>
  </div>
</div>

CSS 110 lines

.scene {
  perspective: 800px;
}

.equalizer {
  position: relative;
  display: flex;
  align-items: flex-end;
  gap: 8px;
  height: 120px;
  transform-style: preserve-3d;
  transform: translateY(6px) rotateX(-20deg) rotateY(-30deg);
}

/* glass floor: a strip centred on the bottom edge, laid flat */
.equalizer::before {
  content: '';
  position: absolute;
  right: -12px;
  bottom: -22px;
  left: -12px;
  height: 44px;
  border-radius: 8px;
  transform: rotateX(90deg);
  background: color-mix(in srgb, #8b6cff 12%, transparent);
  border: 1px solid color-mix(in srgb, #8b6cff 75%, transparent);
  box-shadow: inset 0 0 24px color-mix(in srgb, #8b6cff 30%, transparent);
}

/* --hn is the bar's full height as a plain number (set in the markup). The box never changes
   size: the walls are squashed with scaleY from the bottom, and the lid rides down with
   translateY by exactly the amount the walls lost. Transforms only, zero layout. */
.bar {
  --h: calc(var(--hn) * 1px);
  position: relative;
  width: 18px;
  height: var(--h);
  transform-style: preserve-3d;
}

/* violet → teal across the row, with its own phase and tempo so neighbours never move together */
.bar:nth-child(1) { --hue: 265; --delay: -0.4s; --dur: 2.7s; }
.bar:nth-child(2) { --hue: 250; --delay: -1.6s; --dur: 2.2s; }
.bar:nth-child(3) { --hue: 235; --delay: -0.9s; --dur: 3.1s; }
.bar:nth-child(4) { --hue: 220; --delay: -2.3s; --dur: 2.35s; }
.bar:nth-child(5) { --hue: 205; --delay: -0.2s; --dur: 2.9s; }
.bar:nth-child(6) { --hue: 190; --delay: -1.3s; --dur: 2.25s; }
.bar:nth-child(7) { --hue: 175; --delay: -2s; --dur: 2.6s; }

.bar i {
  position: absolute;
  top: 0;
  left: 0;
  width: 18px;
  height: 100%;
  transform-origin: bottom center;
  animation: var(--dur) ease-in-out var(--delay) infinite;
}

/* front wall */
.bar i:nth-child(1) {
  background: linear-gradient(to top, hsl(var(--hue) 85% 45% / 0.92), hsl(var(--hue) 90% 68% / 0.92));
  transform: translateZ(9px);
  animation-name: equalizer-front;
}

/* right wall, darker */
.bar i:nth-child(2) {
  background: linear-gradient(to top, hsl(var(--hue) 75% 28% / 0.92), hsl(var(--hue) 80% 46% / 0.92));
  transform: rotateY(90deg) translateZ(9px);
  animation-name: equalizer-side;
}

/* lid: an 18 × 18 square laid flat on top, lightest */
.bar i:nth-child(3) {
  height: 18px;
  background: hsl(var(--hue) 95% 80%);
  transform-origin: center;
  transform: rotateX(90deg) translateZ(9px);
  animation-name: equalizer-lid;
}

/* the three animations share the same stops and easing, so walls and lid stay glued together */
@keyframes equalizer-front {
  0%   { transform: translateZ(9px) scaleY(0.18); }
  22%  { transform: translateZ(9px) scaleY(1); }
  42%  { transform: translateZ(9px) scaleY(0.5); }
  64%  { transform: translateZ(9px) scaleY(0.86); }
  82%  { transform: translateZ(9px) scaleY(0.34); }
  100% { transform: translateZ(9px) scaleY(0.18); }
}

@keyframes equalizer-side {
  0%   { transform: rotateY(90deg) translateZ(9px) scaleY(0.18); }
  22%  { transform: rotateY(90deg) translateZ(9px) scaleY(1); }
  42%  { transform: rotateY(90deg) translateZ(9px) scaleY(0.5); }
  64%  { transform: rotateY(90deg) translateZ(9px) scaleY(0.86); }
  82%  { transform: rotateY(90deg) translateZ(9px) scaleY(0.34); }
  100% { transform: rotateY(90deg) translateZ(9px) scaleY(0.18); }
}

/* walls lost (1 - level) of the height at each stop: the lid drops by the same distance */
@keyframes equalizer-lid {
  0%   { transform: translateY(calc(var(--h) * 0.82)) rotateX(90deg) translateZ(9px); }
  22%  { transform: translateY(calc(var(--h) * 0))    rotateX(90deg) translateZ(9px); }
  42%  { transform: translateY(calc(var(--h) * 0.5))  rotateX(90deg) translateZ(9px); }
  64%  { transform: translateY(calc(var(--h) * 0.14)) rotateX(90deg) translateZ(9px); }
  82%  { transform: translateY(calc(var(--h) * 0.66)) rotateX(90deg) translateZ(9px); }
  100% { transform: translateY(calc(var(--h) * 0.82)) rotateX(90deg) translateZ(9px); }
}

Sass source 122 lines

@use 'sass:list';
@use 'sass:math';
@use '../mixins' as *;

$w: 18px; // bar width and depth
$half: $w * 0.5;
$count: 7;
// pseudo-random phase and tempo per bar, so neighbours never move together
// calm, not jumpy: each bar takes 2.2 to 3.1 s for one bounce
$delays: (-0.4s, -1.6s, -0.9s, -2.3s, -0.2s, -1.3s, -2s);
$durs: (2.7s, 2.2s, 3.1s, 2.35s, 2.9s, 2.25s, 2.6s);
// the "level" (fraction of the bar's full height) at each keyframe; first == last → seamless
$levels: (
  0%: 0.18,
  22%: 1,
  42%: 0.5,
  64%: 0.86,
  82%: 0.34,
  100%: 0.18,
);

.d-equalizer {
  position: relative;
  display: flex;
  align-items: flex-end;
  gap: 8px;
  height: 120px;
  transform-style: preserve-3d;
  transform: translateY(6px) rotateX(-20deg) rotateY(-30deg);

  // glass floor: a strip centred on the bottom edge, laid flat
  &::before {
    content: '';
    position: absolute;
    right: -12px;
    bottom: -22px;
    left: -12px;
    height: 44px;
    border-radius: 8px;
    transform: rotateX(90deg);
    @include face(var(--accent), 12%);
  }

  // --hn is the bar's full height as a plain number (set in the markup). The box never changes
  // size: the walls are squashed with scaleY from the bottom, and the lid rides down with
  // translateY by exactly the amount the walls lost. Transforms only, zero layout.
  &__bar {
    --h: calc(var(--hn) * 1px);
    position: relative;
    width: $w;
    height: var(--h);
    transform-style: preserve-3d;

    @for $i from 1 through $count {
      &:nth-child(#{$i}) {
        // violet → teal across the row
        --hue: #{math.round(265 - ($i - 1) * 15)};
        --delay: #{list.nth($delays, $i)};
        --dur: #{list.nth($durs, $i)};
      }
    }

    i {
      position: absolute;
      top: 0;
      left: 0;
      width: $w;
      height: 100%;
      transform-origin: bottom center;
      animation: var(--dur) ease-in-out var(--delay) infinite;
    }

    // front wall
    i:nth-child(1) {
      background: linear-gradient(to top, hsl(var(--hue) 85% 45% / 0.92), hsl(var(--hue) 90% 68% / 0.92));
      transform: translateZ($half);
      animation-name: d-equalizer-front;
    }

    // right wall, darker
    i:nth-child(2) {
      background: linear-gradient(to top, hsl(var(--hue) 75% 28% / 0.92), hsl(var(--hue) 80% 46% / 0.92));
      transform: rotateY(90deg) translateZ($half);
      animation-name: d-equalizer-side;
    }

    // lid: a $w × $w square laid flat on top, lightest
    i:nth-child(3) {
      height: $w;
      background: hsl(var(--hue) 95% 80%);
      transform-origin: center;
      transform: rotateX(90deg) translateZ($half);
      animation-name: d-equalizer-lid;
    }
  }
}

// The three animations share the same stops and easing, so walls and lid stay glued together.
@keyframes d-equalizer-front {
  @each $stop, $level in $levels {
    #{$stop} {
      transform: translateZ($half) scaleY($level);
    }
  }
}

@keyframes d-equalizer-side {
  @each $stop, $level in $levels {
    #{$stop} {
      transform: rotateY(90deg) translateZ($half) scaleY($level);
    }
  }
}

@keyframes d-equalizer-lid {
  @each $stop, $level in $levels {
    #{$stop} {
      // walls lost (1 - level) of the height: the lid drops by the same distance
      transform: translateY(calc(var(--h) * #{1 - $level})) rotateX(90deg) translateZ($half);
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.