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.
The gap
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:
Try it here
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.
Install
npm install dualscreen
# pnpm add dualscreen
# yarn add dualscreen
# bun add dualscreenreact is an optional peer dependency — needed only for the React bindings. The core is framework-agnostic and ships no React code.
| Import | Contents |
|---|---|
dualscreen | Core. No framework. |
dualscreen/react | Hooks + components |
dualscreen/screens | Displays + placement |
dualscreen/devtools | Debug overlay |
Prefer smaller graphs? Install the scoped packages individually.
| Package | min+gzip | Contents |
|---|---|---|
@dualscreen/core | 4.8 kB | Protocol, transport, presence, leader election, shared state |
@dualscreen/screens | 2.3 kB | Display detection, placement, the degradation ladder |
@dualscreen/react | 2.9 kB | <DualScreen> and 11 hooks |
@dualscreen/devtools | 1.9 kB | Peers, state, and live protocol traffic |
Implementation
There is no second bundle to build, no second route table to maintain, and nothing to deploy. Both windows run the same component tree.
channel namespaces your app so two apps on one origin never collide.
import { DualScreen } from 'dualscreen/react'
export function App() {
return (
<DualScreen channel="my-app">
{/* everything else */}
</DualScreen>
)
}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.
<DualScreen channel="my-app">
<DualScreen.Main>
<ExperimentTable />
</DualScreen.Main>
<DualScreen.Surface name="inspector">
<ExperimentDetail />
</DualScreen.Surface>
</DualScreen>useShared works identically in both windows. Whoever writes, everyone sees it.
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} />
}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.
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>
)
}The core idea
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.
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.
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
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
setSelected('EXP-102')✗ Clones the whole table, on every click
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.
What you get
Built on Web Locks, so a force-quit window disappears immediately. No heartbeat interval, no timeout that's wrong on somebody's machine.
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.
useEphemeral coalesces onto animation frames — a 60 fps pointer stream can't flood the channel — and is excluded from late-join snapshots.
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.
window.open() runs synchronously before any await, then placement resolves. Get that order wrong and the popup is blocked every time.
Peers, shared state, and live protocol traffic in a floating panel. Render it in both windows and the disagreement is usually obvious.
Demos
Each opens the full playground. Every one degrades in front of you — shrink to one monitor and it becomes a split pane.
The work-list pattern. Results on one monitor, the selected item large on the other — route-driving and ids-not-payloads over a 520-row differential expression table.
Open demo →Shared state, divergent views. The stage shows the slide; your screen shows notes, a timer, and what’s next — the thing mirroring libraries cannot express.
Open demo →The 60 fps case. Hover either plot and a crosshair tracks the same cell in the other window, with a live round-trip readout that measures latency instead of claiming it.
Open demo →API
<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. |
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()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
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.
| Capability | Chrome / Edge | Safari | Firefox |
|---|---|---|---|
| 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 | ✓ | ✗ | ✗ |
Your code does not change between rungs. open() is the same call; <DualScreen.Surface> is the same JSX.
automanualmanualsplitPrior art
The transport layer is genuinely commoditised — BroadcastChannel has been baseline for years. What was missing everywhere else is the combination.
| What it does well | What it doesn't | |
|---|---|---|
broadcast-channel | Excellent transport and leader election | No screens, no state protocol, no framework layer |
redux-state-sync, cross-tab Zustand | Mirror one store across tabs | Mirroring is the wrong model — two monitors should show different views of shared state, not identical state |
Yjs | Conflict-free concurrent editing | Far heavier than controller→viewer needs; no window management |
electron-multi-monitor | Complete window control | Requires 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
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.
__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.
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.
If your app already has routes, it already has most of this. The integration is a provider, two components, and one hook.