Style Queries Meet the Arcade: Collision Detection in Pure CSS

When Lee Meyer first saw Manuel Schaller’s CodePen of a pure CSS Pong simulation, it played itself in an endless loop—two automated paddles and a bouncing ball, no input required. But with a little coaxing, that same demo can become interactive. By adding a vertical range input and a life counter, the left paddle becomes player-controlled, all through a combination of modern and experimental CSS features that handle collision detection inside CSS animations.

The result feels less like a hack and more like a glimpse of where CSS is headed. Compare that to Alex Walker’s 2013 CSS Pong, which he once described as “a glittering city of hacks built on the banks of the ol’ Hack River. On the Planet Hack.” Walker’s version relied on checkbox hacks, sibling selectors, and :hover. The new demo, by contrast, uses style queries to detect collisions, alongside animatable custom properties and animation timelines. It’s a striking measure of how much more expressive CSS has become in the intervening decade—and a hint that future features like inline conditionals and custom functions could simplify the code even further.

The Mechanics: Style Queries, Custom Properties, and Timelines

Collision detection in CSS animations sounds like a stretch, but the 2025 implementation reads surprisingly close to something you might write by hand. The demo’s core trick is using style queries to check whether the ball’s position intersects with a paddle’s bounds. That check then drives the animation, reversing direction or changing state as needed. It’s nowhere near as convoluted as earlier attempts, and it’s roughly the same size as Walker’s 2013 implementation—but it doesn’t feel like a stack of fragile dependencies.

The real win is how several modern CSS features work in concert. Style queries handle the collision logic. Animatable custom properties let the ball and paddles carry their own state through the animation. And animation timelines, particularly view progress timelines, tie the ball’s movement to the overall animation flow without resorting to JavaScript.

That combo also makes the demo mobile-friendly. Because Chrome now supports controlling animations with range inputs, the game can be played with a slider on a touchscreen—something the 2013 version couldn’t manage, tied as it was to :hover. Temani Afif has documented how view progress timelines can be used to style elements based on a range input’s value, and that technique is central here.

What Works—and What Doesn’t

Trying to build Pong in CSS isn’t something the CSS Working Group likely had in mind, but that’s not the point. Projects like this demonstrate how far the platform has come. Reading introductory tutorials about individual features is one thing; seeing them interoperate in a working game is another. You start to appreciate that CSS’s increased flexibility might soon make it a legitimate choice for effects that currently require scripting—at least for those willing to push against the current limits.

Those limits do show up. Style queries and animation timelines are still young, and the demo runs into their rough edges. But Meyer’s kids playtested it on both phone and laptop, which is as good a seal of quality as any. What’s striking is that it works at all, and that the techniques used here are not one-off tricks—they’re the building blocks of future CSS. The boundaries are being tested, and for now, the result is a fully playable, pure CSS arcade game. The haunted carnival token was, apparently, well spent.

Style queries as a collision detector

The only interactive moment in this CSS Pong demo is whether the paddle intercepts the ball when it reaches the player's side. That single decision determines whether the ball continues its fixed animation loop or the screen flashes red, the life counter decrements, and eventually the game-over screen appears. Game designers call this a quick time event — it is still a game, but one limited to a small set of possible outcomes. That limitation is precisely what makes it feasible in CSS rather than JavaScript.

Container style queries only support name-value pair conditions, matching the syntax of feature queries rather than the comparison operators available in container size queries. So detecting whether the ball is within the paddle's range requires a workaround. The trick is to use the min() function: if the result of min() equals the first argument, that argument is less than or equal to the second; otherwise it is greater. This lets us write style queries that check whether the ball position falls in or out of the paddle's range, which would otherwise require "greater than" or "less than" comparisons.

body {
  --int-ball-position-x: round(down, var(--ball-position-x));
  --min-ball-position-y-and-top-of-paddle: min(var(--ball-position-y) + var(--ball-height), var(--ping-position));
  --min-ball-position-y-and-bottom-of-paddle: min(var(--ball-position-y), var(--ping-position) + var(--paddle-height));
}

@container style(--int-ball-position-x: var(--ball-left-boundary)) {
  .screen {
    --lives-decrement: running;
      
    .field {
      background: red;
    }
  }
}

@container style(--min-ball-position-y-and-top-of-paddle: var(--ping-position)) and style(--min-ball-position-y-and-bottom-of-paddle: var(--ball-position-y)) and style(--int-ball-position-x: var(--ball-left-boundary)) {
  .screen {
    --lives-decrement: paused;

    .field {
      background: green;
    }
  }
}

Side effects through animation play state

Once collision detection is in place, we need two responses: a green flash when the paddle hits the ball, and a red screen with a decrementing life counter when it misses. Since CSS cannot directly run code on an event, the workaround relies on pausing and unpausing keyframe animations. When the ball misses the paddle and hits the wall, a style query unpauses the life-decrementing animation just long enough to complete one step. When lives reach zero, the play field is hidden and the game-over screen appears.


body {
  animation: ball 8s infinite linear, lives 80ms forwards steps(4) var(--lives-decrement);
  --lives-decrement: paused;        
}

.lives::after {
   content: var(--lives);
}

@keyframes lives {
  0% {
    --lives: "3";
  }
  25% {
    --lives: "2";
  }
  75% {
    --lives: "1";
  }
  100% {
    --lives: "0";
  }
}

@container style(--int-ball-position-x: var(--ball-left-boundary)) {
  .screen {
    --lives-decrement: running;
      
    .field {
      background: red;
    }
  }
}

@container style(--min-ball-position-y-and-top-of-paddle: var(--ping-position)) and style(--min-ball-position-y-and-bottom-of-paddle: var(--ball-position-y)) and style(--int-ball-position-x: 8) {
  .screen {
    --lives-decrement: paused;
    
    .field {
      background: green;
    }
  }
}

@container style(--lives: '0') {
  .field {
     display: none;
  }
  
  .game-over {
     display: flex;
  }
}

This approach reveals a broader capability: style queries let animations indirectly control any property, even non-animatable ones, including the play state of other animations. Animation play state is deliberately non-animatable because animating it could create dangerous feedback loops, but pausing and unpausing via style queries sidesteps that restriction.

Choosing animation durations turned out to be the hidden complexity. If the life-decrementing animation is too slow, it won't complete a step while the ball is still hitting the wall. If it is too fast, a single miss could cost more than one life. Setting both the ball-bouncing animation to eight seconds and the life animation to 80 milliseconds — both multiples of eight — seemed to solve intermittent failures where the life counter sometimes failed to decrement at the same point in the loop.

Why predictable animations behave unpredictably

The intermittent nature of the bug is puzzling because both animations have fixed durations and should follow the same timeline every loop. Several theories might explain it:

  1. CSS animations suffer from timer drift, so a one-second animation may sometimes take slightly more or less than a full second. When two animations change at different rates, the drift between them compounds and produces unpredictable synchronization. If both animations change at the same rate, they drift about equally and stay in sync — or at least the chance of desync becomes negligible.
  2. Moving the ball with translate3d() even for 2D motion could improve timing precision by offloading rendering to the GPU, but that path involves rendering-performance tradeoffs and tuning that go beyond this experiment.
  3. Style queries might take a variable amount of time to activate, creating a race condition. Slowing down the ball animation could make this failure mode less likely.
  4. The bug might still be lurking. The "fix" may be a placebo.

The collision detection works reliably enough to demonstrate the concept, but the timing sensitivity underscores how CSS animation timelines are not a hard real-time system. Small timing discrepancies that are irrelevant for visual effects become significant when used for game logic.

Design origins and play testing

This experiment forks Manuel Schaller's looping CSS Pong animation, which includes paddle animations that twitch to create the illusion of a computer opponent. That foundation allowed the collision detection work to focus on style queries rather than reimplementing the game.

Play testing was handled by the author's seven- and twelve-year-old children, who declared the implementation "pretty cool" and suggested the green and red flashes for hits and misses.