Skip to content

Common Patterns

Unsaved changes — prevent close while dirty

Section titled “Unsaved changes — prevent close while dirty”

Set closable={false} when the user has unsaved changes. The X button becomes inert; your UI handles the prompt.

<Window
open={showEditor}
closable={!hasUnsavedChanges}
onUserClose={() => setShowEditor(false)}
>
<Editor />
{hasUnsavedChanges && <SavePrompt />}
</Window>

Use a ref to call window methods from outside the window’s children.

const ref = useRef<WindowRef>(null);
<Window ref={ref} open={show}>
<Content />
</Window>;
// Later
ref.current?.focus();
ref.current?.setBounds({ width: 1024, height: 768 });

For control from inside the window’s children, use useCurrentWindow() instead.

Each <Window> manages its own lifecycle independently.

<Window open={showA} title="Window A">
<ContentA />
</Window>
<Window open={showB} title="Window B">
<ContentB />
</Window>

open={true} visible={false} creates the window and mounts content without showing it. State is fully initialized and preserved while hidden.

<Window open={true} visible={showPanel}>
<ExpensivePanel />
</Window>

Useful when you want content pre-initialized before the user opens it, or when toggling visibility without unmounting.

Use the controlled width/height props with onBoundsChange to keep your state in sync with user resizing.

const [size, setSize] = useState({ width: 800, height: 600 });
<Window
open={show}
width={size.width}
height={size.height}
onBoundsChange={(bounds) => setSize({ width: bounds.width, height: bounds.height })}
>
<Content />
</Window>

For fire-and-forget sizing, use defaultWidth / defaultHeight instead. See Props Reference for the full geometry API.

Combine pooling and persistence for overlays that remember their position.

const hudPool = createWindowPool(
{ transparent: true, frame: false },
{ minIdle: 1 },
);
function HUD() {
const { bounds, save } = usePersistedBounds("hud", {
defaultWidth: 300,
defaultHeight: 200,
});
return (
<PooledWindow pool={hudPool} open={show} {...bounds} onBoundsChange={save}>
<HUDContent />
</PooledWindow>
);
}

See Pooling and Persistence for details.

Radix UI and Base UI portal overlays (dropdowns, dialogs, tooltips) to document.body by default. Inside a <Window>, this means they’ll render in the parent window’s body — invisible to the user in the child window.

Fix this by passing the child window’s document.body as the portal container:

import { useWindowDocument } from "@loc/electron-window";
function WindowContent({ children }) {
const doc = useWindowDocument();
return (
// Radix UI example — use whichever container prop your library provides
<DropdownMenu.Root>
<DropdownMenu.Portal container={doc.body}>
<DropdownMenu.Content>...</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

For a library-wide fix, wrap your window content in a provider that sets the default container:

function WindowShell({ children }) {
const doc = useWindowDocument();
// Base UI example
return (
<PortalContext.Provider value={doc.body}>
{children}
</PortalContext.Provider>
);
}
<Window open={show}>
<WindowShell>
<AppContent />
</WindowShell>
</Window>

useWindowDocument() throws if called outside an open window, so it’s safe to use without null-checking — the window is open by the time children render.

The child window starts with a blank <html> element — no theme class, no data-theme attribute. Copy them from the parent:

import { useWindowDocument } from "@loc/electron-window";
import { useEffect } from "react";
function ThemeSync({ children }) {
const doc = useWindowDocument();
useEffect(() => {
const parentHtml = window.document.documentElement;
const childHtml = doc.documentElement;
// Initial sync
childHtml.className = parentHtml.className;
childHtml.setAttribute("data-theme", parentHtml.getAttribute("data-theme") ?? "");
// Keep in sync as theme changes
const observer = new MutationObserver(() => {
childHtml.className = parentHtml.className;
childHtml.setAttribute("data-theme", parentHtml.getAttribute("data-theme") ?? "");
});
observer.observe(parentHtml, { attributes: true, attributeFilter: ["class", "data-theme"] });
return () => observer.disconnect();
}, [doc]);
return <>{children}</>;
}

Framer Motion uses window.performance and document.timeline for animation scheduling. Because window.open creates a new browsing context, the child window has its own independent timeline. Animations that coordinate across windows (e.g., shared layout transitions) will not synchronize — this is a known upstream limitation.

For animations contained entirely within one window this is not an issue.

window.innerWidth / window.innerHeight resolve to the parent window’s dimensions inside child window content, because window in the React render context is the opener. Use the element’s own document to get the correct dimensions:

// Avoid — reads the parent window's viewport
const { innerWidth, innerHeight } = window;
// Correct — reads the child window's viewport
const win = element.ownerDocument.defaultView;
const { innerWidth, innerHeight } = win ?? {};

For layout-driven sizing, prefer ResizeObserver on the scroll container rather than reading innerHeight at all:

useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const ro = new ResizeObserver(([entry]) => {
setHeight(entry.contentRect.height);
});
ro.observe(container);
return () => ro.disconnect();
}, []);
<Window
open={show}
frame={false}
titleBarStyle="hidden"
defaultWidth={400}
defaultHeight={300}
>
<div style={{ WebkitAppRegion: "drag", height: 32 }}>
{/* Drag handle */}
</div>
<div style={{ WebkitAppRegion: "no-drag" }}>
<Content />
</div>
</Window>

frame and titleBarStyle are creation-only — set them once and don’t change them. If you need to change them dynamically, use recreateOnShapeChange.