CSS + JS Loaders & patterns #pointer #generated

Ripple flip in CSS + JavaScript

Click any tile: every tile flips, delayed by its distance from the one you clicked.

  • 43 lines of CSS
  • 3 lines of HTML
  • 29 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Tiles are two-sided (::before / ::after, backface-visibility: hidden) and flip when the grid gets data-flipped.
  2. That single attribute would flip them all at once. The ripple comes from transition-delay: calc(var(--d) * 70ms).
  3. On click, JS works out each tile’s distance from the clicked one, writes it to --d, then toggles the attribute.
  4. Order matters: the delays must be in place before the change that triggers the transition.
  5. The grid has 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.
  6. Every length is a multiple of one base unit, --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.

Key techniques

  • distance → --d → transition-delay
  • one data attribute flips everything
  • two-sided tiles

Copy-paste code

HTML 3 lines

<div class="scene">
  <div class="tiles" role="button" tabindex="0" aria-label="Flip the tiles in a ripple"></div>
</div>

CSS 43 lines

.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);
}

JS 29 lines

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));
});
Standalone snippet · plain CSS, no build step, no dependencies.