MIT · three.js · ~10 KB gzipped

ballpit-js

A hero background that pushes back. Spheres rain into the container, settle into a real pile, and scatter when your cursor drags through them.

You are looking at it. Drag your cursor through the pile; the panel top-left is the built-in tuning UI.

Why it exists

Most decorative hero backgrounds are a looping video or a shader: pretty, but nothing happens when you touch them. This one is a small physics world. The balls stack on each other, hold their shape, and remember where you shoved them — so a visitor has something to fiddle with instead of scrolling straight past.

Built for ozdalgic.com and pulled out into a library afterwards.

One function

No canvas framework to learn and no physics engine to bundle. Point it at a container element and it fills it.

Real contacts

Verlet integration with positional relaxation over a uniform-grid broadphase — cost scales with ball count, not its square.

Sizes in pixels

Diameters are CSS pixels and are re-derived on every resize, so a 60 px ball stays 60 px whatever the viewport does.

Polices its own frames

It times real frames and sheds balls if the median runs past your budget. Deterministic seed, so the pour is identical every load.

Install

Not on npm yet — install from the repository:

npm install github:ozdalgic/ballpit-js three

Or copy src/ballpit.js into your project; it is a single module with three.js as its only dependency.

Use it

import { createBallpit } from 'ballpit-js';

createBallpit(document.querySelector('#hero-pit'), {
  count: 180,
  palette: ['#eaf1ff', '#2957ff', '#00509f'],
});

The container needs a size of its own — the canvas fills it:

#hero-pit { position: absolute; inset: 0; z-index: 0; }

Without a build step

The module imports the bare specifier three, so a bundler resolves it for you. Otherwise, an import map does the same job — this page uses exactly that:

<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js"
    }
  }
</script>

Options

OptionDefaultWhat it does
count180How many balls. Absolute, not a density — see Sizing.
minSize / maxSize25 / 60Ball diameters in CSS pixels, re-derived whenever the container resizes.
palette3 bluesColours cycled across the balls. Each gets a glossy and a frosted material.
gravity11Downward acceleration in world units/s².
drift0Sideways acceleration. Negative makes the pile lean and re-gather to the left.
pointer.radius1.45Size of the invisible disc the cursor drags through the pile (~157 px).
pointer.strength0.02Shove budget per step. See Tuning the cursor.
envIntensity0.45Environment reflection. Raise for wetter, more mirror-like balls.
specular / clearcoat0.3 / 0.12Grazing-angle reflectance and the coat on top of it.
maxPixelRatio1.5DPR cap. The retina pass is the single biggest fragment cost.
autoQualitytrueShed balls if frames run long. See Performance.
budgetMs8The frame cost that counts as “too slow”.
seed20260907Fixed, so the pour is identical on every load.
respectReducedMotiontrueRender one settled frame instead of animating.
controlsfalseShow the built-in tuning panel.
labelsEnglishPanel strings, for translating the UI.

API

const pit = createBallpit(el, options);

pit.setCount(240);      // add or remove balls; new ones rain in
pit.setSizes(20, 45);   // min/max diameter in CSS px
pit.repour();           // replay the opening pour
pit.destroy();          // remove listeners, dispose GPU resources, drop the canvas
pit.canvas;             // the <canvas> element
The built-in tuning panel: sliders for count, sizes and cursor behaviour.
controls: true gives you sliders for count, sizes and the cursor, plus a re-pour button. Useful while you dial a design in — or keep it, it works fine as a toy for visitors.

Sizing

count is a number of balls, not a density. The same value reads as a shallow bed in a wide hero and a deep drift in a narrow one, so pick it against your own container:

ContainerReads as
~1400 × 640120 — a bed roughly two balls deep
~1900 × 580180 — the same depth across a wider hero
~2500 × 610180 — sparser, closer to a single layer

Tuning the cursor

pointer.strength is a hard cap on how far one ball may be displaced in a single step. It has to be re-tuned when the packing changes, and the useful range is narrow:

  • Too high and a single drag bulldozes the whole bed into a mound — and with drift: 0 nothing ever undoes that.
  • Too low and a densely packed pile simply absorbs the push: neighbour contacts cancel it out within the same step and nothing visibly moves.

The default (0.02, with radius: 1.45) shoves nearby balls a few widths aside and leaves the bed's shape intact across repeated drags. Measured against 180 balls in a 1900 × 580 container.

Performance

Everything shares one sphere geometry and a handful of materials. Measured on an M4 Max, step + render per frame:

Ballsms / frameShare of a 60 fps budget
1200.221%
1800.392%
4000.775%

Those are one machine's numbers. A weak integrated GPU will be several times slower, and the fragment cost — not the physics — is what bites. autoQuality therefore times the real frames and drops to 60% of the balls if the median exceeds budgetMs. If you expose the count slider to visitors, the safety net stands down as soon as they touch it: their choice wins.

Accessibility

  • prefers-reduced-motion: reduce renders a single settled frame instead of animating.
  • The pit is decorative — give the container aria-hidden="true" and keep real content in normal DOM above it.
  • The canvas never takes pointer events from your content; it tracks the cursor from window instead.

Browser support

Any browser with WebGL2 and ES modules. If WebGL is unavailable, createBallpit warns and returns a no-op controller rather than throwing, so the page is unaffected.