Documentation
Guides

Popouts

Move tabs into native browser windows with live content - the host page, popoutURL, the commands, theming and closing.

A popout moves a tab into a native browser window. The content keeps its state: the tab's moveable element is re-parented into the new document with appendChild, never cloned, and its React tree stays where it was.

In the model, a popout is a window layout: an entry of model.get("windows") (a WindowLayout, with its id, its screen rect and its own root row). The core opens one native window per window layout while the layout is mounted, and closes a window whose layout is gone.

Pop out
Gallery

1. Serve a host page

The new window loads a host page, popout.html by default (relative to the current page). It can be an empty document; the core adds a data-dockable-popout content root to it:

public/popout.html
<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <style>
            body { height: 100%; }
        </style>
    </head>
    <body></body>
</html>

The window layout's id is passed as ?id=. Nothing names or titles the window unless you pass a title.

2. Point popoutURL at it

<Dockable.Root model={model} popoutURL="/popout.html">

Under a base path (GitHub Pages, a sub-directory deploy), include it: the window is opened with window.open, which knows nothing about your router. The examples (a Vite app) use `${import.meta.env.BASE_URL}popout.html`.

openWindow on Root replaces the opener, a function (url, name, features) returning the new Window or null (the default calls the main window's open). Use it to open windows through another API, or to fake them in tests.

3. Render the windows

Dockable.Popout goes once, directly under the root. For every window layout it opens a window and, once the window is ready (styles copied), portals your child function's result into it. The child function receives the WindowLayout; a Row without node inside renders that window's root row:

<Dockable.Root model={model} popoutURL="/popout.html">
    <Dockable.Row<Types>>{renderNode}</Dockable.Row>
    <Dockable.Panels<Types>>{(tab) => <Dockable.Panel node={tab}>{content(tab)}</Dockable.Panel>}</Dockable.Panels>
    <Dockable.DropIndicator className="drop-indicator" />
    <Dockable.Popout<Types> className="popout-root">
        {() => <Dockable.Row<Types>>{renderNode}</Dockable.Row>}
    </Dockable.Popout>
</Dockable.Root>

Dockable.Panels needs no change: it renders the panels of every layout, and each panel is positioned in its own window.

4. Pop a tab out

Allow it per tab (poppable: true, or for every tab with defaults.tab.poppable: the built-in default is false), then put a Dockable.PopoutTrigger in the tabset's header. It pops the selected tab out, and in the window the same button docks it back (data-mode is "popout" or "dock"). It renders nothing when neither is possible (popouts unsupported, or the model refuses it):

<Dockable.PopoutTrigger aria-label="Pop out" className="group">
    <ExternalLinkIcon aria-hidden className="group-data-[mode=dock]:hidden" />
    <DockIcon aria-hidden className="hidden group-data-[mode=dock]:block" />
</Dockable.PopoutTrigger>

target="tabset" moves the whole tabset into one window. Tab also exposes data-poppable, for a button that lives in the tab.

From code, two commands create a window layout:

  • tab.popout { tabId, rect? } moves a tab into a new window. It is refused when the tab's poppable resolves to false, when it is pinned, or when it is already in a window.
  • tabset.popout { tabsetId, rect? } moves a whole tabset. It is refused when the tabset is empty or already in a window, or when any of its tabs does not allow popouts.

Both return { windowId }, the new window layout's id. rect is the window's screen rect; without one a default place is used. The engine's engine.run("popout", { nodeId }) runs the right command with the node's current screen rect, so the window opens where the tab was; engine.can("popout", { nodeId }) answers with model.can (and whether popouts are supported); engine.run("dock-back", { nodeId }) moves a tab or tabset of a window back into the main layout. Any engine does it, a popout's included.

const { model, engine } = useDockable<Types>();
const popOut = () => engine.run("popout", { nodeId: tab.id }); // or model.run("tab.popout", { tabId: tab.id })
const allowed = engine.can("popout", { nodeId: tab.id });

supportsPopout on Root decides whether windows open at all; the default is "a desktop pointer is present". Without support (or when the browser blocks the window), a window layout is docked back immediately.

Theming the popout

The core copies into the popout document: every <link rel="stylesheet">, every <style> (including later edits), CSSOM rules inserted at runtime (polled), adoptedStyleSheets, and the page's lang. The popout's dir is its layout's direction (engine.get("direction") of the main layout), wherever the page sets it, and follows a flip (Right-to-left layouts).

If your theme keys off :root[data-theme], a class on <html> or on <body>, turn on popoutMirrorRoot: it copies the attributes of <html> and <body> into every popout and keeps them in sync, so a theme switch follows into open windows:

<Dockable.Root model={model} popoutMirrorRoot>                  {/* all but style and id */}
<Dockable.Root model={model} popoutMirrorRoot={["data-theme"]}> {/* only these */}

The examples put their theme on <body> (data-example-theme) for this reason: popoutMirrorRoot carries it into every window, and the menus they portal into <body> take it too. A theme that lives on another element (a wrapper div) is not on the document root: set it in Root's onPopoutOpen, which runs when the popout document is ready and before its content renders. onPopoutOpen and onPopoutClose receive (layout, window, document) (layout is the WindowLayout), also for set-up that is not about rendering (a CSS-in-JS cache for the new document, analytics). popoutTitle on Root names the window's document (popoutTitle={(layout) => "My app"}); with none, the host page's title is kept.

Closing a popout

When the user closes a popout window, the core runs window.close for its layout: its tabs move back into the main layout's active tabset (its first tabset when none is active: model.get("default-tabset")), and the window layout is removed. The result lists the tabs that moved back. It is a command like any other, so a listener sees it and an undo can reverse it.

The same command closes a window from code:

model.run("window.close", { windowId }); // e.g. the id tab.popout returned

A "dock back" button inside popped-out content moves one tab home; the engine does it, closing the window when it was the window's last tab:

const { engine } = useDockable<Types>();
engine.run("dock-back", { nodeId: tab.id });

Do not veto window.close in a middleware for a window the user closed: the window is already gone, and its layout would stay in the state without it.

Other ways a popout goes away:

  • the main page unloading (pagehide) closes every popout without running window.close: the model still has the window layouts, so a layout saved at that moment reopens them;
  • a layout.load whose layout drops a window closes that window.

The window's screen rect is recorded in the model (a transient window.configure when the window moves or resizes), so a saved layout reopens it in place.

What works across windows, and what does not

  • Works: content state (counters, inputs, scroll) survives popping out and docking back; splitters, tabs, keyboard and maximize work inside the window; the window layouts are saved by model.get("layout-json") under windows and reopened by createModel or layout.load (the browser may block a window opened without a user gesture).
  • Drags: tabs drag between the main window and a popout, and between popouts, like between tabsets. See Drag across windows and layouts.
  • Libraries that use the global document: popped-out content lives in the popout's document, but document and window in your code (and in your libraries) are still the main window's. A dialog, popover, tooltip or toast that portals into document.body opens in the main window, behind the popout; a drag library or an outside-click handler that listens on window hears nothing from the popout. The layout cannot fix that for them. Give such a library the content's own document: a portal container of element.ownerDocument.body, listeners on element.ownerDocument or its defaultView (most portal-based components take a container prop for this). onPopoutOpen hands you the popout's window and document for set-up that must happen once per window.
Analytics dashboard
Gallery