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.

  • 40 lines of CSS
  • 3 lines of HTML
  • 20 lines of JS
  • No dependencies
  • MIT licensed

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.

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"></div>
</div>

CSS 40 lines

.scene {
  perspective: 800px;
}

.tiles {
  display: grid;
  grid-template-columns: repeat(7, 42px);
  gap: 5px;
  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: 42px;
  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: 7px;
  backface-visibility: hidden;
}

.tiles i::before { background: #ffb547; }
.tiles i::after  { background: #ff4d9d; transform: rotateY(180deg); }

.tiles[data-flipped] i {
  transform: rotateY(180deg);
}

JS 20 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];

grid.addEventListener('click', (e) => {
  const index = tiles.indexOf(e.target);
  if (index < 0) return;
  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');
});

Sass source 44 lines

.d-ripple {
  display: grid;
  grid-template-columns: repeat(7, 30px);
  gap: 4px;
  transform-style: preserve-3d;
  transform: rotateX(30deg);
  // The container lies on exactly the same 3D plane as its children. Coplanar surfaces have no
  // stable front-to-back order, so in patches the browser hit-tests the (invisible) container
  // instead of the child: wrong cursor, ignored hover. Only the children should catch the pointer.
  pointer-events: none;

  i {
    position: relative;
    pointer-events: auto;
    height: 30px;
    cursor: pointer;
    transform-style: preserve-3d;
    transition: transform 0.6s cubic-bezier(0.3, 1.3, 0.5, 1);
    // --d = distance from the clicked tile, written by JS just before the flip
    transition-delay: calc(var(--d, 0) * 70ms);

    &::before,
    &::after {
      content: '';
      position: absolute;
      inset: 0;
      border-radius: 6px;
      backface-visibility: hidden;
    }

    &::before {
      background: var(--warm);
    }

    &::after {
      background: var(--hot);
      transform: rotateY(180deg);
    }
  }

  &[data-flipped] i {
    transform: rotateY(180deg);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.