Exclusivebeta
Portal — <mv-portal>
Linked drop zones that move items across a page in one gesture: two <mv-portal> openings are paired, and any [data-portal-item] dragged into one is swallowed by its elliptical opening, then emerges from 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, portals 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 portal to send).
| Category | Transitions |
|---|---|
| Type | Web Component (<mv-portal>) |
| Status | beta |
| Keywords | exclusive, 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 portal --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["portal"], 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/portal/portal.js, components/portal/portal.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="pt-demo">
<style>
#pt-demo { display:grid; gap:1.25rem; width:100%; max-width:68rem; margin-inline:auto; font-size:.875rem }
#pt-demo .pt-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) }
#pt-demo .pt-col { display:grid; gap:.75rem; align-content:start }
#pt-demo .pt-inbox { grid-column:1; grid-row:1 }
#pt-demo .pt-archive { grid-column:2; grid-row:2; align-self:end }
#pt-demo .pt-head { display:flex; align-items:center; gap:.5rem; margin:0 }
#pt-demo .pt-head h3 { margin:0; font-size:.9375rem; font-weight:600; letter-spacing:-.01em }
#pt-demo .pt-head span.pt-sub { color:var(--mv-fg-subtle); font-size:.75rem; margin-inline-start:auto }
#pt-demo .pt-lane { display:flex; align-items:center; gap:1.25rem }
#pt-demo .pt-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 }
#pt-demo .pt-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) }
#pt-demo .pt-item:hover { border-color:var(--mv-border-strong) }
#pt-demo .pt-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) }
#pt-demo .pt-ico svg { width:1rem; height:1rem }
#pt-demo .pt-txt { display:grid; gap:.0625rem; min-width:0 }
#pt-demo .pt-txt strong { font-weight:600; font-size:.8125rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
#pt-demo .pt-txt span { color:var(--mv-fg-muted); font-size:.75rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
#pt-demo .pt-amt { font-variant-numeric:tabular-nums; font-weight:500; font-size:.8125rem }
#pt-demo .pt-bar { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem }
#pt-demo .pt-log { color:var(--mv-fg-muted); font-size:.75rem; margin-inline-start:auto; min-height:1.2em }
#pt-demo .pt-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 }
#pt-demo .pt-canvas-title { position:absolute; top:.875rem; left:1rem; margin:0; font-size:.8125rem; font-weight:600 }
#pt-demo .pt-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 }
#pt-demo .pt-canvas mv-portal { position:absolute; top:50%; translate:0 -50% }
#pt-demo .pt-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 }
#pt-demo .pt-note i { width:.5rem; height:.5rem; border-radius:50%; background:var(--_dot, var(--mv-accent)) }
@media (max-width: 720px) {
#pt-demo .pt-board { grid-template-columns:1fr; }
#pt-demo .pt-archive { grid-column:1; margin-top:0 }
#pt-demo mv-portal { --mv-portal-size:5.5rem }
#pt-demo .pt-lane { gap:.75rem }
#pt-demo .pt-amt, #pt-demo .pt-head span.pt-sub { display:none }
#pt-demo .pt-canvas-hint { white-space:normal; width:80%; text-align:center }
}
</style>
<div class="pt-board">
<section class="pt-col pt-inbox" aria-labelledby="pt-inbox-title">
<div class="pt-head"><h3 id="pt-inbox-title">Inbox</h3><span class="mv-badge" data-variant="secondary" id="pt-inbox-count">5</span><span class="pt-sub">Drag a document into the portal</span></div>
<div class="pt-lane">
<ul class="pt-list" id="pt-inbox" aria-labelledby="pt-inbox-title">
<li class="pt-item" data-portal-item data-portal-label="Invoice #4821">
<span class="pt-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="pt-txt"><strong>Invoice #4821</strong><span>Northwind Traders · due Oct 3</span></span>
<span class="pt-amt">$2,480.00</span>
</li>
<li class="pt-item" data-portal-item data-portal-label="Contract renewal, Acme Logistics">
<span class="pt-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="pt-txt"><strong>Contract renewal</strong><span>Acme Logistics · signed Sep 19</span></span>
<span class="pt-amt">$18,000.00</span>
</li>
<li class="pt-item" data-portal-item data-portal-label="Expense report, Priya Raman">
<span class="pt-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="pt-txt"><strong>Expense report</strong><span>Priya Raman · client travel</span></span>
<span class="pt-amt">$312.40</span>
</li>
<li class="pt-item" data-portal-item data-portal-label="Purchase order PO-1187">
<span class="pt-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="pt-txt"><strong>Purchase order PO-1187</strong><span>Halden Studio · 12 monitors</span></span>
<span class="pt-amt">$9,150.00</span>
</li>
<li class="pt-item" data-portal-item data-portal-label="Refund request, Mateo Alvarez">
<span class="pt-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="pt-txt"><strong>Refund request</strong><span>Mateo Alvarez · order #58213</span></span>
<span class="pt-amt">$64.99</span>
</li>
</ul>
<mv-portal id="pt-gate-inbox" link="pt-gate-archive" facing="left" target="#pt-inbox" label="Inbox"></mv-portal>
</div>
</section>
<section class="pt-col pt-archive" aria-labelledby="pt-archive-title">
<div class="pt-head"><h3 id="pt-archive-title">Archive</h3><span class="mv-badge" data-variant="secondary" id="pt-archive-count">1</span><span class="pt-sub">Filed documents</span></div>
<div class="pt-lane">
<mv-portal id="pt-gate-archive" link="pt-gate-inbox" facing="right" target="#pt-archive" label="Archive" insert="start"></mv-portal>
<ul class="pt-list" id="pt-archive" aria-labelledby="pt-archive-title">
<li class="pt-item" data-portal-item data-portal-label="Invoice #4790">
<span class="pt-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="pt-txt"><strong>Invoice #4790</strong><span>Blue Harbor Co. · paid Sep 12</span></span>
<span class="pt-amt">$1,240.00</span>
</li>
</ul>
</div>
</section>
</div>
<div class="pt-bar">
<button class="mv-button" data-variant="secondary" data-size="sm" id="pt-send">Send the top item</button>
<button class="mv-button" data-variant="ghost" data-size="sm" id="pt-reset">Reset board</button>
<span class="pt-log" id="pt-log" aria-hidden="true">Keyboard: focus a document, Space to pick it up, P to send it.</span>
</div>
<div class="pt-canvas" data-portal-canvas id="pt-canvas">
<p class="pt-canvas-title">Scratchpad</p>
<mv-portal id="pt-gate-a" link="pt-gate-b" facing="right" label="Ideas" style="left:1.5rem"></mv-portal>
<mv-portal id="pt-gate-b" link="pt-gate-a" facing="left" label="Parking lot" style="right:1.5rem"></mv-portal>
<div class="pt-note" data-portal-item style="left:30%;top:28%"><i aria-hidden="true"></i>Design review</div>
<div class="pt-note" data-portal-item style="left:48%;top:52%;--_dot:var(--mv-success)"><i aria-hidden="true"></i>Q4 roadmap</div>
<div class="pt-note" data-portal-item style="left:60%;top:22%;--_dot:var(--mv-warning)"><i aria-hidden="true"></i>Offsite agenda</div>
<p class="pt-canvas-hint">Flick a note into a portal: the faster it goes in, the farther it flies out.</p>
</div>
</div>
<script type="module">
const root = document.getElementById("pt-demo");
if (root) {
const inbox = root.querySelector("#pt-inbox");
const archive = root.querySelector("#pt-archive");
const log = root.querySelector("#pt-log");
const initial = { inbox: [...inbox.children], archive: [...archive.children] };
const counts = () => {
root.querySelector("#pt-inbox-count").textContent = inbox.querySelectorAll("[data-portal-item]").length;
root.querySelector("#pt-archive-count").textContent = archive.querySelectorAll("[data-portal-item]").length;
};
// Items are real DOM nodes that move: listen to the events to keep app state in sync.
root.addEventListener("mv-portal-exit", (e) => {
const { item, to } = e.detail;
counts();
log.textContent = `mv-portal-exit · ${item.dataset.portalLabel ?? item.textContent.trim()} → ${to.displayName}`;
});
root.querySelector("#pt-send").addEventListener("click", () => {
const first = inbox.querySelector("[data-portal-item]");
if (first) root.querySelector("#pt-gate-inbox").send(first, { velocity: 1400 });
else root.querySelector("#pt-gate-archive").send(archive.querySelector("[data-portal-item]"), { velocity: 1400 });
});
root.querySelector("#pt-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
| Name | Type | Default | Description |
|---|---|---|---|
id | string | Required to be linked: the twin portal refers to it through its own link attribute. | |
link | string (id) | Id of the twin portal. Links are symmetric: when only one side declares link, the other side finds it automatically. Without a twin the portal is shown dimmed and ignores items. | |
facing | right | left | up | down | right | Side the opening faces, i.e. the direction items fly out of it. left/right draw a tall ellipse, up/down a wide one; the depth gradient recedes to the opposite side. |
target | string (CSS selector) | Container that receives items coming out of this portal: 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 portal's free canvas (closest [data-portal-canvas] ancestor, else the parent). | |
insert | end | start | end | Where an arriving item is inserted in target. |
label | string | Name of the place this portal belongs to (e.g. “Archive”), used in the twin's caption (“To Archive”), its accessible name and the announcements. Fallback: aria-label, the target's aria-label / aria-labelledby, then the id. | |
accept | string (CSS selector) | Only items matching this selector can go through (others are neither pulled in nor accepted from the keyboard). | |
disabled | boolean | false | Portal closed: dimmed, not a drop zone, aria-disabled on its opening. |
no-caption | boolean | false | Hides the visible “To …” caption under the opening (the accessible name is kept). |
data-portal-item | boolean (on any element) | Makes an element anywhere on the page movable through portals. 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-portal-label | string (on an item) | Name of the item in announcements (“Picked up Invoice #4821…”). Fallback: aria-label, the first [data-portal-title], strong or heading, then its text. | |
data-portal-canvas | boolean (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 portals inside it land items on it with soft wall bounces. |
Properties
| Name | Type | Description |
|---|---|---|
twin | MvPortal | null | The linked portal (read-only). |
targetElement | Element | null | Resolved target container (read-only). |
canvasElement | Element | null | Free canvas used when there is no target (read-only). |
displayName | string | Name used in captions and announcements (read-only). |
link, facing, target, insert, label, accept, disabled, noCaption | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
send(item, { velocity? }) | Sends an item (element or selector) through this portal to its twin, with the full animation: it travels into this opening, then flies out of the twin. velocity: a speed in px/s (aimed from the item toward this portal) 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-portal-enter was cancelled. |
Events
| Name | Description |
|---|---|
mv-portal-enter | Cancelable, dispatched on the entry portal just before an item is swallowed. detail: { item, from, to, velocity: { x, y, speed } } (px/s). preventDefault() refuses the item: it springs back to where it came from. |
mv-portal-exit | Dispatched on the exit portal 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
| Name | Description |
|---|---|
(none) | The portal renders its own opening (a button), caption and hint. Items live anywhere else on the page. |
CSS classes
| Name | Description |
|---|---|
mv-portal-opening | Focusable <button> of the opening (aria-label “Portal to Archive”, described by the keyboard hint). |
mv-portal-visual / -well / -rim / -shimmer | Decorative stack (aria-hidden): tinted depth well with receding rings, glowing rim, idle shimmer travelling around it. |
mv-portal-caption | Visible “→ To Archive” caption under the opening. |
mv-portal-slot | Dashed 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-portal | armed (an item is being dragged or held), target (the pointer is over it); pulse = swallow | emit during a transfer. data-end = a | b picks the rim color of each side of the pair. |
[data-portal-state] / [data-portal-arrived] on items | dragging | held | travelling while moving; data-portal-arrived for ~1.5 s after landing (halo, or a static outline under reduced motion). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-portal-rim-a | var(--mv-accent) | Rim color of the first portal of each pair (document order). |
--mv-portal-rim-b | var(--mv-fg-muted) | Rim color of the second portal of each pair. |
--mv-portal-rim | Overrides the rim color of one portal regardless of its side. | |
--mv-portal-size | 7.5rem | Length of the long axis of the opening. |
--mv-portal-ratio | 0.36 | Short axis / long axis ratio of the ellipse. |
Accessibility
Each portal is a real <button> named after its destination (“Portal 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 whole visual stack are aria-hidden. Every [data-portal-item] becomes focusable (tabindex=0 when needed) and is described by a shared hint. Keyboard: Space on an item picks it up (dashed outline, portals armed) and a polite live region announces “Picked up Invoice #4821. Press P to send it through the portal to Archive, or Tab to another portal and press Enter. Press Escape to cancel.”; P sends it through its home portal (the one serving its container, else the nearest), Enter or Space on any focused portal 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, tilt, swallow or flight, the shimmer stops; the item is moved instantly to its destination and outlined for ~1.5 s, the announcements are unchanged. Performance: one page-level controller shared by all portals (delegated listeners, a single live region, created with the first portal and removed with the last); during a drag, one read (portal 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 portals are removed. Items are moved, never cloned: frameworks should listen to mv-portal-exit (or cancel mv-portal-enter) to keep their state in sync.