beta

Physics Stack — <mv-physics-stack>

A list whose items are rigid bodies stacked in a box: grab a card by a corner (it pivots around the grab point), toss it, the pile falls back under gravity and the DOM order follows the visual order. Home-grown physics engine (oriented boxes, friction, bounce), no dependencies.

CategoryMicro-interactions
TypeWeb Component (<mv-physics-stack>)
Statusbeta
Keywordsphysics, rigid-body, todo, list, reorder, drag, toss, gravity, tactile

When to use

Avoid when

Install

node scripts/add.mjs physics-stack --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["physics-stack"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, core/physics.js, components/physics-stack/physics-stack.js, components/physics-stack/physics-stack.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div style="display:grid;gap:.9rem;width:100%;max-width:420px">
  <div style="display:flex;align-items:baseline;justify-content:space-between;gap:1rem">
    <strong style="font-size:.95rem">My week</strong>
    <span id="mv-physics-stack-demo-count" style="font-size:.8rem;color:var(--mv-fg-muted)">5 tasks</span>
  </div>
  <mv-physics-stack id="mv-physics-stack-demo" label="This week’s tasks" style="--mv-physics-stack-height:26rem">
    <div class="mv-psd-card"><span class="mv-psd-dot" style="--c:var(--mv-danger)"></span><span><strong>Review the Carter contract</strong><small>Today · Legal</small></span><button type="button" class="mv-psd-check" aria-label="Complete: Review the Carter contract"></button></div>
    <div class="mv-psd-card"><span class="mv-psd-dot" style="--c:var(--mv-warning)"></span><span><strong>Call the bank</strong><small>Tuesday · Finance</small></span><button type="button" class="mv-psd-check" aria-label="Complete: Call the bank"></button></div>
    <div class="mv-psd-card"><span class="mv-psd-dot" style="--c:var(--mv-info)"></span><span><strong>Prep the client demo</strong><small>Wednesday · Product</small></span><button type="button" class="mv-psd-check" aria-label="Complete: Prep the client demo"></button></div>
    <div class="mv-psd-card"><span class="mv-psd-dot" style="--c:var(--mv-success)"></span><span><strong>Book the train to Boston</strong><small>Thursday · Travel</small></span><button type="button" class="mv-psd-check" aria-label="Complete: Book the train to Boston"></button></div>
    <div class="mv-psd-card"><span class="mv-psd-dot" style="--c:var(--mv-accent)"></span><span><strong>Water the plants</strong><small>Friday · Home</small></span><button type="button" class="mv-psd-check" aria-label="Complete: Water the plants"></button></div>
  </mv-physics-stack>
  <div style="display:flex;gap:.5rem;flex-wrap:wrap">
    <button class="mv-button" data-size="sm" id="mv-physics-stack-demo-add">Add a task</button>
    <button class="mv-button" data-size="sm" data-variant="outline" id="mv-physics-stack-demo-tidy">Tidy up</button>
    <button class="mv-button" data-size="sm" data-variant="ghost" id="mv-physics-stack-demo-shake">Shake</button>
  </div>
  <p style="margin:0;font-size:.78rem;color:var(--mv-fg-subtle)">Grab a card by a corner and toss it. Keyboard: Alt + ↑/↓.</p>
</div>
<style>
  .mv-psd-card { display: flex; align-items: center; gap: .7rem; }
  .mv-psd-card > span:nth-child(2) { display: grid; flex: 1; min-width: 0; }
  .mv-psd-card strong { font-weight: 600; font-size: .86rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .mv-psd-card small { color: var(--mv-fg-muted); font-size: .74rem; }
  .mv-psd-dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--c); flex: none; box-shadow: 0 0 0 3px color-mix(in oklch, var(--c) 20%, transparent); }
  .mv-psd-check { flex: none; width: 1.35rem; height: 1.35rem; border-radius: 50%; border: 1.5px solid var(--mv-border-strong); background: transparent; cursor: pointer; display: grid; place-items: center; transition: background var(--mv-duration-fast), border-color var(--mv-duration-fast); }
  .mv-psd-check::after { content: ""; width: .5rem; height: .28rem; border: solid var(--mv-fg-on-accent); border-width: 0 0 2px 2px; rotate: -45deg; translate: 0 -1px; opacity: 0; }
  .mv-psd-check:hover { border-color: var(--mv-success); }
  .mv-psd-check:hover::after { opacity: 1; border-color: var(--mv-success); }
  .mv-psd-check[aria-pressed="true"] { background: var(--mv-success); border-color: var(--mv-success); }
  .mv-psd-check[aria-pressed="true"]::after { opacity: 1; border-color: var(--mv-fg-on-accent); }
  .mv-psd-check:focus-visible { outline: none; box-shadow: var(--mv-focus-ring); }
</style>
<script type="module">
  const stack = document.getElementById("mv-physics-stack-demo");
  const count = document.getElementById("mv-physics-stack-demo-count");
  const ideas = [
    ["Send invoice #2041", "Monday · Finance", "var(--mv-warning)"],
    ["Update the schedule", "Tuesday · Team", "var(--mv-info)"],
    ["Call the plumber back", "Wednesday · Home", "var(--mv-accent)"],
    ["Approve the mockups", "Thursday · Design", "var(--mv-success)"],
  ];
  let n = 0;
  const sync = () => { const k = stack.querySelectorAll(".mv-physics-stack-item:not([data-leaving])").length; count.textContent = k + (k === 1 ? " task" : " tasks"); };
  stack.addEventListener("click", (e) => {
    const btn = e.target.closest(".mv-psd-check");
    if (!btn) return;
    btn.setAttribute("aria-pressed", "true");
    const card = btn.closest(".mv-physics-stack-item");
    setTimeout(() => { stack.toss(card); sync(); }, 180);
  });
  document.getElementById("mv-physics-stack-demo-add").addEventListener("click", () => {
    const [title, meta, color] = ideas[n++ % ideas.length];
    const card = document.createElement("div");
    card.className = "mv-psd-card";
    const dot = document.createElement("span"); dot.className = "mv-psd-dot"; dot.style.setProperty("--c", color);
    const text = document.createElement("span");
    const strong = document.createElement("strong"); strong.textContent = title;
    const small = document.createElement("small"); small.textContent = meta;
    text.append(strong, small);
    const check = document.createElement("button"); check.type = "button"; check.className = "mv-psd-check"; check.setAttribute("aria-label", "Complete: " + title);
    card.append(dot, text, check);
    stack.prepend(card);
    queueMicrotask(sync);
  });
  document.getElementById("mv-physics-stack-demo-tidy").addEventListener("click", () => stack.arrange());
  document.getElementById("mv-physics-stack-demo-shake").addEventListener("click", () => stack.shake());
  stack.addEventListener("mv-toss", () => setTimeout(sync, 0));
</script>

API

Attributes

NameTypeDefaultDescription
labelstringAccessible name of the list.
gravitynumber1Gravity multiplier (1 ≈ 2400 px/s²).
frictionnumber0.6Friction between cards (0 = soap, 1 = rubber).
bouncenumber0.12Bounce on impact (0–1).
gapnumber6Space between cards when the pile is tidied (px).

Properties

NameTypeDescription
itemsElement[]List items, in DOM order (= visual order once settled).

Methods

NameDescription
arrange()Tidies the pile into a straight tower, in DOM order (smooth transition).
toss(item)Spins the item out of the box, then removes it from the DOM; the cards above fall into the gap.
shake(strength = 1)Gives every card a shake.

Events

NameDescription
mv-reorderdetail: { order, item } — the visual order changed (toss, Alt + arrows).
mv-settledetail: { order } — the pile is at rest (the animation loop stops).
mv-tossdetail: { item } — an item was tossed out by toss().

Content structure

NameDescription
(children)Each direct child becomes a card (mv-physics-stack-item class, role listitem). Adding or removing children is picked up: a new child drops in from the top.

CSS classes

NameDescription
mv-physics-stack-itemSet on each child; default card style, overridable.

CSS variables

NameDefaultDescription
--mv-physics-stack-height22remBox height.
--mv-physics-stack-item-widthmin(19rem, 88%)Card width.
--mv-physics-stack-bgvar(--mv-bg-subtle)Box background.

Accessibility

role=list / listitem, each card is focusable. ↑/↓ move focus, Alt + ↑/↓ move the card (announced in a live region). The DOM is reordered to match the visual order and focus is preserved. Inner controls (buttons, links, fields) stay clickable and don’t start a grab. Reduced motion: the pile settles instantly, without animation. Without JS: a regular vertical list.