3D equalizer
Pure CSSSeven 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.
Click any tile: every tile flips, delayed by its distance from the one you clicked.
Your edited version
::before / ::after, backface-visibility: hidden) and flip when the grid gets data-flipped.transition-delay: calc(var(--d) * 70ms).--d, then toggles the attribute.pointer-events: none and the tiles auto. Both lie on the same 3D plane, and coplanar surfaces have no stable front-to-back order — without this the browser hit-tests the invisible grid in patches, and the cursor and clicks fail there.--u, tied to the canvas: the 7 × 5 tiles are 42 units square, so the grid is the same share of a gallery card, the editor and a recording canvas. The distance JS writes is a plain number of tiles, never a length.distance → --d → transition-delayone data attribute flips everythingtwo-sided tiles<div class="scene">
<div class="tiles" role="button" tabindex="0" aria-label="Flip the tiles in a ripple"></div>
</div>
.scene {
/* one base unit: every length below is a multiple of it, so the grid is the same share of a
gallery card, the editor, a full screen and a recording canvas */
--u: 0.25vmin;
perspective: calc(800 * var(--u));
}
.tiles {
display: grid;
grid-template-columns: repeat(7, calc(42 * var(--u)));
gap: calc(5 * var(--u));
transform-style: preserve-3d;
transform: rotateX(30deg);
/* the grid is on the same 3D plane as its tiles; coplanar surfaces have no stable order, so
in patches the browser would hit-test the grid instead of the tile (wrong cursor, dead clicks) */
pointer-events: none;
}
.tiles i {
position: relative;
height: calc(42 * var(--u));
pointer-events: auto;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.3, 1.3, 0.5, 1);
transition-delay: calc(var(--d, 0) * 70ms);
}
.tiles i::before,
.tiles i::after {
content: '';
position: absolute;
inset: 0;
border-radius: calc(7 * var(--u));
backface-visibility: hidden;
}
.tiles i::before { background: #ffb547; }
.tiles i::after { background: #ff4d9d; transform: rotateY(180deg); }
.tiles[data-flipped] i {
transform: rotateY(180deg);
}
const grid = document.querySelector('.tiles');
const COLS = 7, ROWS = 5;
for (let i = 0; i < COLS * ROWS; i++) grid.append(document.createElement('i'));
const tiles = [...grid.children];
function ripple(index) {
const r0 = Math.floor(index / COLS), c0 = index % COLS;
// 1) delays first…
tiles.forEach((tile, i) => {
const d = Math.hypot(Math.floor(i / COLS) - r0, (i % COLS) - c0);
tile.style.setProperty('--d', d.toFixed(2));
});
// 2) …then the change that triggers the transitions
grid.toggleAttribute('data-flipped');
}
grid.addEventListener('click', (e) => {
const index = tiles.indexOf(e.target);
if (index >= 0) ripple(index);
});
// the keyboard: Enter or Space ripples out from the middle tile
grid.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
ripple(Math.floor(tiles.length / 2));
});