interface PhysicsBody { el: HTMLElement; x: number; y: number; vx: number; vy: number; width: number; height: number; originX: number; originY: number; isDragging: boolean; startX: number; startY: number; } export class GravityEngine { private bodies: PhysicsBody[] = []; private dirtyElements: Array<{ el: HTMLElement; originalCssText: string }> = []; private animationFrameId: number | null = null; private isRunning = false; INITIAL_FORCE = 10; start() { if (this.isRunning) return; this.isRunning = true; this.dirtyElements = []; const MAX_DEPTH = 3; const containers = document.querySelectorAll(".gravity-body"); const leafElements: HTMLElement[] = []; const intermediates: { el: HTMLElement; w: number; h: number }[] = []; // READ-ONLY traversal: classify all elements and snapshot sizes. // No DOM writes here — one layout flush for the entire traversal. const collectElements = (el: HTMLElement, depth: number) => { // Skip purely decorative elements (e.g. background images). if (el.classList.contains("pointer-events-none")) return; const isSolid = ["SVG", "IMG", "BUTTON", "IFRAME", "A"].includes( el.tagName.toUpperCase(), ); // Treat as a leaf if solid, childless, or at the depth cap. if (isSolid || el.children.length === 0 || depth >= MAX_DEPTH) { if (el.offsetWidth > 0 && el.offsetHeight > 0) { leafElements.push(el); } return; } intermediates.push({ el, w: el.offsetWidth, h: el.offsetHeight }); Array.from(el.children).forEach((child) => collectElements(child as HTMLElement, depth + 1), ); }; const rootSizes: { el: HTMLElement; w: number; h: number }[] = []; containers.forEach((container) => { const htmlContainer = container as HTMLElement; rootSizes.push({ el: htmlContainer, w: htmlContainer.offsetWidth, h: htmlContainer.offsetHeight, }); Array.from(htmlContainer.children).forEach((child) => collectElements(child as HTMLElement, 0), ); }); // WRITE PASS 1: size-lock roots and intermediates so they don't collapse // when their children become position:fixed. [...rootSizes, ...intermediates].forEach(({ el, w, h }) => { this.dirtyElements.push({ el, originalCssText: el.style.cssText }); el.style.width = `${w}px`; el.style.height = `${h}px`; }); // READ PASS 2: batch all getBoundingClientRect calls before any leaf writes. const snapshots = leafElements.map((el) => ({ el, rect: el.getBoundingClientRect(), })); // WRITE PASS 2: apply fixed positioning to leaves using snapshotted rects. this.bodies = snapshots.map(({ el, rect }) => { this.dirtyElements.push({ el, originalCssText: el.style.cssText }); el.style.width = `${rect.width}px`; el.style.height = `${rect.height}px`; el.style.margin = "0px"; el.style.position = "fixed"; el.style.left = "0px"; el.style.top = "0px"; el.style.transform = `translate(${rect.left}px, ${rect.top}px)`; const body: PhysicsBody = { el, x: rect.left, y: rect.top, vx: (Math.random() - 0.5) * 8, vy: (Math.random() - 0.5) * 5, width: rect.width, height: rect.height, originX: rect.left, originY: rect.top, isDragging: false, startX: 0, startY: 0, }; this.attachMouseEvents(body); return body; }); this.tick(); } stop() { this.isRunning = false; if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = null; } const DURATION = 600; // Glide each body back to its origin position. this.bodies.forEach((body) => { body.el.style.transition = `transform ${DURATION}ms cubic-bezier(0.22, 1, 0.36, 1)`; body.el.style.transform = `translate(${body.originX}px, ${body.originY}px)`; }); // After the transition completes, restore every element's original inline // styles so document flow is fully recovered — including elements that had // pre-existing inline styles we must not erase (e.g. Next.js fill images). const elementsToClear = [...this.dirtyElements]; this.bodies = []; this.dirtyElements = []; setTimeout(() => { elementsToClear.forEach(({ el, originalCssText }) => { el.style.cssText = originalCssText; }); }, DURATION); } private attachMouseEvents(body: PhysicsBody) { // Prevent default drag behaviors that interfere with physics body.el.ondragstart = () => false; body.el.addEventListener("pointerdown", (e) => { body.isDragging = true; body.startX = e.clientX; body.startY = e.clientY; body.vx = 0; body.vy = 0; body.el.setPointerCapture(e.pointerId); }); body.el.addEventListener("pointermove", (e) => { if (!body.isDragging) return; // Calculate velocity based on mouse movement for the "throw" body.vx = e.movementX * 0.5; body.vy = e.movementY * 0.5; body.x += e.movementX; body.y += e.movementY; }); body.el.addEventListener("pointerup", (e) => { body.isDragging = false; body.el.releasePointerCapture(e.pointerId); // The Drag vs. Click Resolver const deltaX = Math.abs(e.clientX - body.startX); const deltaY = Math.abs(e.clientY - body.startY); // If the user moved the mouse less than 5px, treat it as a click if (deltaX > 5 || deltaY > 5) { // It was a drag. Prevent links from firing. e.preventDefault(); e.stopPropagation(); } }); // Catch clicks at the capture phase to stop them if we were dragging body.el.addEventListener( "click", (e) => { const deltaX = Math.abs(e.clientX - body.startX); const deltaY = Math.abs(e.clientY - body.startY); if (deltaX > 5 || deltaY > 5) { e.preventDefault(); e.stopPropagation(); } }, true, ); } private tick = () => { if (!this.isRunning) return; const gravity = 0.5; const bounce = -0.7; const floorY = window.innerHeight; this.bodies.forEach((body) => { if (body.isDragging) { body.el.style.transform = `translate(${body.x}px, ${body.y}px)`; return; } body.vy += gravity; body.x += body.vx; body.y += body.vy; body.vx *= 0.99; body.vy *= 0.99; if (body.y + body.height > floorY) { body.y = floorY - body.height; body.vy *= bounce; body.vx *= 0.9; } body.el.style.transform = `translate(${body.x}px, ${body.y}px)`; }); this.animationFrameId = requestAnimationFrame(this.tick); }; }