独家

Twin Drop <mv-twin-drop>

联动的放置区,一个手势就能把条目移到页面另一处:两个 <mv-twin-drop> 区域成对配对,任何被拖入其中一个区域的 [data-twin-drop-item] 都会滑入它的开口,然后从页面别处的孪生区域中冒出来,沿孪生区域的朝向飞出,速度与进入时的速度成正比(有上限),逐渐减速并落入孪生区域的目标容器;如果没有目标,则在自由画布上滑行、弹跳直至停下。

文化出处

《传送门》,Valve(2007 年,电子游戏)。两个相连的开口:进入其中一个的东西会从另一个出来,穿过时动量守恒。在界面中,两个联动的放置区把项目移到页面另一处,抛出的速度就成为出口的速度。

工作原理

联动的放置区,一个手势就能把项目移到页面另一处:两个 <mv-twin-drop> 区域配对,任何被拖入其中一个的 [data-twin-drop-item] 都会滑入它的开口,然后从页面另一处的孪生区域出来,沿孪生区域的朝向飞出,速度与进入时的速度成正比(有上限),逐渐减速并落入孪生区域的目标容器;如果没有目标,则在自由画布上滑行、弹跳直至停下。拖动时,放置区会随着项目靠近而张得更大并以磁吸方式将其吸入,孪生区域则以一半强度呼应,让你看到它将从哪里出来。项目是被移动(从不克隆)的真实 DOM 节点,会向屏幕阅读器播报,并完全支持键盘操作(空格键拾起,在放置区上按 P 或 Enter 发送)。

分类过渡效果
类型Web Component(<mv-twin-drop>)
状态稳定版
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

安装

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

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["twin-drop"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):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。

用法

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<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 }
      #td-demo .td-canvas { height:21rem }
    }
  </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>Tidewell 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, Kitebridge 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>Kitebridge 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] };
    // Narrow screens: stack the notes in the free column between the two drop zones.
    const canvas = root.querySelector("#td-canvas");
    if (canvas.clientWidth < 560) {
      canvas.querySelectorAll(".td-note").forEach((note, i) => {
        note.style.left = `calc(50% - ${note.offsetWidth / 2}px)`;
        note.style.top = `${14 + i * 15}%`;
      });
    }
    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>

API

Attributes

Name类型DefaultDescription
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

Name类型Description
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).
stringsPartial<Record<string, string>>Overrides for every visible text and announcement, English defaults, {placeholders} filled in (caption, captionUnlinked, sendTo, nameUnlinked, hint, hintUnlinked, zone, itemHint, cannotMove, pickedUp, pickedUpOthers, dropped, refused, sent, pickFirst, notLinked). Announcements use the strings of the zone involved; itemHint is shared by every item of the page, so give all zones the same one.
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. Known limits: a zone and its twin must live in the same document (no transfer across iframes or windows), and on touch a list of items cannot be scrolled from the items themselves (leave some padding or a handle-free area to scroll).

本页面由 AI 翻译。报告翻译问题