Seamlessly Moving an UNO Card Across Multiple Tabs

Published June 20, 2021

Occasianly, I saw a demo demonstated by someone showing a card moving across different window seamlessly, but he didn’t give out the code. But I found it interesting, so I tried to realize it by myself and also want to share this little demo with you.

What the demo does

  • Opens the same URL in multiple same-origin windows and place them on the screen seamlessly
  • Click and move to grab and move the card across different window seamlessly
  • The result feels like one card moving across tabs, but technically it is several synchronized replicas.

Use screen coordinates as the source of truth

The shared state stores the card in desktop space, not page space:

const CARD_WIDTH = 308;
const CARD_HEIGHT = 428;

function defaultState() {
  const rect = getViewportRect();
  return {
    card: {
      screenX: Math.round(rect.x + (rect.width - CARD_WIDTH) / 2),
      screenY: Math.round(rect.y + (rect.height - CARD_HEIGHT) / 2),
      width: CARD_WIDTH,
      height: CARD_HEIGHT,
      rotation: CARD_ROTATION,
    },
    drag: null,
    updatedAt: Date.now(),
  };
}

Once the card lives in screen space, every window can map it back into local coordinates.

Track the real viewport, not just screenX and screenY

window.screenX and window.screenY describe the outer browser window, not the page content area. Browser chrome matters here: tabs, toolbar, bookmarks bar, and window frame all shift the viewport.

This demo estimates the content origin like this:

function getViewportRect() {
  const windowX = window.screenX ?? window.screenLeft ?? 0;
  const windowY = window.screenY ?? window.screenTop ?? 0;
  const horizontalChrome = Math.max(0, window.outerWidth - window.innerWidth);
  const verticalChrome = Math.max(0, window.outerHeight - window.innerHeight);

  const leftFrame = Math.round(horizontalChrome / 2);
  const bottomFrame = Math.min(16, Math.round(verticalChrome * 0.16));
  const topChrome = Math.max(0, verticalChrome - bottomFrame);

  return {
    x: Math.round(windowX + leftFrame),
    y: Math.round(windowY + topChrome),
    width: window.innerWidth,
    height: window.innerHeight,
  };
}

This is not perfect across every browser, but it is good enough for a convincing desktop demo.

Render the card only if it intersects the current window

Each window computes whether the shared card overlaps its own viewport:

function intersectsViewport(card, viewport) {
  return (
    card.screenX + card.width > viewport.x &&
    card.screenX < viewport.x + viewport.width &&
    card.screenY + card.height > viewport.y &&
    card.screenY < viewport.y + viewport.height
  );
}

function toLocalCard(card, viewport) {
  return {
    x: card.screenX - viewport.x,
    y: card.screenY - viewport.y,
    width: card.width,
    height: card.height,
    rotation: card.rotation,
  };
}

If there is no intersection, the window renders nothing. If there is, it draws its local replica at the translated position.

Sync state with localStorage and BroadcastChannel

localStorage gives persistence and cross-window updates. BroadcastChannel makes synchronization immediate.

function persistState(nextState) {
  state = sanitizeState(nextState);
  localStorage.setItem(STATE_KEY, JSON.stringify(state));
  channel?.postMessage({
    type: "state",
    source: tabId,
    payload: state,
  });
  render();
}

This combination works well for same-origin demos and avoids extra infrastructure.

Convert drag events back into screen space

During drag, the pointer is local to the current window, so the code converts it back into screen coordinates before updating shared state:

function localPointerToScreen(clientX, clientY, drag) {
  const viewport = getViewportRect();
  return {
    x: Math.round(viewport.x + clientX - drag.offsetX),
    y: Math.round(viewport.y + clientY - drag.offsetY),
  };
}

That single conversion is what makes the whole trick work.

Each window owns a hidden copy

There is no DOM node moving across browser windows. That is not possible. Instead:

  • Every window contains the same card markup
  • All windows listen to the same shared state
  • Each one decides when to show its own copy

This is a common pattern for “seamless multi-window” demos: shared world state, local rendering.

Final notes

A few constraints remain:

  • Drag events disappear when the cursor enters browser chrome or desktop gaps
  • Viewport estimation is browser-dependent
  • This demo works only across same-origin windows

Even with those limits, the pattern is useful. If you want a convincing multi-window illusion in the browser, screen-space state plus per-window replicas is the simplest foundation.

Comments