Skip to content

Drive a second monitor from your web app.

Click in one window, change what's shown in another. No server, no Electron, no second entry point — the other window is your app, at the same URL, rendering a different surface.

~10 kB gzipped0 runtime depsTypeScriptReact 18 · 19MIT
$npm install dualscreen
'EXP-102'9 bytes — not the 520 rows
One window drives. Only the selector crosses; each side resolves it against its own cache.

The gap

Everyone already works this way. The web just can't.

Radiology worklists, trading desks, DAWs, IDEs — every field that works on two monitors solved this decades ago in native code. Web apps never got the plumbing, so people improvise:

01

Open a second tab

Duplicate the app, because there's no other way to get a second view on screen.

02

Drag it to the other monitor

Manually, every session. Nothing remembers where it went.

03

Watch them drift apart

Two independent apps that share nothing. This is the gap — steps 1 and 2 already prove the demand.

Try it here

A working demo, in this page

This is the real playground running in an iframe, pinned to split mode so both surfaces fit. Click a row and the inspector follows. On two monitors, that right-hand pane is a separate window on your second screen — same component, same state, no code change.

dualscreen playground — analysis dashboard

Install

One package, four entry points

bash
npm install dualscreen
# pnpm add dualscreen
# yarn add dualscreen
# bun add dualscreen

react is an optional peer dependency — needed only for the React bindings. The core is framework-agnostic and ships no React code.

ImportContents
dualscreenCore. No framework.
dualscreen/reactHooks + components
dualscreen/screensDisplays + placement
dualscreen/devtoolsDebug overlay

Prefer smaller graphs? Install the scoped packages individually.

Packagemin+gzipContents
@dualscreen/core4.8 kBProtocol, transport, presence, leader election, shared state
@dualscreen/screens2.3 kBDisplay detection, placement, the degradation ladder
@dualscreen/react2.9 kB<DualScreen> and 11 hooks
@dualscreen/devtools1.9 kBPeers, state, and live protocol traffic

Implementation

The whole integration, in four steps

There is no second bundle to build, no second route table to maintain, and nothing to deploy. Both windows run the same component tree.

Wrap your app

channel namespaces your app so two apps on one origin never collide.

tsx
import { DualScreen } from 'dualscreen/react'

export function App() {
  return (
    <DualScreen channel="my-app">
      {/* everything else */}
    </DualScreen>
  )
}

Declare where things render

Main renders only in the primary window. Surface renders when this window is that surface — or inline beside Main when there's only one display.

tsx
<DualScreen channel="my-app">
  <DualScreen.Main>
    <ExperimentTable />
  </DualScreen.Main>

  <DualScreen.Surface name="inspector">
    <ExperimentDetail />
  </DualScreen.Surface>
</DualScreen>

Share the selection

useShared works identically in both windows. Whoever writes, everyone sees it.

tsx
import { useShared } from 'dualscreen/react'

function ExperimentTable() {
  const [selected, setSelected] = useShared<string | null>('selected', null)

  return rows.map((row) => (
    <tr key={row.id}
        aria-selected={row.id === selected}
        onClick={() => setSelected(row.id)}>
      <td>{row.name}</td>
    </tr>
  ))
}

function ExperimentDetail() {
  const [selected] = useShared<string | null>('selected', null)
  // Resolve the id against your own cache — React Query, SWR, anything.
  const { data } = useQuery({ queryKey: ['row', selected], queryFn: fetchRow })
  return <Detail data={data} />
}

Open the window

Call open() directly in the click handler — popup blockers reject windows opened outside a user gesture, and awaiting anything first is enough to lose it.

tsx
import { useSurface } from 'dualscreen/react'

function Toolbar() {
  const inspector = useSurface('inspector')

  return (
    <button onClick={() => inspector.open()}>
      {inspector.isConnected ? 'Inspector open' : 'Open on second screen'}
    </button>
  )
}

Full walkthrough →

The core idea

A surface is a route

This is the reframing that makes adoption cheap. In a single-page app, "what's on screen" already is a route plus params — so a second window doesn't need to be a new app. It's the same app, at the same URL, told to render a different named surface.

How a window knows what it is

The surface name rides in a query parameter:

/dashboard              → surface "main"
/dashboard?ds=inspector → surface "inspector"

Because it's in the URL, it survives a reload, a bookmark, and a pasted link. You can open a surface by hand from the address bar to debug it.

Why that matters

The integration question stops being "how do I restructure my state management?" and becomes "which of my existing views goes on the other monitor?"

Only the query string is touched, so it composes with React Router, TanStack Router, Next's app router, or no router at all.

The rule that matters

Ids, not payloads

BroadcastChannel uses structured clone, which copies. Broadcasting a 200 MB matrix doesn't pass a reference — it serialises and deserialises the whole thing into every connected window, blocking the main thread on both ends. This is the single rule that decides whether a cross-window library survives contact with real data.

✓ Nine bytes, whatever the dataset size

tsx
setSelected('EXP-102')

✗ Clones the whole table, on every click

tsx
setSelected(rowsForExperiment102)

The rule generalises: send the smallest thing that describes the selection. The linked-brushing demo ships a brush over 400 points as a rectangle in data space — four numbers — so cost never grows with the selection, and the receiving window can apply it to entirely different axes.

Read more →

What you get

Built for the parts that actually go wrong

Crash-safe presence

Built on Web Locks, so a force-quit window disappears immediately. No heartbeat interval, no timeout that's wrong on somebody's machine.

State that converges

Last-writer-wins on (version, origin). Every peer derives the same answer from the message alone, so the map converges with no server ordering writes.

An ephemeral tier

useEphemeral coalesces onto animation frames — a 60 fps pointer stream can't flood the channel — and is excluded from late-join snapshots.

Durable routes

navigate() writes to shared state, not a one-shot event. A surface that reloads lands back where it was instead of on a blank screen.

Gesture-safe opening

window.open() runs synchronously before any await, then placement resolves. Get that order wrong and the popup is blocked every time.

Devtools included

Peers, shared state, and live protocol traffic in a floating panel. Render it in both windows and the disagreement is usually obvious.

API

The whole surface, on one screen

Components

<DualScreen channel>Root provider. One per app.
<DualScreen.Main>Renders only in the primary window.
<DualScreen.Surface name>Renders when this window is that surface — or inline, on one display.

Without React

ts
import { createLink } from 'dualscreen'

const link = createLink({ channel: 'my-app' })

link.set('selected', 'EXP-102')
link.subscribeKey('selected', (id) => render(id))
link.send('rerun', { force: true })
link.command('rerun', (args, from) => rerun(args))

await link.whenReady()
link.close()

Hooks

useShared(key, initial?)A value replicated to every window.
useEphemeral(key, initial?)rAF-coalesced, excluded from snapshots.
useSurface(name)open, close, navigate, isConnected, mode
useSurfaceRoute()The route this surface was told to show.
usePeers()Every connected window.
useCommand(name, fn)Handle a one-off event.
useSend()Send one.
useScreens()Layout, permission, placement mode.
useSharedState()Every shared value at once.
useIsLeader()Whether this window leads.
useLinkReady()Whether the handshake settled.
useDualScreen()Escape hatch to the Link.

Browser support

An honest account

Two different things degrade differently, and conflating them is how libraries in this space oversell themselves. The API that places a window on a chosen monitor is Window Management, and it is Chromium-only. We don't paper over that — we confine the damage.

CapabilityChrome / EdgeSafariFirefox
Cross-window sync — the actual value
Shared state, presence, commands
Crash-safe presence & leader election
Split-pane fallback
Opening a secondary window
Automatic placement on a chosen display
Fullscreen on a chosen display

The degradation ladder

Your code does not change between rungs. open() is the same call; <DualScreen.Surface> is the same JSX.

Chromium, permission granted, second displayWindow opens on monitor 2, sized to it, optionally fullscreen.auto
Chromium, permission deniedPopup opens; the user drags it once. Position is remembered.manual
Safari / FirefoxPopup opens; the user drags it once. Sync is completely unaffected.manual
One displayRenders inline as a resizable split pane — the same component tree, laid out differently.split

Prior art

Where this differs

The transport layer is genuinely commoditised — BroadcastChannel has been baseline for years. What was missing everywhere else is the combination.

What it does wellWhat it doesn't
broadcast-channelExcellent transport and leader electionNo screens, no state protocol, no framework layer
redux-state-sync, cross-tab ZustandMirror one store across tabsMirroring is the wrong model — two monitors should show different views of shared state, not identical state
YjsConflict-free concurrent editingFar heavier than controller→viewer needs; no window management
electron-multi-monitorComplete window controlRequires shipping an Electron app

Deliberately not a CRDT. dualscreen targets the shape where one window drives and the others follow, and last-writer-wins is the honest fit. If you need genuine concurrent editing, put Yjs on top and use dualscreen as the transport — the Transport interface is public for exactly that.

Security

The trust boundary is the origin

BroadcastChannel is scoped to an origin, not to your app. Any script on the page — an analytics tag, an embedded widget, a compromised dependency — can join a channel and read or write shared state. The data was always reachable; what changes is how convenient it is to collect. So put no secrets in shared state, and treat everything arriving from another window as untrusted input.

Hardened where it counts

__proto__, constructor, and prototype are refused as state keys — a prototype write would be invisible to Object.keys() and to the devtools panel. Cross-origin surface windows are refused before window.open is reached, since an opener handle across origins enables reverse tabnabbing. Every wire payload is shape-checked, and handler exceptions are isolated so one malformed message cannot wedge the link.

Honest about the limits

Shape validation is not authorisation. A peer can legitimately set selectedExperiment to any id — whether the user may see it is your server's question, not the channel's. And if an attacker can run script on your origin, they already have your session; dualscreen neither helps nor hinders there.

Read the threat model →

Ship it this afternoon

If your app already has routes, it already has most of this. The integration is a provider, two components, and one hook.

Released under the MIT License.