Flipping loader
Pure CSSThe classic square loader: half a turn on X, then half a turn on Y.
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.
Your edited version
transform ever changes.scaleY() from a transform-origin: bottom center, so growing or shrinking a bar never touches layout — it is one compositor-only transform.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.scaleY walls + translateY lid (no layout)shared keyframe stops keep lid and walls gluedSass @each builds the keyframes from one mapper-bar delay and duration<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>
.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.2s; --dur: 1.5s; }
.bar:nth-child(2) { --hue: 250; --delay: -0.9s; --dur: 1.2s; }
.bar:nth-child(3) { --hue: 235; --delay: -0.5s; --dur: 1.7s; }
.bar:nth-child(4) { --hue: 220; --delay: -1.3s; --dur: 1.3s; }
.bar:nth-child(5) { --hue: 205; --delay: -0.1s; --dur: 1.6s; }
.bar:nth-child(6) { --hue: 190; --delay: -0.7s; --dur: 1.25s; }
.bar:nth-child(7) { --hue: 175; --delay: -1.1s; --dur: 1.45s; }
.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); }
}
@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
$delays: (-0.2s, -0.9s, -0.5s, -1.3s, -0.1s, -0.7s, -1.1s);
$durs: (1.5s, 1.2s, 1.7s, 1.3s, 1.6s, 1.25s, 1.45s);
// 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);
}
}
}