Exclusivebeta

Twin Drop <mv-twin-drop>

Linked drop zones that move items across a page in one gesture: two <mv-twin-drop> zones are paired, and any [data-twin-drop-item] dragged into one slides into its opening, then comes out of its twin somewhere else on the page, flying out along the twin's facing with a speed proportional to the speed it went in with (clamped), decelerating and settling into the twin's target container, or gliding and bouncing to a stop on a free canvas when there is no target. While you drag, drop zones open wider as the item approaches and pull it in magnetically, and the twin answers at half strength so you see where it will come out. Items are real DOM nodes that are moved (never cloned), announced to screen readers and fully keyboard-operable (Space to pick up, P or Enter on a drop zone to send).

CategoryTransitions
TypeWeb Component (<mv-twin-drop>)
Statusbeta
Keywordsexclusive, culture, drag-and-drop, drop-zone, dnd, move, transfer, momentum, velocity, physics, flip, linked, teleport, triage, board, canvas, keyboard-dnd

When to use

  • Two lists that belong together sit far apart on a page or dashboard and dragging across the whole screen is tiring
  • A triage board needs a quick way to file items to a distant Archive, Done or Trash zone
  • A free canvas or whiteboard needs a playful but precise way to throw notes from one edge to another
  • A long page needs a shortcut to send an item to a section that is currently off-screen

Avoid when

  • Items are reordered within a single list or moved between adjacent columns → use Kanban instead
  • The move is destructive or irreversible and needs an explicit confirmation → use Dialog instead
  • Items are managed by a framework that re-renders from state and cannot react to DOM moves through events

Install

node scripts/add.mjs twin-drop --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["twin-drop"], 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, components/twin-drop/twin-drop.js, components/twin-drop/twin-drop.css.

Usage

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

<div id="td-demo">
  <style>
    #td-demo { display:grid; gap:1.25rem; width:100%; max-width:68rem; margin-inline:auto; font-size:.875rem }
    #td-demo .td-board { position:relative; display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); grid-template-rows:auto 1fr; gap:1.5rem 3rem; min-height:40rem; padding:1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #td-demo .td-col { display:grid; gap:.75rem; align-content:start }
    #td-demo .td-inbox { grid-column:1; grid-row:1 }
    #td-demo .td-archive { grid-column:2; grid-row:2; align-self:end }
    #td-demo .td-head { display:flex; align-items:center; gap:.5rem; margin:0 }
    #td-demo .td-head h3 { margin:0; font-size:.9375rem; font-weight:600; letter-spacing:-.01em }
    #td-demo .td-head span.td-sub { color:var(--mv-fg-subtle); font-size:.75rem; margin-inline-start:auto }
    #td-demo .td-lane { display:flex; align-items:center; gap:1.25rem }
    #td-demo .td-list { flex:1; min-width:0; display:grid; gap:.5rem; margin:0; padding:.5rem; list-style:none; min-height:4.5rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle); border:1px solid var(--mv-border); align-content:start }
    #td-demo .td-item { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:.75rem; padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-xs) }
    #td-demo .td-item:hover { border-color:var(--mv-border-strong) }
    #td-demo .td-ico { display:grid; place-items:center; width:2rem; height:2rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg-muted) }
    #td-demo .td-ico svg { width:1rem; height:1rem }
    #td-demo .td-txt { display:grid; gap:.0625rem; min-width:0 }
    #td-demo .td-txt strong { font-weight:600; font-size:.8125rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
    #td-demo .td-txt span { color:var(--mv-fg-muted); font-size:.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
    #td-demo .td-amt { font-variant-numeric:tabular-nums; font-weight:500; font-size:.8125rem }
    #td-demo .td-bar { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem }
    #td-demo .td-log { color:var(--mv-fg-muted); font-size:.75rem; margin-inline-start:auto; min-height:1.2em }
    #td-demo .td-canvas { position:relative; height:17rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:
      radial-gradient(circle at 1px 1px, color-mix(in oklch, var(--mv-fg) 9%, transparent) 1px, transparent 0) 0 0 / 20px 20px, var(--mv-surface); overflow:hidden }
    #td-demo .td-canvas-title { position:absolute; top:.875rem; left:1rem; margin:0; font-size:.8125rem; font-weight:600 }
    #td-demo .td-canvas-hint { position:absolute; bottom:.875rem; left:50%; translate:-50% 0; margin:0; color:var(--mv-fg-subtle); font-size:.75rem; white-space:nowrap; pointer-events:none }
    #td-demo .td-canvas mv-twin-drop { position:absolute; top:50%; translate:0 -50% }
    #td-demo .td-note { display:flex; align-items:center; gap:.5rem; padding:.5rem .75rem; border-radius:var(--mv-radius-full); border:1px solid var(--mv-border); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-sm); font-weight:500; font-size:.8125rem; white-space:nowrap }
    #td-demo .td-note i { width:.5rem; height:.5rem; border-radius:50%; background:var(--_dot, var(--mv-accent)) }
    @media (max-width: 720px) {
      #td-demo .td-board { grid-template-columns:1fr; }
      #td-demo .td-archive { grid-column:1; margin-top:0 }
      #td-demo mv-twin-drop { --mv-twin-drop-size:5.5rem }
      #td-demo .td-lane { gap:.75rem }
      #td-demo .td-amt, #td-demo .td-head span.td-sub { display:none }
      #td-demo .td-canvas-hint { white-space:normal; width:80%; text-align:center }
    }
  </style>

  <div class="td-board">
    <section class="td-col td-inbox" aria-labelledby="td-inbox-title">
      <div class="td-head"><h3 id="td-inbox-title">Inbox</h3><span class="mv-badge" data-variant="secondary" id="td-inbox-count">5</span><span class="td-sub">Drag a document into the drop zone</span></div>
      <div class="td-lane">
        <ul class="td-list" id="td-inbox" aria-labelledby="td-inbox-title">
          <li class="td-item" data-twin-drop-item data-twin-drop-label="Invoice #4821">
            <span class="td-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h9l3 3v15l-2.5-1.5L13 21l-2.5-1.5L8 21l-2-1.5z"/><path d="M9 9h6M9 13h6"/></svg></span>
            <span class="td-txt"><strong>Invoice #4821</strong><span>Northwind Traders · due Oct 3</span></span>
            <span class="td-amt">$2,480.00</span>
          </li>
          <li class="td-item" data-twin-drop-item data-twin-drop-label="Contract renewal, Acme Logistics">
            <span class="td-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 17c1.5-2 2.5-2 3 0s1.5 1 3-1"/></svg></span>
            <span class="td-txt"><strong>Contract renewal</strong><span>Acme Logistics · signed Sep 19</span></span>
            <span class="td-amt">$18,000.00</span>
          </li>
          <li class="td-item" data-twin-drop-item data-twin-drop-label="Expense report, Priya Raman">
            <span class="td-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M7 15h3"/></svg></span>
            <span class="td-txt"><strong>Expense report</strong><span>Priya Raman · client travel</span></span>
            <span class="td-amt">$312.40</span>
          </li>
          <li class="td-item" data-twin-drop-item data-twin-drop-label="Purchase order PO-1187">
            <span class="td-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h2l2.4 11.2a1 1 0 0 0 1 .8h8.9a1 1 0 0 0 1-.8L20 8H6"/><circle cx="9" cy="20" r="1"/><circle cx="17" cy="20" r="1"/></svg></span>
            <span class="td-txt"><strong>Purchase order PO-1187</strong><span>Halden Studio · 12 monitors</span></span>
            <span class="td-amt">$9,150.00</span>
          </li>
          <li class="td-item" data-twin-drop-item data-twin-drop-label="Refund request, Mateo Alvarez">
            <span class="td-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg></span>
            <span class="td-txt"><strong>Refund request</strong><span>Mateo Alvarez · order #58213</span></span>
            <span class="td-amt">$64.99</span>
          </li>
        </ul>
        <mv-twin-drop id="td-gate-inbox" link="td-gate-archive" facing="left" target="#td-inbox" label="Inbox"></mv-twin-drop>
      </div>
    </section>

    <section class="td-col td-archive" aria-labelledby="td-archive-title">
      <div class="td-head"><h3 id="td-archive-title">Archive</h3><span class="mv-badge" data-variant="secondary" id="td-archive-count">1</span><span class="td-sub">Filed documents</span></div>
      <div class="td-lane">
        <mv-twin-drop id="td-gate-archive" link="td-gate-inbox" facing="right" target="#td-archive" label="Archive" insert="start"></mv-twin-drop>
        <ul class="td-list" id="td-archive" aria-labelledby="td-archive-title">
          <li class="td-item" data-twin-drop-item data-twin-drop-label="Invoice #4790">
            <span class="td-ico" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h9l3 3v15l-2.5-1.5L13 21l-2.5-1.5L8 21l-2-1.5z"/><path d="m9 12 2 2 4-4"/></svg></span>
            <span class="td-txt"><strong>Invoice #4790</strong><span>Blue Harbor Co. · paid Sep 12</span></span>
            <span class="td-amt">$1,240.00</span>
          </li>
        </ul>
      </div>
    </section>
  </div>

  <div class="td-bar">
    <button class="mv-button" data-variant="secondary" data-size="sm" id="td-send">Send the top item</button>
    <button class="mv-button" data-variant="ghost" data-size="sm" id="td-reset">Reset board</button>
    <span class="td-log" id="td-log" aria-hidden="true">Keyboard: focus a document, Space to pick it up, P to send it.</span>
  </div>

  <div class="td-canvas" data-twin-drop-canvas id="td-canvas">
    <p class="td-canvas-title">Scratchpad</p>
    <mv-twin-drop id="td-gate-a" link="td-gate-b" facing="right" label="Ideas" style="left:1.5rem"></mv-twin-drop>
    <mv-twin-drop id="td-gate-b" link="td-gate-a" facing="left" label="Parking lot" style="right:1.5rem"></mv-twin-drop>
    <div class="td-note" data-twin-drop-item style="left:30%;top:28%"><i aria-hidden="true"></i>Design review</div>
    <div class="td-note" data-twin-drop-item style="left:48%;top:52%;--_dot:var(--mv-success)"><i aria-hidden="true"></i>Q4 roadmap</div>
    <div class="td-note" data-twin-drop-item style="left:60%;top:22%;--_dot:var(--mv-warning)"><i aria-hidden="true"></i>Offsite agenda</div>
    <p class="td-canvas-hint">Flick a note into a drop zone: the faster it goes in, the farther it flies out.</p>
  </div>
</div>

<script type="module">
  const root = document.getElementById("td-demo");
  if (root) {
    const inbox = root.querySelector("#td-inbox");
    const archive = root.querySelector("#td-archive");
    const log = root.querySelector("#td-log");
    const initial = { inbox: [...inbox.children], archive: [...archive.children] };
    const counts = () => {
      root.querySelector("#td-inbox-count").textContent = inbox.querySelectorAll("[data-twin-drop-item]").length;
      root.querySelector("#td-archive-count").textContent = archive.querySelectorAll("[data-twin-drop-item]").length;
    };
    // Items are real DOM nodes that move: listen to the events to keep app state in sync.
    root.addEventListener("mv-twin-drop-exit", (e) => {
      const { item, to } = e.detail;
      counts();
      log.textContent = `mv-twin-drop-exit · ${item.dataset.twinDropLabel ?? item.textContent.trim()} → ${to.displayName}`;
    });
    root.querySelector("#td-send").addEventListener("click", () => {
      const first = inbox.querySelector("[data-twin-drop-item]");
      if (first) root.querySelector("#td-gate-inbox").send(first, { velocity: 1400 });
      else root.querySelector("#td-gate-archive").send(archive.querySelector("[data-twin-drop-item]"), { velocity: 1400 });
    });
    root.querySelector("#td-reset").addEventListener("click", () => {
      inbox.replaceChildren(...initial.inbox);
      archive.replaceChildren(...initial.archive);
      counts();
      log.textContent = "Board reset.";
    });
  }
</script>

Cultural reference

Portal, Valve (2007, video game). Two linked openings: whatever goes into one comes out of the other, and momentum is conserved on the way through. In the UI, two linked drop zones move an item across the page, and the speed of the throw becomes the speed of the exit.

API

Attributes

NameTypeDefaultDescription
idstringRequired to be linked: the twin zone refers to it through its own link attribute.
linkstring (id)Id of the twin zone. Links are symmetric: when only one side declares link, the other side finds it automatically. Without a twin the zone is shown dimmed with a dotted border and ignores items.
facingright | left | up | downrightSide the opening faces, i.e. the direction items fly out of it. left/right draw an upright slot, up/down a wide one.
targetstring (CSS selector)Container that receives items coming out of this zone: the item is appended (see insert), a spacer opens its slot and the flight settles exactly into it. Without target, the item lands where its momentum leaves it on the zone's free canvas (closest [data-twin-drop-canvas] ancestor, else the parent).
insertend | startendWhere an arriving item is inserted in target.
labelstringName of the place this zone belongs to (e.g. “Archive”), used in the twin's caption (“To Archive”), its accessible name (“Send to Archive”) and the announcements. Fallback: aria-label, the target's aria-label / aria-labelledby, then the id.
acceptstring (CSS selector)Only items matching this selector can go through (others are neither pulled in nor accepted from the keyboard).
disabledbooleanfalseZone closed: dimmed, dotted border, not a drop target, aria-disabled on its opening.
no-captionbooleanfalseHides the visible “To …” caption under the opening (the accessible name is kept).
data-twin-drop-itemboolean (on any element)Makes an element anywhere on the page movable through drop zones. It gets tabindex=0 if it is not focusable, a keyboard hint (aria-describedby), touch-action: none and a grab cursor. Interactive children (links, buttons, fields) keep working and never start a drag.
data-twin-drop-labelstring (on an item)Name of the item in announcements (“Picked up Invoice #4821…”). Fallback: aria-label, the first [data-twin-drop-title], strong or heading, then its text.
data-twin-drop-canvasboolean (on a container)Free canvas: its direct items are absolutely positioned (left/top written in px), dropping one inside the canvas keeps it there with a short momentum glide, and target-less zones inside it land items on it with soft wall bounces.

Properties

NameTypeDescription
twinMvTwinDrop | nullThe linked zone (read-only).
targetElementElement | nullResolved target container (read-only).
canvasElementElement | nullFree canvas used when there is no target (read-only).
displayNamestringName used in captions and announcements (read-only).
link, facing, target, insert, label, accept, disabled, noCaptionreflectedMirror the attributes.

Methods

NameDescription
send(item, { velocity? })Sends an item (element or selector) through this zone to its twin, with the full animation: it slides into this opening, then flies out of the twin. velocity: a speed in px/s (aimed from the item toward this zone) or { x, y }; default 900 px/s. Returns a Promise resolving to true once the item has landed, false if it could not be sent or mv-twin-drop-enter was cancelled.

Events

NameDescription
mv-twin-drop-enterCancelable, dispatched on the entry zone just before an item slides in. detail: { item, from, to, velocity: { x, y, speed } } (px/s). preventDefault() refuses the item: it springs back to where it came from.
mv-twin-drop-exitDispatched on the exit zone once the item has landed in its final place. detail: { item, from, to, container }. Frameworks should update their state here (the node has already been moved).

Content structure

NameDescription
(none)The zone renders its own opening (a button), caption and hint. Items live anywhere else on the page.

CSS classes

NameDescription
mv-twin-drop-openingFocusable <button> of the opening (aria-label “Send to Archive”, described by the keyboard hint).
mv-twin-drop-visual / -glyphDecorative drop target (aria-hidden): rounded frame with a dashed border that turns accent when armed and solid when targeted, around a neutral “drop here” glyph.
mv-twin-drop-captionVisible “→ To Archive” caption under the opening: the only cue for the direction of a pair.
mv-twin-drop-slotDashed placeholder left in the source list during a drag; quiet spacer (data-quiet) opening the slot in the target during a flight.
[data-state] / [data-pulse] on mv-twin-droparmed (an item is being dragged or held), target (the pointer is over it); pulse = swallow | emit during a transfer.
[data-twin-drop-state] / [data-twin-drop-arrived] on itemsdragging | held | travelling while moving; data-twin-drop-arrived for ~1.5 s after landing (halo, or a static outline under reduced motion).

CSS variables

NameDefaultDescription
--mv-twin-drop-rimvar(--mv-accent)Border, fill tint and glyph color of a zone while it is armed or targeted (the same on both sides of a pair).
--mv-twin-drop-size7.5remLength of the long side of the opening.
--mv-twin-drop-ratio0.5Short side / long side ratio of the opening.

Accessibility

Each drop zone is a real <button> named after its destination (“Send to Archive”) and described by a hint (“Pick up an item with Space, then press Enter here to send it to Archive.”); the caption and the visual frame are aria-hidden. Direction never relies on color: both sides of a pair share one color and the caption names the destination. Every [data-twin-drop-item] becomes focusable (tabindex=0 when needed) and is described by a shared hint. Keyboard: Space on an item picks it up (dashed outline, drop zones armed) and a polite live region announces “Picked up Invoice #4821. Press P to send it to Archive, or Tab to another drop zone and press Enter. Press Escape to cancel.”; P sends it through its home zone (the one serving its container, else the nearest), Enter or Space on any focused zone sends it through that one, Space again or Escape puts it down (“Invoice #4821 dropped. Nothing moved.”). After a keyboard transfer the item keeps the focus at its new place and “Invoice #4821 sent to Archive.” is announced (pointer transfers are announced too). Pointer and touch: a press becomes a drag after 5 px, interactive children never start one, the click that ends a drag is swallowed, pointercancel springs the item back; items use touch-action: none (a list of items cannot be scrolled by touch from the items themselves). Reduced motion (system or data-motion="reduce"): no magnetic pull, widening, tilt, swallow or flight; the item is moved instantly to its destination and outlined for ~1.5 s, the announcements are unchanged. Forced colors: system colors only, armed zones use Highlight, unlinked or disabled zones GrayText with a dotted border. Performance: one page-level controller shared by all zones (delegated listeners, a single live region, created with the first zone and removed with the last); during a drag, one read (zone rects) then one write (transform) per frame; flights run on frameLoop only while an item is in the air, animate transform/clip-path/opacity on the fixed-positioned item, and are finished instantly if the zones are removed. Items are moved, never cloned: frameworks should listen to mv-twin-drop-exit (or cancel mv-twin-drop-enter) to keep their state in sync.