Back to Blog
App DevelopmentPublished on July 20, 2026

Beyond CSS Transitions: Engineering Real-Time Soft-Body Physics for DOM Elements

Discover how to build highly organic, reactive user interfaces by implementing spring-mass systems and Verlet integration directly in the browser. This guide walks you through bypassing traditional CSS transitions to engineer fluid, soft-body UI controls from scratch.

The Limits of Declarative Animation

Modern web development has perfected the art of transition. With tools like Framer Motion, GSAP, and native CSS transitions, developers can easily move elements from point A to point B using cubic-bezier curves. However, these declarative animation techniques share a fundamental limitation: they are mathematically rigid. A standard cubic-bezier curve does not react to the speed of a user’s cursor, nor does it retain physical momentum, inertia, or structural elasticity. It is a pre-calculated path, completely detached from physical reality.

To build truly organic, high-fidelity user interfaces—such as "jelly" buttons, elastic sidebars, and liquid form controls—we must abandon declarative transitions and step into the world of physical simulation. By constructing a real-time soft-body physics engine inside the browser, we can make DOM elements behave like actual physical matter that reacts dynamically to user interactions.

In this deep dive, we will engineer a lightweight, high-performance spring-mass system using Verlet integration, map its physical nodes to dynamic SVG paths, and optimize the rendering pipeline to maintain a locked 60 FPS on mobile and desktop hardware alike.

The Physics of Jelly: Spring-Mass-Damper Systems

To simulate a soft-body object, we represent its structure as a network of point masses (nodes) connected by structural springs (constraints).

When an external force (such as a cursor hover, drag, or click) acts upon a node, it displaces that node from its equilibrium position. The connected springs stretch or compress, exerting a restorative force proportional to the displacement. To prevent the system from oscillating infinitely, we introduce damping, which simulates friction and energy loss.

Instead of Euler integration (which calculates velocity explicitly and is highly susceptible to numerical instability), we use Verlet Integration. Verlet integration calculates a point's next position based on its current position, its previous position, and its acceleration:

$$x_{t + \Delta t} = x_t + (x_t - x_{t - \Delta t}) + a \cdot \Delta t^2$$

Because velocity is implicit (derived from the difference between the current and previous positions), Verlet integration handles constraints with incredible stability. If a spring stretches too far, we can simply project the points back toward their allowed distance without manually resolving complex velocity vectors.

Designing the Architecture: The Verlet Engine

Let's write a highly optimized, object-oriented physical engine in TypeScript. We will define two core structures: Node (representing a physical coordinate in 2D space) and Spring (representing the elastic constraint between two nodes).

class PhysicsNode {
  public x: number;
  public y: number;
  public px: number; // Previous X
  public py: number; // Previous Y
  public ax: number = 0; // Acceleration X
  public ay: number = 0; // Acceleration Y
  public originalX: number;
  public originalY: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
    this.px = x;
    this.py = y;
    this.originalX = x;
    this.originalY = y;
  }

  public update(dt: number, friction: number): void {
    const tempX = this.x;
    const tempY = this.y;

    // Verlet formula
    this.x += (this.x - this.px) * friction + this.ax * dt * dt;
    this.y += (this.y - this.py) * friction + this.ay * dt * dt;

    this.px = tempX;
    this.py = tempY;

    // Reset acceleration
    this.ax = 0;
    this.ay = 0;
  }

  public applyForce(fx: number, fy: number): void {
    this.ax += fx;
    this.ay += fy;
  }
}

Next, we implement the Spring constraint. The spring must calculate the distance between two nodes, compare it to its target rest length, and apply a proportional restorative force to both nodes.

class Spring {
  public nodeA: PhysicsNode;
  public nodeB: PhysicsNode;
  public restLength: number;
  public stiffness: number;

  constructor(nA: PhysicsNode, nB: PhysicsNode, stiffness: number = 0.1) {
    this.nodeA = nA;
    this.nodeB = nB;
    this.restLength = this.getDistance();
    this.stiffness = stiffness;
  }

  public getDistance(): number {
    const dx = this.nodeB.x - this.nodeA.x;
    const dy = this.nodeB.y - this.nodeA.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  public resolve(): void {
    const dx = this.nodeB.x - this.nodeA.x;
    const dy = this.nodeB.y - this.nodeA.y;
    const currentLength = Math.sqrt(dx * dx + dy * dy) || 0.0001;
    const diff = (this.restLength - currentLength) / currentLength;

    // Hooke's Law scalar adjustment
    const offsetX = dx * diff * this.stiffness * 0.5;
    const offsetY = dy * diff * this.stiffness * 0.5;

    this.nodeA.x -= offsetX;
    this.nodeA.y -= offsetY;
    this.nodeB.x += offsetX;
    this.nodeB.y += offsetY;
  }
}

Mapping Physics to the DOM: The Soft-Body Button

To render this simulation as an HTML element, we will overlay an SVG element directly on top of our interaction target. We construct the perimeter of our button out of interconnected physical nodes. By linking these boundary nodes with springs to their original, rigid grid layout, the button will retain its general rectangular shape while flexing dynamically upon contact.

Let’s implement a SoftBodyButton manager that hooks into the browser’s render loop:

class SoftBodyButton {
  private canvas: SVGSVGElement;
  private path: SVGPathElement;
  private nodes: PhysicsNode[] = [];
  private springs: Spring[] = [];
  private anchorSprings: Spring[] = [];
  private width: number;
  private height: number;

  constructor(svgElement: SVGSVGElement, width: number, height: number) {
    this.canvas = svgElement;
    this.path = document.createElementNS("http://www.w3.org/2000/svg", "path");
    this.canvas.appendChild(this.path);
    this.width = width;
    this.height = height;

    this.initGeometry();
    this.startRenderLoop();
  }

  private initGeometry(): void {
    const segments = 8;
    // Create boundary nodes along the perimeter of the button
    for (let i = 0; i < segments; i++) {
      const angle = (i / segments) * Math.PI * 2;
      const x = this.width / 2 + Math.cos(angle) * (this.width / 2);
      const y = this.height / 2 + Math.sin(angle) * (this.height / 2);
      
      const node = new PhysicsNode(x, y);
      this.nodes.push(node);
    }

    // Connect adjacent nodes to form the outer elastic perimeter
    for (let i = 0; i < this.nodes.length; i++) {
      const nextNode = this.nodes[(i + 1) % this.nodes.length];
      this.springs.push(new Spring(this.nodes[i], nextNode, 0.15));
    }
  }

  public triggerImpulse(mouseX: number, mouseY: number, force: number): void {
    this.nodes.forEach(node => {
      const dx = node.x - mouseX;
      const dy = node.y - mouseY;
      const dist = Math.sqrt(dx * dx + dy * dy);
      if (dist < 100) {
        const influence = (100 - dist) / 100;
        node.applyForce(dx * influence * force, dy * influence * force);
      }
    });
  }

  private updatePhysics(): void {
    const dt = 1.0; // Normalized delta time step
    const friction = 0.92; // Emulates air resistance

    // Apply restorative anchoring forces back to original coordinates
    this.nodes.forEach(node => {
      const anchorForceX = (node.originalX - node.x) * 0.05;
      const anchorForceY = (node.originalY - node.y) * 0.05;
      node.applyForce(anchorForceX, anchorForceY);
      node.update(dt, friction);
    });

    // Resolve constraints multiple times per frame for structural stability
    for (let iteration = 0; iteration < 4; iteration++) {
      this.springs.forEach(spring => spring.resolve());
    }
  }

  private render(): void {
    this.updatePhysics();

    // Generate a smooth cubic bezier path through the physical nodes
    let d = `M ${this.nodes[0].x} ${this.nodes[0].y}`;
    for (let i = 0; i < this.nodes.length; i++) {
      const curr = this.nodes[i];
      const next = this.nodes[(i + 1) % this.nodes.length];
      const xc = (curr.x + next.x) / 2;
      const yc = (curr.y + next.y) / 2;
      d += ` Q ${curr.x} ${curr.y}, ${xc} ${yc}`;
    }
    d += " Z";

    this.path.setAttribute("d", d);
  }

  private startRenderLoop(): void {
    const tick = () => {
      this.render();
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
  }
}

Optimizing for 60 FPS: Eliminating Layout Thrashing

Running physical simulations inside a browser can quickly exhaust CPU resources if implemented carelessly. The primary bottleneck is not the mathematical computations, but Layout Thrashing.

Layout thrashing occurs when JavaScript writes a style/attribute to a DOM element, and then immediately reads a geometric property (like getBoundingClientRect()). This forces the browser to recalculate the visual layout of the entire page on the fly.

To keep our physics engine running at a flawless 60 FPS:

  1. Batch DOM Reads: Read element dimensions and mouse coordinates once during initialization or inside event listeners. Store these values as raw numbers in local memory. Never query layout properties inside the requestAnimationFrame render loop.
  2. Leverage SVG Path Buffering: Instead of mutating individual HTML elements, we mutate a single SVG path. By using quadratic bezier curves (Q commands), we minimize the complexity of the path generation string.
  3. Isolate Layer Painting: Force GPU acceleration on the SVG overlay container using the CSS property will-change: transform. This isolates the rendering layer, preventing the browser from repainting the entire document whenever the button flexes.

Conclusion: The Future of Interactive UX

Moving beyond rigid, declarative animations allows us to create web interfaces that feel tangible, responsive, and alive. By deploying Verlet-integrated spring systems directly to the DOM, user interactions are no longer treated as simple trigger events—they become physical forces that deform, ripple, and bounce through the interface. This organic response mimics the real world, drastically increasing the perceived quality and delightful nature of modern application interfaces.

#Frontend#UI Design#JavaScript#Web Performance