GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 Bouncing Ball Animation Patterns With Physics

This bouncing ball animation set has nine builds below. Ball 01 trades velocity through a 0.82 restitution coefficient, and ball 04 rises to a different height

Auto-plays · click a tile to jump to its section · all nine in one zip

9 Bouncing Ball Animation Patterns: How One Value Reshapes the Motion

All nine balls below run on the same canvas loop: integrate velocity, resolve a collision or a boundary, and repaint only the pixels that moved. Ball 01 is the cleanest read of that loop, a pair trading momentum through a single restitution coefficient. By ball 05 the same loop reads a pointer position instead of a wall, and ball 09 folds gravity, wall bounce, and pair collision into one hero scene, the same layering approach JavaScript Physics Simulation: 9 Demos, No Library uses for springs and inertial scrubs. The order below moves from a single pair, through gravity and wall decay, into mass differences, pointer forces, drag momentum, spawn stacking, and finally the combined hero scene, so each ball hands the loop one more input to read.

01Ball Pair Exchanging Velocity Through an Elastic Collision

The instant two balls touch, resolvePair() reads their mass and velocity along the collision normal and swaps momentum through a BALL_RESTITUTION of 0.82, so the heavier ball barely moves while the lighter one shoots away. It reads like two billiard balls meeting mid-frame, which suits an interactive background scene where objects visibly trade energy.

Ball Pair Exchanging Velocity Through an Elastic Collision
Open in a new tab

01 · Ball Pair Exchanging Velocity Through an Elastic Collision — Measured area 40.801% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BALL_RESTITUTIONWALL_RESTITUTIONresolvePair
  function resolvePair(a, b) {
    var dx = b.x - a.x, dy = b.y - a.y;
    var dist = Math.hypot(dx, dy);
    var minDist = a.r + b.r;
    if (dist <= 0 || dist >= minDist) return;
    var nx = dx / dist, ny = dy / dist;
    var overlap = minDist - dist;
    var totalMass = a.mass + b.mass;
    a.x -= nx * overlap * (b.mass / totalMass);
    a.y -= ny * overlap * (b.mass / totalMass);
    b.x += nx * overlap * (a.mass / totalMass);
    b.y += ny * overlap * (a.mass / totalMass);

    var rvx = b.vx - a.vx, rvy = b.vy - a.vy;
    var vn = rvx * nx + rvy * ny;
    if (vn >= 0) return;
    var j = -(1 + BALL_RESTITUTION) * vn / (1 / a.mass + 1 / b.mass);
    var ix = j * nx, iy = j * ny;
    a.vx -= ix / a.mass; a.vy -= iy / a.mass;
    b.vx += ix / b.mass; b.vy += iy / b.mass;
    var strength = Math.min(1, Math.abs(vn) / 220);
    a.squash = Math.max(a.squash, strength);
    b.squash = Math.max(b.squash, strength);
    impacts.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, born: elapsed, strength: strength });
  }

Item 01 repainted 40.8% of the frame at an average intensity of 106.2 across 23/23 measured frames.

02Ball Falling Under Gravity and Settling on the Floor

Every frame adds GRAVITY to the ball's downward speed, and each floor contact multiplies that speed by FLOOR_RESTITUTION until it drops below SLEEP_THRESHOLD and the ball settles flat. That settle-then-restart cycle reads as a natural drop, which is why it works well as background motion behind a loading screen.

Ball Falling Under Gravity and Settling on the Floor
Open in a new tab

02 · Ball Falling Under Gravity and Settling on the Floor — Measured area 15.354% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLOOR_RESTITUTIONSLEEP_THRESHOLDallSettled
  function stepSim(dtMs, nowMs) {
    var dtS = dtMs / 1000;
    for (var i = 0; i < balls.length; i += 1) {
      var b = balls[i];
      if (nowMs < b.startAt) continue;
      if (b.settled) {
        b.squash = Math.max(0, b.squash - dtS * 6);
        continue;
      }
      b.vy += GRAVITY * dtS;
      b.y += b.vy * dtS;
      var floor = CALC_H - b.r;
      if (b.y >= floor) {
        b.y = floor;
        if (Math.abs(b.vy) < SLEEP_THRESHOLD) {
          b.vy = 0;
          b.settled = true;
          b.squash = 0.5;
          impacts.push({ x: b.x, y: floor, born: nowMs, strength: 0.35 });
        } else {
          impacts.push({ x: b.x, y: floor, born: nowMs, strength: Math.min(1, Math.abs(b.vy) / 320) });
          b.squash = Math.max(b.squash, Math.min(0.85, Math.abs(b.vy) / 300));
          b.vy = -b.vy * FLOOR_RESTITUTION;
        }

Item 02 repainted 15.4% of the frame at an average intensity of 103.7, and only 20/23 measured frames actually moved — the ball spends part of the capture window sitting still after it settles.

03Ball Losing Energy at Every Wall Bounce by a Restitution Value

The instant a ball reaches the canvas edge, stepSim() flips that axis's velocity and multiplies it by WALL_RESTITUTION, so every bounce lands a little weaker than the one before it. Once the combined speed drops under MIN_SPEED, kick() fires and reseeds the ball with a fresh seeded random angle and speed, which keeps a bounded scene playing for as long as it's on screen.

Ball Losing Energy at Every Wall Bounce by a Restitution Value
Open in a new tab

03 · Ball Losing Energy at Every Wall Bounce by a Restitution Value — Measured area 35.015% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

KICK_SPEEDMIN_SPEEDkick
  function stepSim(dtMs) {
    var dtS = dtMs / 1000;
    for (var i = 0; i < balls.length; i += 1) {
      var b = balls[i];
      b.x += b.vx * dtS;
      b.y += b.vy * dtS;
      var hit = false;
      if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
      if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
      if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
      if (b.y + b.r > CALC_H) { b.y = CALC_H - b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
      if (hit) {
        b.hits += 1;
        b.squash = Math.min(1, Math.hypot(b.vx, b.vy) / 260 + 0.25);
      }
      b.squash = Math.max(0, b.squash - dtS * 3.4);
      var speed = Math.hypot(b.vx, b.vy);
      if (speed < MIN_SPEED) kick(b);
    }
  }

Item 03 repainted 35.0% of the frame at an average intensity of 96.4 across 23/23 measured frames.

04Balls Whose Bounce Height Splits by Mass

restitutionFor() takes each ball's mass straight from the MASSES list (the radius is derived as half of that value) and divides BASE_MASS_REST times BALL_MASS_BOUNCE_FACTOR by that mass, so a light ball gets a restitution near 0.95 while a heavy one is pulled down toward 0.22. Dropping four balls from the same height at the same instant isolates that one variable, which suits a comparison card more than a full scene.

Balls Whose Bounce Height Splits by Mass
Open in a new tab

04 · Balls Whose Bounce Height Splits by Mass — Measured area 11.113% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BALL_MASS_BOUNCE_FACTORBASE_MASS_RESTrestitutionFor
  function restitutionFor(mass) {
    return Math.max(0.22, Math.min(0.95, BASE_MASS_REST * BALL_MASS_BOUNCE_FACTOR / mass));
  }

  var balls = [];
  function reset() {
    balls = [];
    for (var i = 0; i < MASSES.length; i += 1) {
      balls.push({
        x: CALC_W * XPOS[i],
        y: 26,
        vy: 0,
        mass: MASSES[i],
        r: MASSES[i] * 0.5,
        color: COLORS[i],
        rest: restitutionFor(MASSES[i]),
        squash: 0,
        settled: false
      });
    }
    impacts = [];
  }
  var impacts = [];

  function stepSim(dtMs, nowMs) {

Item 04 repainted only 11.1% of the frame at an average intensity of 108.9 across 23/23 measured frames — the smallest area of the nine, since the balls never leave a narrow vertical lane.

05Balls Scattering Away From an Approaching Pointer

applyPointer() measures the distance from every ball to the pointer each frame and, inside POINTER_REPEL_RADIUS, pushes harder the closer that distance gets, while balls outside the radius drift on a slow sine path around their own home point. That combination reads as balls actively dodging a cursor, which is the shape most background decorations that respond to a mouse actually want.

Balls Scattering Away From an Approaching Pointer
Open in a new tab

05 · Balls Scattering Away From an Approaching Pointer — Measured area 21.441% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

POINTER_REPEL_RADIUSREPEL_FORCEDRIFT_SPEED
  function applyPointer(px, py, dtS) {
    for (var i = 0; i < balls.length; i += 1) {
      var b = balls[i];
      var dx = b.x - px, dy = b.y - py;
      var dist = Math.hypot(dx, dy) || 0.001;
      if (dist < POINTER_REPEL_RADIUS) {
        var force = REPEL_FORCE * (1 - dist / POINTER_REPEL_RADIUS) / dist;
        b.vx += dx * force * dtS;
        b.vy += dy * force * dtS;
        b.squash = Math.min(1, b.squash + (1 - dist / POINTER_REPEL_RADIUS) * 0.6);
      }
    }
  }

  function stepSim(dtMs, nowMs, px, py) {
    var dtS = dtMs / 1000;
    applyPointer(px, py, dtS);
    for (var i = 0; i < balls.length; i += 1) {
      var b = balls[i];
      var homeX = b.hx + Math.cos(nowMs / 1000 * DRIFT_SPEED + b.phase) * 10;
      var homeY = b.hy + Math.sin(nowMs / 1000 * DRIFT_SPEED * 0.8 + b.phase) * 10;
      var pull = 0.9;
      b.vx += (homeX - b.x) * pull * dtS;
      b.vy += (homeY - b.y) * pull * dtS;
      b.vx -= b.vx * Math.min(1, DAMPING * dtS);

Item 05 repainted 21.4% of the frame at an average intensity of 88.1 across 23/23 measured frames.

06Balls Pulled Toward the Pointer and Orbiting It

stepSim() computes only a pull toward the pointer every frame, scaled by ATTRACT_K over distance, while the sideways velocity initBalls() gave each ball once at spawn (orbitSpeed, the square root of ATTRACT_K) survives DAMPING decay instead of vanishing, so the ball settles into an orbit rather than falling straight into the pointer. That reads like a small satellite following a cursor, which fits a decorative element that should circle rather than chase.

Balls Pulled Toward the Pointer and Orbiting It
Open in a new tab

06 · Balls Pulled Toward the Pointer and Orbiting It — Measured area 20.388% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ATTRACT_KMIN_DISTorbitSpeed
  function stepSim(dtMs, px, py) {
    var dtS = dtMs / 1000;
    for (var i = 0; i < balls.length; i += 1) {
      var b = balls[i];
      var dx = px - b.x, dy = py - b.y;
      var dist = Math.max(MIN_DIST, Math.hypot(dx, dy));
      var accel = ATTRACT_K / dist;
      var ax = (dx / dist) * accel, ay = (dy / dist) * accel;
      b.vx += ax * dtS;
      b.vy += ay * dtS;
      b.vx -= b.vx * DAMPING * dtS;
      b.vy -= b.vy * DAMPING * dtS;
      b.x += b.vx * dtS;
      b.y += b.vy * dtS;
      var speed = Math.hypot(b.vx, b.vy);
      b.squash = Math.min(0.5, speed / 900);
    }
  }

  function draw(px, py, nowMs) {

Item 06 repainted 20.4% of the frame at an average intensity of 77.2 across 23/23 measured frames.

07Ball Thrown at the Exact Speed of Its Drag

recordSample() keeps only the pointer positions from the last SAMPLE_WINDOW_MS, and releaseVelocity() turns the gap between the oldest and newest sample into a speed the instant the ball is let go, so the throw always matches how the drag actually felt. That is the physics a drag-to-throw card or ball interaction needs, since a single last-frame delta would read as jittery instead.

Ball Thrown at the Exact Speed of Its Drag
Open in a new tab

07 · Ball Thrown at the Exact Speed of Its Drag — Measured area 16.193% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SAMPLE_WINDOW_MSKEY_IMPULSEreleaseVelocity
  function recordSample(x, y, t) {
    samples.push({ x: x, y: y, t: t });
    while (samples.length && t - samples[0].t > SAMPLE_WINDOW_MS) samples.shift();
  }

  function releaseVelocity(nowT) {
    if (samples.length < 2) return { vx: 0, vy: 0 };
    var a = samples[0], b = samples[samples.length - 1];
    var dt = Math.max(16, b.t - a.t) / 1000;
    return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };
  }

  function throwBall(vx, vy) {
    ball.vx = vx; ball.vy = vy;
    if (Math.hypot(vx, vy) > 1) ball.heading = Math.atan2(vy, vx);
  }

  function stepSim(dtMs) {

Item 07 repainted 16.2% of the frame at an average intensity of 59.8 — the lowest average intensity of the nine — across 23/23 measured frames.

08Balls Pouring In From Above and Stacking on the Floor

spawn() adds one new ball from the top every SPAWN_INTERVAL_MS, and once two balls overlap, stepSim() runs SEPARATE_ITERS passes that push each pair apart by half their overlap along the line between their centers. That repeated separation is what keeps a pile of falling balls from sinking into each other, which suits filling an empty or completed state with motion.

Balls Pouring In From Above and Stacking on the Floor
Open in a new tab

08 · Balls Pouring In From Above and Stacking on the Floor — Measured area 33.263% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPAWN_INTERVAL_MSSEPARATE_ITERSHOLD_MS
    for (var iter = 0; iter < SEPARATE_ITERS; iter += 1) {
      for (var a = 0; a < balls.length; a += 1) {
        for (var c = a + 1; c < balls.length; c += 1) {
          var ba = balls[a], bc = balls[c];
          var dx = bc.x - ba.x, dy = bc.y - ba.y;
          var dist = Math.hypot(dx, dy) || 0.001;
          var minDist = ba.r + bc.r;
          if (dist < minDist) {
            var overlap = (minDist - dist) / 2;
            var nx = dx / dist, ny = dy / dist;
            ba.x -= nx * overlap; ba.y -= ny * overlap;
            bc.x += nx * overlap; bc.y += ny * overlap;
            if (ba.y + ba.r > CALC_H) ba.y = CALC_H - ba.r;
            if (bc.y + bc.r > CALC_H) bc.y = CALC_H - bc.r;
          }
        }
      }
    }
    if (balls.length >= MAX_BALLS) {
      if (holdSince === null) holdSince = nowMs;
      else if (nowMs - holdSince > HOLD_MS) { resetAll(); }
    }
  }

Item 08 repainted 33.3% of the frame at an average intensity of 98.9 across 23/23 measured frames.

09Hero Scene Combining Every Ball-Physics Technique

stepSim() here runs HERO_GRAVITY, the pointer-repel force from item 05, and the pair collision from item 01's resolvePair() inside the same loop, with every constant renamed under a HERO_ prefix so the three sources stay easy to tell apart. Layering all three into one scene is what a hero background needs, since a single technique alone would read thinner than the rest of the page around it.

Hero Scene Combining Every Ball-Physics Technique
Open in a new tab

09 · Hero Scene Combining Every Ball-Physics Technique — Measured area 39.741% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HERO_POINTER_FORCEHERO_WALL_RESTITUTIONHERO_GRAVITY
  function stepSim(dtMs, nowMs, px, py) {
    var dtS = dtMs / 1000;
    for (var i = 0; i < balls.length; i += 1) {
      var b = balls[i];
      b.vy += HERO_GRAVITY * dtS;
      var dx = b.x - px, dy = b.y - py;
      var dist = Math.hypot(dx, dy) || 0.001;
      if (dist < HERO_POINTER_REPEL_RADIUS) {
        var force = HERO_POINTER_FORCE * (1 - dist / HERO_POINTER_REPEL_RADIUS) / dist;
        b.vx += dx * force * dtS;
        b.vy += dy * force * dtS;
      }
      b.x += b.vx * dtS;
      b.y += b.vy * dtS;
      var hit = false;
      if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * HERO_WALL_RESTITUTION; hit = true; }
      if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * HERO_WALL_RESTITUTION; hit = true; }
      if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * HERO_WALL_RESTITUTION; hit = true; }
      if (b.y + b.r > CALC_H) {
        b.y = CALC_H - b.r;
        b.vy = -b.vy * HERO_WALL_RESTITUTION;
        if (Math.abs(b.vy) < 24) b.vy = 0;
        hit = true;
      }
      if (hit) b.squash = Math.max(b.squash, Math.min(0.75, Math.hypot(b.vx, b.vy) / 300));

Item 09 repainted 39.7% of the frame at an average intensity of 99.9 across 23/23 measured frames, and its single busiest frame covered 16.7% of the canvas — the highest single-frame jump of the nine.

Nine Bouncing Ball Patterns, Measured in Area and Intensity

The table below lines up how much of the frame changed between captures against how strong that change was on average wherever it landed. Item 04 lands the highest per-pixel intensity of the nine despite repainting the smallest area, item 07 keeps the lowest average intensity throughout, and item 09 covers more of a single frame than any other build even though its full-run average sits in the middle. A bouncing ball animation reads calmest when area stays under 20% and intensity stays under 100, a band roughly half of these nine fall into.

| Pattern | Cumulative area | Avg. intensity | Frames moved |

|---|---|---|---|

| 01 Elastic pair collision | 40.8% | 106.2 | 23/23 |

| 02 Gravity drop and settle | 15.4% | 103.7 | 20/23 |

| 03 Wall restitution decay | 35.0% | 96.4 | 23/23 |

| 04 Mass-split bounce height | 11.1% | 108.9 | 23/23 |

| 05 Pointer repel scatter | 21.4% | 88.1 | 23/23 |

| 06 Pointer attract orbit | 20.4% | 77.2 | 23/23 |

| 07 Drag-throw momentum | 16.2% | 59.8 | 23/23 |

| 08 Spawn cascade stack | 33.3% | 98.9 | 23/23 |

| 09 Hero composite scene | 39.7% | 99.9 | 23/23 |

Where the Value Changes the Motion — Restitution, Mass, Pointer Radius

Three constants below do most of the shaping: BALL_RESTITUTION decides how much speed item 01's pair keeps after they touch, BALL_MASS_BOUNCE_FACTOR decides how far apart item 04's light and heavy balls land from each other, and POINTER_REPEL_RADIUS decides how early item 05's balls start clearing a path. None of the three change what gets drawn — they change how bouncy, how uneven, and how cautious the same physics loop reads on screen, which is most of what separates a bouncing ball animation reference worth bookmarking from one that just loops the same drop forever. Brick Breaker Game JavaScript — 9 Physics Patterns tunes a related constant, the paddle-hit reflection angle, on the same kind of per-frame loop.

Where It Breaks — Reduced Motion, Narrow Screens, Too Many Balls

prefers-reduced-motion is checked with matchMedia in all nine of these builds — dropping the auto-playing .is-demo class on load rather than disabling the canvas outright, so item 06 still orbits when a pointer actually moves over it and item 07 still throws on a real drag. fit() rescales the 320×200 calculation space to whatever the canvas element measures, so a narrow phone width shrinks every ball's on-screen size without changing how the physics itself runs. Item 08 is the one to watch on a low-end device: once MAX_BALLS balls are stacked, stepSim() still runs SEPARATE_ITERS separation passes against all of them every frame, and that pairwise check is where a slow device is most likely to drop frames first — not the gravity or the drawing. The nine index.html/style.scss pairs behind these builds, nqxed599, are the fastest way to see exactly how little ball physics animation code it takes to run all nine of these bouncing ball demos at once.

Three Variants and Accessibility

| Variant | Value change | Feel |

|---|---|---|

| Adjust the elastic collision restitution coefficient | 01 BALL_RESTITUTION 0.82 → 0.55 | Lowering the value makes two colliding balls lose energy fast and drift together, while raising it keeps every bounce forceful for longer. |

| Adjust the mass-to-bounce-height factor | 04 BALL_MASS_BOUNCE_FACTOR 1.4 → 2.2 | Lowering the value shrinks the height gap between heavy and light balls, while raising it sends only the light balls shooting much higher. |

| Adjust the pointer repel radius | 05 POINTER_REPEL_RADIUS 90 → 160 | Lowering the value means balls scatter only when the pointer gets very close, while raising it makes them clear space well before the pointer arrives. |

Every one of the nine checks prefers-reduced-motion once at the top of its own script and answers by dropping the autoplay class rather than turning off individual properties, so a visitor who asked for less motion still sees a ball at rest instead of a blank canvas. Arrow keys and Enter or Space work on four of the nine — items 05, 06, 07, and 09 all set tabindex="0" and listen for keydown, moving a virtual pointer, nudging the orbit, throwing the held ball, or panning the hero scene without a mouse. MDN has the full reference for all three: CanvasRenderingContext2D for the drawing surface itself, requestAnimationFrame() for the loop driving it, and the prefers-reduced-motion media query behind the accessibility check above.

What Does a Bouncing Ball Animation Guide Actually Calculate?

None of these nine load a physics engine. Each keeps its own array of plain ball objects, steps them forward on a fixed FIXED_DT of 16ms accumulated against real elapsed time, and resolves gravity, walls, pointer forces, or pair collisions with a few lines of Math.hypot() and vector math — the same category of calculation a physics engine wraps in its own API, written out in the open instead. Eight of the nine seed their starting positions or angles with a local seedRand() so a reload looks the same every time; item 04 is the one exception, since its four balls start from fixed positions and never need a random seed. Spin Wheel: 9 Canvas Roulettes, Flick to Stop runs a related friction-and-restitution calculation on a single rotating disc instead of multiple balls.

Frequently Asked Questions About Bouncing Ball Physics Timing

Can I drop one of these nine into a page as a bouncing ball animation keyframes replacement without any build step?

Yes — every demo is a single self-contained index.html with its script inline, so copying the file and its style.scss companion is the whole setup; nothing here depends on a CSS @keyframes block or a bundler.

Why does item 07 show a lower average intensity than the rest?

Item 07 only redraws one ball at a time instead of a whole scene's worth, so each frame's brightness change is smaller even while its area stays comparable to items 02 and 05 — averaged across 23/23 measured frames, its intensity of 59.8 is the lowest of the nine.

Do I need a physics engine for a bouncing ball animation like these nine?

No — every build here resolves gravity, walls, mass, or pointer forces with a fixed timestep and a handful of vector calculations, the same math a physics engine performs internally but written directly into stepSim() instead of imported.

Enter the archive password

The password is inside this article. You will find it as you read.