liten

Gravity Stack

Chips drop in from above with real rigid-body physics, gravity, tumbling, bounce, contact friction, and settle into a pile you can grab and toss.

A physics stage for label chips, tags, and badges. Each child is a real rigid body: it has mass, rotational inertia, restitution, and friction, so a chip that lands off-center genuinely tips over and slides down the pile. There is no physics library, the impulse solver is written into the single file. The drop waits until the stage scrolls into view, and every chip is grabbable: drag one around and it carries its momentum when you let go. Built with Motion.

Installation

Complete the shared Setup first, then add Motion.

Terminal
bun add motion

Copy the component into components/ui/gravity-stack.tsx.

components/ui/gravity-stack.tsx
'use client';import * as React from 'react';import { useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';function seeded(n: number) {  const x = Math.sin(n * 12.9898) * 43758.5453;  return x - Math.floor(x);}const SUBSTEP = 1 / 120;const ITERATIONS = 2;const SLOP = 0.5;const CORRECT = 0.8;const FRICTION = 0.4;const BOUNCE_MIN_SPEED = 90;const WAKE_IMPULSE = 26;const SLEEP_LIN = 7;const SLEEP_ANG = 0.14;const SLEEP_AFTER = 0.55;const MAX_SPEED = 3600;const DRAG_STIFFNESS = 26;const GRAB_SCALE = 0.97;type Body = {  el: HTMLDivElement;  w: number;  h: number;  r: number;  offs: number[];  x: number;  y: number;  a: number;  vx: number;  vy: number;  va: number;  invM: number;  invI: number;  dropAt: number;  active: boolean;  sleeping: boolean;  sleepTime: number;  dragging: boolean;  gx: number;  gy: number;  tx: number;  ty: number;  scale: number;};export type GravityStackProps = {  children: React.ReactNode;  gravity?: number;  bounce?: number;  stagger?: number;  interactive?: boolean;  className?: string;};export function GravityStack({  children,  gravity = 2600,  bounce = 0.18,  stagger = 150,  interactive = true,  className,}: GravityStackProps) {  const reduce = useReducedMotion();  const containerRef = React.useRef<HTMLDivElement>(null);  const chipRefs = React.useRef<(HTMLDivElement | null)[]>([]);  const items = React.Children.toArray(children);  const count = items.length;  React.useEffect(() => {    if (reduce) return;    const container = containerRef.current;    if (!container) return;    const els = chipRefs.current.slice(0, count).filter(Boolean) as HTMLDivElement[];    if (!els.length) return;    let bounds = { w: container.clientWidth, h: container.clientHeight };    let zTop = 10;    const bodies: Body[] = els.map((el, i) => {      const w = Math.max(el.offsetWidth, 8);      const h = Math.max(el.offsetHeight, 8);      const r = h / 2;      const n = Math.min(4, Math.max(1, Math.round(w / h)));      const span = Math.max(0, w / 2 - r);      const offs =        n === 1          ? [0]          : Array.from({ length: n }, (_, k) => -span + (2 * span * k) / (n - 1));      const m = (w * h) / 1800;      const u = (i * 0.618034 + (seeded(i + 1) - 0.5) * 0.2 + 1) % 1;      return {        el,        w,        h,        r,        offs,        x: Math.min(Math.max(w / 2 + 8 + u * Math.max(bounds.w - w - 16, 0), w / 2), Math.max(bounds.w - w / 2, w / 2)),        y: -h / 2 - 24 - seeded(i * 3 + 2) * 120,        a: (seeded(i * 5 + 3) - 0.5) * 0.5,        vx: (seeded(i * 7 + 4) - 0.5) * 80,        vy: 0,        va: (seeded(i * 9 + 5) - 0.5) * 2.4,        invM: 1 / m,        invI: 12 / (m * (w * w + h * h)),        dropAt: (i * stagger) / 1000 + seeded(i * 11 + 6) * 0.12,        active: false,        sleeping: false,        sleepTime: 0,        dragging: false,        gx: 0,        gy: 0,        tx: 0,        ty: 0,        scale: 1,      };    });    const paint = (b: Body) => {      b.el.style.transform = `translate3d(${b.x - b.w / 2}px, ${b.y - b.h / 2}px, 0) rotate(${b.a}rad) scale(${b.scale})`;    };    for (const b of bodies) {      b.el.style.visibility = 'visible';      paint(b);    }    const wake = (b: Body) => {      b.sleeping = false;      b.sleepTime = 0;    };    const solve = (      A: Body,      B: Body | null,      px: number,      py: number,      nx: number,      ny: number,      pen: number,    ) => {      const invMA = A.dragging ? 0 : A.invM;      const invIA = A.dragging ? 0 : A.invI;      const invMB = B ? (B.dragging ? 0 : B.invM) : 0;      const invIB = B ? (B.dragging ? 0 : B.invI) : 0;      const invSum = invMA + invMB;      if (invSum === 0) return;      const corr = (Math.max(pen - SLOP, 0) * CORRECT) / invSum;      A.x -= nx * corr * invMA;      A.y -= ny * corr * invMA;      if (B) {        B.x += nx * corr * invMB;        B.y += ny * corr * invMB;      }      const raX = px - A.x;      const raY = py - A.y;      const rbX = B ? px - B.x : 0;      const rbY = B ? py - B.y : 0;      const rvx = (B ? B.vx - B.va * rbY : 0) - (A.vx - A.va * raY);      const rvy = (B ? B.vy + B.va * rbX : 0) - (A.vy + A.va * raX);      const vn = rvx * nx + rvy * ny;      if (vn >= 0) return;      const crossA = raX * ny - raY * nx;      const crossB = rbX * ny - rbY * nx;      const denom = invSum + crossA * crossA * invIA + crossB * crossB * invIB;      if (denom === 0) return;      const e = -vn > BOUNCE_MIN_SPEED ? bounce : 0;      const j = (-(1 + e) * vn) / denom;      A.vx -= j * nx * invMA;      A.vy -= j * ny * invMA;      A.va -= crossA * j * invIA;      if (B) {        B.vx += j * nx * invMB;        B.vy += j * ny * invMB;        B.va += crossB * j * invIB;      }      const tx = -ny;      const ty = nx;      const vt = rvx * tx + rvy * ty;      const crossAt = raX * ty - raY * tx;      const crossBt = rbX * ty - rbY * tx;      const denomT = invSum + crossAt * crossAt * invIA + crossBt * crossBt * invIB;      if (denomT > 0) {        const maxF = Math.abs(j) * FRICTION;        const jt = Math.max(-maxF, Math.min(maxF, -vt / denomT));        A.vx -= jt * tx * invMA;        A.vy -= jt * ty * invMA;        A.va -= crossAt * jt * invIA;        if (B) {          B.vx += jt * tx * invMB;          B.vy += jt * ty * invMB;          B.va += crossBt * jt * invIB;        }      }      if (Math.abs(j) > WAKE_IMPULSE) {        wake(A);        if (B) wake(B);      }    };    const collide = () => {      for (const A of bodies) {        if (!A.active) continue;        const ca = Math.cos(A.a);        const sa = Math.sin(A.a);        for (const off of A.offs) {          const px = A.x + ca * off;          const py = A.y + sa * off;          const penF = py + A.r - bounds.h;          if (penF > 0) solve(A, null, px, py + A.r, 0, 1, penF);          const penL = A.r - px;          if (penL > 0) solve(A, null, px - A.r, py, -1, 0, penL);          const penR = px + A.r - bounds.w;          if (penR > 0) solve(A, null, px + A.r, py, 1, 0, penR);        }      }      for (let i = 0; i < bodies.length; i++) {        const A = bodies[i];        if (!A.active) continue;        for (let j = i + 1; j < bodies.length; j++) {          const B = bodies[j];          if (!B.active || (A.sleeping && B.sleeping)) continue;          const reach = A.w / 2 + B.w / 2;          const dx0 = B.x - A.x;          const dy0 = B.y - A.y;          if (dx0 * dx0 + dy0 * dy0 > reach * reach) continue;          const ca = Math.cos(A.a);          const sa = Math.sin(A.a);          const cb = Math.cos(B.a);          const sb = Math.sin(B.a);          for (const oa of A.offs) {            const ax = A.x + ca * oa;            const ay = A.y + sa * oa;            for (const ob of B.offs) {              const bx = B.x + cb * ob;              const by = B.y + sb * ob;              const dx = bx - ax;              const dy = by - ay;              const rr = A.r + B.r;              const d2 = dx * dx + dy * dy;              if (d2 >= rr * rr || d2 === 0) continue;              const d = Math.sqrt(d2);              const nx = dx / d;              const ny = dy / d;              const pen = rr - d;              solve(A, B, ax + nx * (A.r - pen / 2), ay + ny * (A.r - pen / 2), nx, ny, pen);            }          }        }      }    };    let clock = 0;    const step = (dt: number) => {      for (const b of bodies) {        if (!b.active && clock >= b.dropAt) b.active = true;        if (!b.active) continue;        b.scale += ((b.dragging ? GRAB_SCALE : 1) - b.scale) * Math.min(1, dt * 14);        if (b.sleeping) {          b.vx = 0;          b.vy = 0;          b.va = 0;          continue;        }        if (b.dragging) {          b.vx = (b.tx - b.x) * DRAG_STIFFNESS;          b.vy = (b.ty - b.y) * DRAG_STIFFNESS;          b.va *= Math.exp(-6 * dt);        } else {          b.vy += gravity * dt;          const damp = Math.exp(-0.35 * dt);          b.vx *= damp;          b.vy *= damp;          b.va *= Math.exp(-1.4 * dt);        }        const sp = Math.hypot(b.vx, b.vy);        if (sp > MAX_SPEED) {          b.vx *= MAX_SPEED / sp;          b.vy *= MAX_SPEED / sp;        }        b.x += b.vx * dt;        b.y += b.vy * dt;        b.a += b.va * dt;      }      for (let k = 0; k < ITERATIONS; k++) collide();      for (const b of bodies) {        if (!b.active || b.dragging || b.sleeping) continue;        if (Math.hypot(b.vx, b.vy) < SLEEP_LIN && Math.abs(b.va) < SLEEP_ANG) {          b.sleepTime += dt;          if (b.sleepTime > SLEEP_AFTER) {            b.sleeping = true;            b.vx = 0;            b.vy = 0;            b.va = 0;          }        } else {          b.sleepTime = 0;        }      }    };    let started = false;    const io = new IntersectionObserver(      (entries) => {        if (entries.some((entry) => entry.isIntersecting)) {          started = true;          io.disconnect();        }      },      { threshold: 0.25 },    );    io.observe(container);    let raf = 0;    let last = performance.now();    let acc = 0;    const frame = (now: number) => {      const dt = Math.min((now - last) / 1000, 0.05);      last = now;      if (started) {        acc += dt;        while (acc >= SUBSTEP) {          clock += SUBSTEP;          step(SUBSTEP);          acc -= SUBSTEP;        }        for (const b of bodies) {          if (b.active && (!b.sleeping || b.dragging)) paint(b);        }      }      raf = requestAnimationFrame(frame);    };    raf = requestAnimationFrame(frame);    const ro = new ResizeObserver(() => {      bounds = { w: container.clientWidth, h: container.clientHeight };      for (const b of bodies) {        if (!b.active) continue;        if (b.x > bounds.w - b.r) {          b.x = bounds.w - b.r;          wake(b);        }        if (b.x < b.r) {          b.x = b.r;          wake(b);        }        if (b.y > bounds.h - b.r) {          b.y = bounds.h - b.r;          wake(b);        }        paint(b);      }    });    ro.observe(container);    const findBody = (el: EventTarget | null) => bodies.find((b) => b.el === el);    const pointerAt = (e: PointerEvent) => {      const rect = container.getBoundingClientRect();      return { x: e.clientX - rect.left, y: e.clientY - rect.top };    };    const onDown = (e: PointerEvent) => {      const b = findBody(e.currentTarget);      if (!b || !b.active) return;      e.preventDefault();      b.el.setPointerCapture(e.pointerId);      const p = pointerAt(e);      b.dragging = true;      wake(b);      b.gx = b.x - p.x;      b.gy = b.y - p.y;      b.tx = b.x;      b.ty = b.y;      b.el.style.zIndex = String(++zTop);    };    const onMove = (e: PointerEvent) => {      const b = findBody(e.currentTarget);      if (!b || !b.dragging) return;      const p = pointerAt(e);      b.tx = p.x + b.gx;      b.ty = p.y + b.gy;    };    const onUp = (e: PointerEvent) => {      const b = findBody(e.currentTarget);      if (!b || !b.dragging) return;      b.dragging = false;      wake(b);      const sp = Math.hypot(b.vx, b.vy);      if (sp > 1800) {        b.vx *= 1800 / sp;        b.vy *= 1800 / sp;      }    };    if (interactive) {      for (const el of els) {        el.addEventListener('pointerdown', onDown);        el.addEventListener('pointermove', onMove);        el.addEventListener('pointerup', onUp);        el.addEventListener('pointercancel', onUp);      }    }    return () => {      cancelAnimationFrame(raf);      io.disconnect();      ro.disconnect();      if (interactive) {        for (const el of els) {          el.removeEventListener('pointerdown', onDown);          el.removeEventListener('pointermove', onMove);          el.removeEventListener('pointerup', onUp);          el.removeEventListener('pointercancel', onUp);        }      }    };  }, [count, gravity, bounce, stagger, interactive, reduce]);  if (reduce) {    return (      <div        className={cn(          'relative flex flex-wrap content-end items-end justify-center gap-2 overflow-hidden p-4',          className,        )}      >        {items.map((child, i) => (          <div key={i}>{child}</div>        ))}      </div>    );  }  return (    <div ref={containerRef} className={cn('relative overflow-hidden', className)}>      {items.map((child, i) => (        <div          key={i}          ref={(el) => {            chipRefs.current[i] = el;          }}          style={{ visibility: 'hidden', touchAction: 'none' }}          className={cn(            'absolute left-0 top-0 w-max will-change-transform',            interactive && 'cursor-grab select-none active:cursor-grabbing',          )}        >          {child}        </div>      ))}    </div>  );}

Usage

Any direct children become bodies. Size the stage with className, the floor is the bottom edge, the walls are the sides.

Example.tsx
import { GravityStack } from '@/components/ui/gravity-stack';

export default function Example() {
  return (
    <GravityStack className="h-[340px] w-full">
      <span className="rounded-full bg-amber-500/12 px-3 py-1.5 text-amber-500">Pending</span>
      <span className="rounded-full bg-emerald-500/12 px-3 py-1.5 text-emerald-500">Approved</span>
      <span className="rounded-full bg-red-500/12 px-3 py-1.5 text-red-500">Cancelled</span>
    </GravityStack>
  );
}

Examples

The same stage with neutral chips from the elevation ladder, the depth comes from the top-lit raised-chip recipe instead of a tint.

Props

PropTypeDefaultDescription
childrenReact.ReactNode-The objects that fall. Each child is one rigid body.
gravitynumber2600Downward acceleration in px/s squared.
bouncenumber0.18Restitution 0..1, how much energy survives a bounce.
staggernumber150Milliseconds between releases.
interactivebooleantrueAllow grabbing and tossing chips with the pointer.
classNamestring-Forwarded to the stage. Set the height here.
On this page0%