Exclusifbêta

Twin Drop <mv-twin-drop>

Zones de dépôt liées qui déplacent des éléments d’un bout à l’autre d’une page en un seul geste : deux zones <mv-twin-drop> sont appairées, et tout [data-twin-drop-item] glissé dans l’une s’engouffre dans son ouverture, puis ressort de sa jumelle ailleurs sur la page, projeté dans la direction vers laquelle elle est orientée à une vitesse proportionnelle à sa vitesse d’entrée (bornée), avant de ralentir et de se poser dans le conteneur cible de la jumelle, ou de glisser et rebondir jusqu’à l’arrêt sur un canevas libre quand il n’y a pas de cible. Pendant le glisser, les zones de dépôt s’ouvrent davantage à l’approche de l’élément et l’attirent magnétiquement, et la jumelle répond à mi-intensité pour montrer par où il ressortira. Les éléments sont de vrais nœuds DOM déplacés (jamais clonés), annoncés aux lecteurs d’écran et entièrement utilisables au clavier (Espace pour saisir, P ou Entrée sur une zone de dépôt pour envoyer).

CatégorieTransitions
TypeWeb Component (<mv-twin-drop>)
Statutbêta
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

Installation

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

Agent IA avec le serveur MCP Marvelous UI : install_components({ slugs: ["twin-drop"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : 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.

Utilisation

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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 }
    }
  </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>

Référence culturelle

Portal, Valve (2007, jeu vidéo). Deux ouvertures liées : ce qui entre dans l’une ressort par l’autre, et la quantité de mouvement est conservée au passage. Dans l’interface, deux zones de dépôt liées déplacent un élément à travers la page, et la vitesse du lancer devient la vitesse de sortie.

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.

Cette page a été traduite par IA. Signaler un problème de traduction