The Physics Behind Those Floating Category Pills

Deep dive into Neorgon's physics-simulated search category pills: spring physics, collision detection, and performance optimization in vanilla JavaScript.

Why Physics Simulation?

When we built the search interface for Neorgon, we wanted categories that felt alive—not static buttons, but objects with weight and momentum. The result: 10 floating pills that orbit, collide, and settle into place using real physics principles.

💡 The Design Goal

Make search categories feel like planets in orbit—distinct, interactive, and responsive to user input with organic, natural motion.

The Physics System Architecture

The simulation runs on a 60fps game loop using requestAnimationFrame. Each pill is an object with:

Core Physics Properties
var pill = {
  x: 150,        // Current position X
  y: 100,        // Current position Y
  vx: 0.5,       // Velocity X (pixels/frame)
  vy: -0.3,      // Velocity Y (pixels/frame)
  homeX: 150,    // Rest position X
  homeY: 100,    // Rest position Y
  mass: 1.0      // Mass (affects collision response)
};

1. Spring Physics (The Home Force)

Each pill is attached to its "home" position with an invisible spring. When displaced, it wants to return home.

Spring Force Calculation
// Hooke's Law: F = -k * x
var dx = pill.homeX - pill.x;
var dy = pill.homeY - pill.y;
var k = 0.008; // Spring constant (stiffness)

pill.vx += dx * k;
pill.vy += dy * k;

Making it feel natural: The spring constant k = 0.008 is deliberately soft. We want drift, not snap-back. This creates that lazy, orbital feeling.

2. Orbital Drift (Fake Orbital Mechanics)

To prevent pills from just sitting still, we add layered sine wave motion:

Double-layered Orbital Drift
// Two sine waves at different frequencies
time1 += 0.012 + (i % 3) * 0.003;
time2 += 0.007 + (i % 4) * 0.002;

pill.vx += Math.sin(time1) * 0.04;
pill.vy += Math.cos(time1 * 0.7) * 0.03;
pill.vx += Math.cos(time2) * 0.015;
pill.vy += Math.sin(time2 * 1.3) * 0.012;

The result: Each pill follows an unpredictable but smooth path, like a planet perturbed by other gravitational bodies.

3. Collision Detection and Response

When pills get too close, they repel each other with a soft force:

Soft Repulsion Algorithm
pills.forEach(function(other) {
  if (other === pill) return;

  var dx = pill.x - other.x;
  var dy = pill.y - other.y;
  var dist = Math.sqrt(dx * dx + dy * dy);
  var minDist = 85; // Minimum separation

  if (dist < minDist) {
    var t = (minDist - dist) / minDist;
    var force = t * t * 0.2; // Quadratic falloff

    pill.vx += (dx / dist) * force;
    pill.vy += (dy / dist) * force;
  }
});

The quadratic falloff (t * t): Pills only push hard when very close. This prevents the system from exploding while maintaining personal space.

4. Damping (Energy Loss)

Without damping, pills would oscillate forever. We apply velocity decay each frame:

Velocity Damping
var damp = 0.96; // 4% energy loss per frame
pill.vx *= damp;
pill.vy *= damp;

This creates the "settling" effect. Pills eventually calm down unless disturbed.

5. Soft Bounds (Container Walls)

Instead of hard walls that cause jarring bounces, we use gentle nudges:

Soft Boundary Force
if (pill.x < 20) {
  pill.vx += (20 - pill.x) * 0.05; // Gentle pull
}
if (pill.x > W - 20) {
  pill.vx += (W - 20 - pill.x) * 0.05;
}

Search Interaction: Planet Zoom

When you click a category pill, it "zooms" like a planet approaching:

Animated Scale via Physics
pill._zoomScale = 1;

function animateZoom(now) {
  var t = Math.min(1, (now - start) / 450);
  pill._zoomScale = 1 + 0.4 * Math.sin(t * Math.PI);

  if (t < 1) requestAnimationFrame(animateZoom);
  else pill._zoomScale = 1;
}

Applied during render: transform: translate(...) scale(pill._zoomScale)

Performance Optimization

1. Spatial Partitioning (Fake)

Instead of checking every pill against every other pill (O(n²)), we only check when close:

Distance Culling
// Only check if within bounding box
if (Math.abs(pill.x - other.x) > 150) return;
if (Math.abs(pill.y - other.y) > 150) return;

// Then do expensive sqrt distance check
var dist = Math.sqrt(dx * dx + dy * dy);

2. GPU-Accelerated Transforms

Never modify left or top. Always use transform:

GPU Compositing
// ❌ BAD - triggers layout
pill.el.style.left = pill.x + 'px';
pill.el.style.top = pill.y + 'px';

// ✅ GOOD - GPU composited
pill.el.style.transform =
  `translate(${pill.x}px, ${pill.y}px)`;

3. Fixed Timestep with Interpolation

Physics runs at consistent speed regardless of frame rate:

Delta Time Normalization
function tick(timestamp) {
  var deltaTime = timestamp - lastTime;
  lastTime = timestamp;

  // Normalize forces for 60fps
  var dtFactor = Math.min(deltaTime / 16.67, 2);

  pills.forEach(function(pill) {
    pill.vx += forceX * dtFactor;
    pill.vy += forceY * dtFactor;
  });

  requestAnimationFrame(tick);
}

Filtering State: Matching vs Non-Matching

When searching, matched pills attract to center while others drift to periphery:

Dual Behavior System
if (pill.matched) {
  // Attract to center
  var dx = centerX - pill.x;
  var dy = centerY - pill.y;
  pill.vx += dx * 0.003;
  pill.vy += dy * 0.003;
} else {
  // Assign to orbital position
  if (!pill._orbitX) {
    var angle = (i / pills.length) * Math.PI * 2;
    pill._orbitX = centerX + Math.cos(angle) * 200;
    pill._orbitY = centerY + Math.sin(angle) * 200;
  }

  var dx = pill._orbitX - pill.x;
  var dy = pill._orbitY - pill.y;
  pill.vx += dx * 0.008;
  pill.vy += dy * 0.008;
}

What Makes It Feel "Right"

After weeks of tweaking, we discovered the magic formula:

🎯 The Physics Sweet Spot

  • Spring constant (0.008): Weak enough to drift, strong enough to return
  • Damping (0.96): Loses energy slowly, maintains liveliness
  • Repulsion (0.2): One-fifth of spring force, maintains spacing without fighting
  • Drift amplitude (0.04): Just enough to see motion, not enough to distract

Lessons Learned

1. Physics is Subjective

"Correct" physics feels wrong. Real springs overshoot and oscillate. Our springs have magical damping that feels natural in UI.

2. Performance is Design

The most beautiful animation is useless if it drops frames. We built performance constraints first, then designed within them.

3. Defaults Matter

The initial positions are randomly distributed but constrained. We generate 10 layouts and pick the most balanced one.

Smart Initialization
function randomPositions(count, minDist) {
  var attempts = 0;
  do {
    var positions = generateRandom(count);
    var minDistance = computeMinDistance(positions);
    attempts++;
  } while (minDistance < minDist && attempts < 200);

  return positions; // Best we could find
}

Alternative: When to Skip Physics

Physics simulation is overkill for many UIs. Consider these simpler alternatives:

🤔 When Physics is Worth It

Use physics when:

  • You have many interactive elements (10+ categories)
  • Users frequently filter/rearrange items
  • You want emergent, unpredictable behavior

Skip physics when:

  • You have static categories
  • Performance is critical (mobile)
  • You need precise control