Zero-Gravity State in Web Apps
Building animations that feel weightless—where cards tilt, particle fields drift, and skill bubbles orbit—requires a shift in how we think about React state updates. If we store high-frequency cursor coordinates or frame-by-frame physics loops inside React component state (useState), we trigger thousands of unnecessary re-renders.
This leads to interface stuttering, input lag, and a poor user experience.
To solve this, we must adopt a zero-gravity state architecture.
The Rules of Zero-Gravity State
- Bypass the React Render Cycle: For high-frequency updates (like custom cursors tracking the mouse or Canvas animations), store values in React
useRefrather than state. - Utilize RequestAnimationFrame (rAF): Read directly from mutable refs inside a
requestAnimationFrameloop. - Hardware Acceleration: Shift layout transitions and transforms to the GPU using CSS
will-change: transformandtransform-gputailwind properties.
┌──────────────────────────────────────────────────────────────┐
│ Browser DOM │
│ │
│ ┌───────────────────────┐ ┌──────────────────────┐ │
│ │ High-Freq Mouse ├────────►│ React Refs (X, Y) │ │
│ │ Move Event Listener │ └──────────┬───────────┘ │
│ └───────────────────────┘ │ │
│ ▼ │
│ ┌───────────────────────┐ ┌──────────────────────┐ │
│ │ DOM Elements / │◄────────┤ requestAnimationFrame│ │
│ │ Canvas Shader │ │ Loop (with LERP lag) │ │
│ └───────────────────────┘ └──────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Implementing the LERP Lag
Linear interpolation (LERP) is the secret behind custom cursors or smooth scrolling that feel like they drift. Instead of snapping elements directly to coordinates, we move them a percentage of the remaining distance:
$$\text += (\text - \text) \times \text$$
Here is a clean typescript utility implementing this loop for a dual-axis translation:
interface Point {
x: number;
y: number;
}
export class SmoothTracker {
private current: Point = { x: 0, y: 0 };
private target: Point = { x: 0, y: 0 };
private factor: number;
constructor(factor: number = 0.1) {
this.factor = factor;
}
public setTarget(x: number, y: number) {
this.target = { x, y };
}
public update(): Point {
this.current.x += (this.target.x - this.current.x) * this.factor;
this.current.y += (this.target.y - this.current.y) * this.factor;
return { ...this.current };
}
}
By separating structural application state (handled by React) from high-frequency rendering updates (handled by direct refs and native loops), we can achieve smooth interfaces that run at a lock 120fps.