Parting The Waves
An experiment with physics & water.
Bit ·
Drag your cursor through a field of a thousand squares and they slide out of the way, swirl in your wake, then roll back into place like water closing behind a hand. That's Parting Grid, a small interactive piece built with React and a single HTML canvas.
It looks like a fluid simulation, but there's no fluid maths involved. Every square is just a weight on a spring, tied to its neighbours. Get those springs right and the grid starts to behave like a liquid.
In this post I'll walk through how it works: the physics that makes the squares part, the small visual touches that make it feel alive, and how to keep 3,000 moving squares running at 60 frames a second inside a React component.
The idea
A grid of squares is about the most rigid thing you can put on a screen, which is why it's fun to make it act like a liquid. The contrast does the work: your eye expects the squares to stay put, so when they flow it feels physical.
"Parting like liquid" breaks down into four behaviours:
- Displacement. Squares near the cursor get pushed away from it.
- Wake. Squares get dragged slightly in the direction the cursor is moving, the way water follows a hand.
- Ripples. A disturbance spreads outward to squares the cursor never touched.
- Recovery. Everything drifts back to its starting position, overshooting a little before it settles.
Each one maps to a single force in the code. Add them together and you get the effect.
How it works
Every square stores two things: its offset from its home position (dx, dy) and its velocity (vx, vy). Each frame, we add up the forces on each square, update its velocity, then move it. Four forces do all the work.
1. A spring back home
Each square is pulled towards its rest position with a force proportional to how far it has strayed. This is Hooke's law, and it's what makes the grid recover.
let ax = -K * dx[k]let ay = -K * dy[k]
K is small (0.05), so the pull is gentle and squares glide home rather than snapping back.
2. Springs between neighbours
This is the force that turns a grid of springs into something that behaves like water. Each square is also tied to the four squares around it. If its neighbours have moved further than it has, it gets pulled along with them.
In practice we compare a square's offset with the sum of its neighbours' offsets (a discrete Laplacian, if you want the proper name):
lx[k] = (left + right + up + down) - 4 * dx[k]ax += C * lx[k]
Push one square and it tugs its neighbours, which tug theirs. The disturbance travels outward as a ripple. Without this term each square would move on its own and the effect would look like iron filings around a magnet, not liquid.
3. The push from the cursor
Squares within a set radius of the cursor are pushed directly away from it. The push is strongest at the centre and fades smoothly to nothing at the edge, using a smoothstep curve so there's no visible ring where the effect stops:
let f = 1 - d / R
f = f * f * (3 - 2 * f)ax += (ex / d) * push * f
One detail matters a lot here. A fast mouse can jump 50 pixels between frames, and if you only measure distance to the cursor's current position, squares in that gap never get pushed. The result is a dotted trail instead of a clean channel. So instead of measuring distance to a point, Parting Grid measures distance to the line segment between the cursor's last position and its current one. However fast you swipe, every square along the path is parted.
4. The wake
Water doesn't just get pushed aside; it gets dragged along behind whatever moves through it. To get that, squares inside the radius also get a small nudge in the direction the cursor is travelling, scaled by how far it moved this frame:
ax += segX * wake * fay += segY * wake * f
This is what gives a swipe its swirl: the squares part sideways and also stream after the cursor.
Putting it together: damping and viscosity
With the forces added up, each square's velocity is updated and multiplied by a damping factor, then its position moves by that velocity:
vx[k] = (vx[k] + ax) * dampdx[k] += vx[k]
The damping factor is the viscosity control. At 0.955 the squares keep their energy, overshoot and ripple for a while, like water. At 0.845 they lose it quickly and ooze back into place, like honey. One number changes the whole character of the liquid.
The simulation also scales with frame time, so it behaves the same on a 60 Hz laptop and a 120 Hz phone.
Making it look alive
The physics makes the squares move like liquid. A few visual touches make them look like it.
- Colour by displacement. Squares at rest are a deep ink colour. The further a square is pushed from home, the closer it shifts towards a bright teal. The ramp is pre-computed as 24 steps when the page loads, so there's no colour maths per square per frame. The result is a glowing channel that traces your path and fades as the grid settles.
- Rotation. Each square tilts based on its offset and velocity, so squares tumble as they're pushed instead of sliding stiffly. The tilt is capped at about 50 degrees so it never turns chaotic.
- Squash. Fast-moving squares shrink by up to 35%. It's a cheap trick, but it reads as motion blur and makes quick swipes feel faster.
- Splash. Clicking or tapping fires a single outward impulse into every square within reach. Because of the neighbour springs, that impulse becomes a ring that spreads and bounces back.
- A ghost cursor. An empty grid of still squares doesn't tell anyone what to do. So when nobody is interacting, an invisible cursor drifts through the grid on a slow looping path, stirring it. It stops the moment you move your own cursor in, and comes back after a few seconds of quiet.
- Reduced motion. If the visitor's system asks for reduced motion, the ghost cursor is switched off. The grid only moves when they move it.
Keeping it fast inside React
At the densest setting the grid has 56 × 56 = 3,136 squares, each updated and redrawn every frame. A few choices keep that smooth.
Canvas, not DOM. Rendering 3,000 <div> elements and updating their styles 60 times a second would grind any browser down. Instead, the whole grid is drawn onto one <canvas>. Each square is a single fillRect call after setting its position, rotation and scale in one setTransform.
Typed arrays, not objects. Offsets, velocities and rest positions live in Float32Arrays, one per property, rather than an array of square objects. The data sits together in memory, there's no garbage for the browser to collect, and the loops stay tight.
React owns the controls, not the frames. React renders the page, the sliders and the readout. The animation loop runs in a useEffect with requestAnimationFrame and never touches React state per frame. Slider values reach the loop through a ref, so dragging the viscosity slider changes the liquid instantly without restarting the simulation:
const params = useRef({ viscosity, reach })params.current = { viscosity, reach }
Only a change of grid density restarts the effect, since that needs new arrays. The "displaced squares" counter is sent back to React every 10 frames rather than every frame, which keeps re-renders rare.
Do nothing at rest. Each frame totals the energy in the grid. When it drops below a tiny threshold and nothing is stirring, the loop skips drawing entirely. A still grid costs almost nothing.
Respect the screen. The canvas is sized with a ResizeObserver and scaled by devicePixelRatio (capped at 2) so squares stay crisp on retina screens. Colours are read from CSS custom properties and re-read when the theme changes, so the canvas follows light and dark mode like the rest of the page.
Try it yourself
The whole effect comes down to four forces: a spring home, springs to the neighbours, a push away from the cursor's path, and a nudge along it. Damping decides whether the result feels like water or honey.
A few things worth playing with:
- Turn the grid density right up and the viscosity right down for the most liquid-looking ripples.
- Raise the reach and swipe slowly to carve wide channels through the grid.
- Click repeatedly in one spot and watch the splash rings overlap and interfere.
The same approach works with dots, lines, letters or image tiles. Swap the fillRect for whatever you want to draw and the physics stays the same.