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:
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.
// 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:
// 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:
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:
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:
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:
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:
// 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:
// ❌ 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:
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:
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.
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