A native Windows backend
The win32 backend draws a react-x11 app into real Win32 windows: one
DirectComposition surface per window, Direct2D for drawing, DirectWrite for
text. It is the default on Windows, and it needs the @windowkit/win32
native bridge; without the bridge, 'auto' falls back to X11. The examples
run on it, except wm and xeyes, which are X11 window-manager demos.
windows-integrations.md is the row-by-row status of what an app can reach on Windows. Why the backend is built the way it is — the threading model, the rendering internals, the plan and its open questions — is the design record: design/windows.md.
Choosing a backend
createRoot({ backend }), or REACT_X11_BACKEND without touching code:
| asked | on Windows |
|---|---|
'auto' (the default) | 'win32' when @windowkit/win32 loads. Otherwise X11 through DISPLAY, with one console warning outside production, so a WSLg or Cygwin setup keeps working |
'win32' | the bridge is required: if it cannot load, createRoot() rejects with an error that lists what it tried and how to install it |
'x11' | an X server through DISPLAY. Also what 'auto' picks when you pass display or stream. Windows has no fc-match, so tell ntk where the fonts are with fontSource or REACT_X11_FONT_SOURCE |
const root = await createRoot(); // win32 on Windows, x11 if the bridge is missing
const root = await createRoot({ backend: 'win32' }); // the bridge or an error
Asking for 'win32' on any other platform throws: the backend is
Windows-only.
Installing the bridge
@windowkit/win32is anoptionalDependencyof react-x11 withos: ["win32"], sonpm install react-x11on Windows installs it and every other platform skips it. If optional dependencies were omitted, install it yourself:npm install @windowkit/win32.- Prebuilds ship for x64 and ARM64. Building from source needs Visual Studio with the "Desktop development with C++" workload.
- It is an N-API addon, so it loads under Bun (
bun.exe) as well as Node. REACT_X11_WIN32_PATH=<path>loads the bridge from a path instead of the installed package: a checkout, or the file shipped beside a compiled executable (see Packaging and identity).- An older bridge degrades feature by feature rather than failing: a missing
verb answers "unsupported" (for example,
useWindowState()seesfalse, and a<glarea>'s children are not drawn). - Built and measured on Windows 11. Windows 10 is not a verified target.
Threads and the event loop
The bridge runs every window on a UI thread of its own, and your JS stays on
Node's main thread. Windows' modal loops — a live resize, a native menu, a
drag (DoDragDrop does not return until the drop), a file dialog — run on
that UI thread, so JS is never frozen by one: timers fire, sockets read,
React commits and frames present while the user drags or a dialog is open.
This is the main behavioural difference from the Cocoa backend's pump mode.
Events reach JS as soon as they happen, without waiting for a timer tick, and
a handler's setState commits on the event that caused it.
Rendering
- Every window is composited by DWM, so
transparentalways works anduseSupports('transparency')is always true — there is no "if a compositor is running". - ClearType on opaque windows, grayscale on transparent ones. Direct2D
draws subpixel text only onto a surface whose alpha is ignored, so text in
a
<window transparent>or<popup transparent>is grayscale-antialiased. - Frames are paced by DWM's compositor clock, at the refresh rate of the
monitor under the window;
frameRateworks as on the other backends. An idle app polls nothing. - Only damaged rectangles are repainted, and scrolling is a blit, as on X11 and Cocoa.
- Reading pixels back:
window.snapshot()works once the window has been composed. It rejects for a minimised or not-yet-composed window; draw into an offscreen surface and read that instead. <video frames>draws;<video src>does not —useSupports('mediaPlayback')is false here.- Debugging:
REACT_X11_WIN32_DEBUG=1reports every frame and what it drew into, the size each paragraph is shaped at, and drag and drop;REACT_X11_WIN32_FULL_REPAINT=1paints the whole window every frame, the control for a damage bug.
Scale and DPI
- Windows are per-monitor-v2 DPI aware whatever
node.exe's manifest says, so nothing is bitmap-stretched and layout runs in device pixels. - Fractional scale is the normal case (125%, 150%, 175%). Layout rounds to whole device pixels, so a one-logical-pixel hairline at 1.25× lands on one device pixel or two depending on where it falls (scale.md).
useScale()is the primary monitor's scale when the app starts, static for the life of the root as on every backend, and every window is laid out at it.useScreens()lists every monitor; itsavailableis the monitor's exact work area.- Not yet: live per-window scale. A window on, or dragged to, a monitor at another scale is not re-laid out at that scale: it stays sharp but is the wrong physical size there.
Text: DirectWrite behind the engine contract
Text is DirectWrite end to end — font matching, fallback, shaping, bidi, line breaking, hit testing — so it looks like the platform's text, the way the Cocoa backend uses CoreText.
- Generic families:
sans-serif,system-uiandui-sans-serif→ Segoe UI;serif→ Times New Roman;monospaceandui-monospace→ Consolas;cursive→ Segoe Script. - Font stacks resolve to the first family installed. A face filed under another family ("Arial Black", "Segoe UI Light") is found as GDI finds it, and Courier/Courier New, Times/Times New Roman and Arial/Helvetica stand in for each other.
- In laid-out text, characters the font lacks fall back to a system font,
as DirectWrite layouts do.
app.fonts.fallbackFor()answersnull(not bound yet), so code drawing its own glyph runs gets no fallback face. loadFontandopenFontload the app's own fonts, variable axes kept;style.variationssets axes.- Measurements are rounded up to whole device pixels, and lines break at the same opportunities as on Cocoa, but metrics are DirectWrite's: expect a pixel or so of difference from the X11 backend's layouts.
Input
Pointer
- All five mouse buttons and the wheel. A drag that leaves the window keeps receiving motion while a button is held.
- A precision touchpad sends fractions of a notch; those arrive as wheel
events with
smooth: true. - Motion and wheel are coalesced to one per frame, as on X11.
- Not yet: pen- and touch-specific events, touchpad inertia, pinch.
Keyboard
- Key events carry the same X keysym vocabulary,
codepointandkeyas on every backend; the editing, navigation, function and keypad keys have their X keysyms. - Dead keys compose in the layout, as the layout defines them; the backend never consumes the dead-key state behind your back.
- Tab and Return never insert a control character into a field.
IME
Input methods work, through IMM32 — Chinese, Japanese and Korean composition
reach the same onCompositionStart/Update/End events a Wayland
text-input drives, so one handler serves both.
- The preedit is drawn in the field, laid out and styled with the rest of the text; Windows' floating composition window is suppressed. The candidate list is the input method's own and follows the caret.
- A commit arrives before the next preedit, and the active clause is the
preedit cursor (
cursorBegin/cursorEnd), as on Wayland. - A
sensitivefield is not offered to the input method at all, so its text never reaches a word history — as Windows does for its own password boxes. - What IMM32 does not carry: modern input methods are TSF text services reaching IMM32 windows through a compatibility layer, and that layer drops voice typing (Win+H inserts nothing), the shell's handwriting panel and shape-writing on the touch keyboard. A TSF text store, which would close those, is not built. Reconversion is not wired up either.
REACT_X11_TRACE_IME=1prints a line per input-method event.
Windowing semantics: what maps, what bends, what breaks
Maps:
title,width,height,x,yand the natural size.x/yare virtual-screen coordinates, negative left of and above the primary monitor.minWidth/minHeight/maxWidth/maxHeightandresizablebecome the window's size limits.fullscreen,alwaysOnTop, maximise and minimise use Windows' own calls; full screen is a borderless monitor-sized window that remembers the frame it came from.transparent— always available (Rendering).appId— the window's AppUserModelID (Packaging and identity).<popup>— no taskbar button, never takes activation, placed against the monitor's work area as everywhere.- Drag and drop, both directions:
dropAccept,onDrag*anddragDatahold, and JS keeps running through the drag. Windows itself refuses a drop from Explorer into an elevated app.
Bends:
<popup grab>: there is no pointer grab on Windows, so a press in another of the app's windows, or the app losing activation, closes the popup instead. The gap: a press on one of the app's own title bars or resize borders does not, so a menu stays open while its window is dragged.states:shadedandstickyhave no Windows call and resolve false, whichuseWindowState()reports.focusedcan be refused by Windows' foreground rules, and then simply does not change.embeddableand<foreign>: see Embedding.- The global menu: Windows has none; draw a
<MenuBar>.
Inert — accepted and ignored, so shared code stays branch-free:
decorations={false}, today. A titlebar drawn in the window and moved fromonMouseMoveloses snapping and the Snap Layouts flyout.transientFor,windowType.grabKeyboard: a popup gets keys only while it is the foreground window.onClientMessagenever fires; there is no X.
Breaks, by design: window managers (examples/wm.jsx) — DWM owns every
frame — and ssh -X remoting, for which the X11 backend stays the answer
(remote.md).
The desktop around the app
No D-Bus or portals here: each service has a Windows rung or an honest floor. windows-integrations.md has the measured status of each; in short:
- Work:
useFileDialog()(Windows' own dialog, owned by the window that asked),useSystemAppearance()(light/dark, accent, high contrast, reduced motion; the window frame follows dark mode),useTray(),useProgress(),useBadge()/setBadge()(drawn into the taskbar's overlay icon — pass one character for a dot),setUrgent(),useIdle(),keepAwake(),useLocale(). - Global shortcuts (
useAccelerator()): system-wide, no admin. A chord another app holds fails; Windows-key chords are reserved. - Notifications (
notify(),useNotifier()) are tray balloons, which the shell shows as toasts and keeps in the Action Center: plain text with a severity — no actions, click events, updates or custom icon, as the capabilities say. - Clipboard (
useClipboard()): text only — writing anything else rejects. NoPRIMARY.watch()notices a change within about 400 ms. - Windows only:
useJumpList(),useThumbnailToolbar(),useRecentDocument(), reported as features ofuseDesktopCapability('launcher'). - Answer "no":
useLauncherMenu()is inert (a jump-list task starts a new process — useuseJumpList());useAppOpen()anduseAppActivate()never fire (no file associations, URL schemes or single instance yet);useEyedropper()reportssupported: false;usePermission()answers'unknown';useDesktopCalendarEvents()issupported: falseunless the app is packaged. - Native control bezels are the system's own while the desktop is light, and drawn in dark mode, because Windows' dark common controls are not public.
Accessibility: UI Automation
Screen readers read the app through UI Automation. What an app writes —
role, aria-*, announce() — is read by the same code the Linux AT-SPI
bridge reads, so the two cannot disagree about what a node is.
- Crosses: roles, names, descriptions, states, bounding rectangles,
focus and hit testing; the Invoke, Toggle, Value and RangeValue patterns;
announce()as a UIA notification. - The bridge answers from a copy of the tree the app pushes after commits, so a screen reader never waits on a busy JS thread. Pushes are paced as accessibility.md describes.
- Missing: text ranges. A
<textinput>exposes its value, so a screen reader reads the field and hears edits, but cannot move caret by caret or word by word inside it. REACT_X11_TRACE_A11Y=1prints a line per push and action.
3D: <glarea>
<glarea> draws through OpenGL on the GPU vendor's driver, and its frames
are composited with the rest of the window, the window's alpha included.
- The context is an OpenGL ES-profile one: GLSL ES shaders, and the
WebGL 2-shaped
gl(gl.bindBuffer, …) of the X11 direct backend.@react-x11/components/threeruns on it. NoglPolicyis needed: this backend is always direct. - It needs the driver's
WGL_NV_DX_interop2, which NVIDIA and AMD drivers have and some Intel configurations lack. - Without a vendor driver — a virtual machine without GPU passthrough, a
CI runner, a fresh install before Windows Update fetched the driver, some
Remote Desktop sessions — Windows offers only the OpenGL 1.1 software
rasterizer, which has no shaders.
<glarea>then reports oneonErrornaming the reason, anduseSupports('shaders')is false. The ANGLE fallback that would cover these machines is not built. useSupports('shaders')is false for one tick and then true: the GL probe (about 120 ms) runs the first time something asks, not at startup.- A
<glarea>'s children are drawn on composited panes above the surface, translucency included (useSupports('glOverlay')is true). The surface takes no pointer input; the pointer reaches the tree.
Background: design/windows-gl.md.
Embedding: <Frame>, <foreign>, <window embeddable>
The API is the same as everywhere; what answers it differs.
-
<Frame src>works. The pane runs in its own process and draws into a composition surface the host shows;props,fallbackandrestart()behave as on every backend (frame.md). The host is the pane's parent process; a pane started some other way names it withcreateRoot({ win32: { paneHostPid } }). -
<foreign>refuses. Windows' cross-process child windows attach the two programs' input queues, so a hung guest would hang the host.useSupports('embedding')is false, and a<foreign>reports oneonError:react-x11: <foreign> needs a backend with cross-process window embedding,and this one has none — so nothing can be put in it. AskuseSupports('embedding') before rendering one.const embedding = useSupports('embedding');return embedding ? <foreign onReady={spawnInto} /> : <text>X11 only</text>; -
<window embeddable>is created and not shown, as everywhere.windowHandleOf(ref)/useWindowHandle(ref)returns a composition surface handle already valid in the host process — not an HWND, because a composition target stops presenting once its window is made a child. Pass it to the host out of band, as an XID is passed on X11. The host must bind a composition surface to a visual of its own (another react-x11 app, WinUI 3, a DirectComposition-based host); a plain Win32 or OLE document host cannot. The host is the parent process unlesscreateRoot({ win32: { paneHostPid } })names another. -
OLE embedding (a pane inside a Word document) is not supported.
Background: design/windows-embedding.md.
Packaging and identity
node app.jsworks, with two cosmetic problems.node.exeis a console program, so launched from a shortcut it opens a console window. And without anappId, the taskbar groups the app's windows with every othernode.exewindow under Node's icon, and pinning pinsnode.exe.<window appId>(or the class half ofwmClass) sets the window's AppUserModelID, per window, before it is shown. The taskbar groups by it, pinning pins it, and the jump list belongs to it. A pinned tile relaunches the command line that started the process, named after the window's title, with the executable's icon.- A single executable (packaging.md tier 3,
node --build-sea) is a copy ofnode.exewith the app inside. After the build: a manifest of the app's own withrcedit, the GUI subsystem witheditbin /SUBSYSTEM:WINDOWSso no console opens, then the signature. Windows 11 24H2 also has a manifest setting that detaches the console when launched from Explorer and keeps it in a terminal. Ship the bridge beside the executable and name it withREACT_X11_WIN32_PATH. bun build --compilehas--windows-hide-consoleand icon and metadata flags, but no way to supply a manifest;bun.exe's own manifest has no compatibility section, and what that changes for DPI and DWM is not yet verified.- Distribution: Authenticode signing, which SmartScreen's reputation follows. MSIX package identity is what unlocks the calendar, the capability-status API and declared protocol activation.
- packaging.md has no Windows tier yet.
Testing
react-x11/testruns a tree against an in-process X server or the mock, on Windows as anywhere (testing.md) — it exercises the X11 backend, not this one.window.snapshot()reads a composed window back on a real Windows desktop (above).- Test at fractional scales — 1.25 and 1.5, not only 1 and 2. Logical and device pixels part company there, and a test at scale 1 cannot tell which of the two a coordinate is in.
- GitHub-hosted Windows runners have an interactive desktop (1024×768) and no
GPU driver, so
<glarea>reports its error there; the rest of the backend on Direct3D's software device (WARP) is not yet verified on them.
Not supported yet
- Live per-window scale: a window on a monitor at another scale is not re-laid out (Scale and DPI).
- UI Automation text ranges: no caret or word navigation inside a field.
- TSF: voice typing, handwriting and shape-writing; IME reconversion.
decorations={false},transientFor, and a drag-region API for drawn titlebars (snapping, Snap Layouts).- Clipboard formats other than text: images, HTML, file lists.
- File associations, URL schemes, single instance (
useAppOpen,useAppActivate);useLauncherMenu. - Toasts with actions or click events (notifications are balloons).
useEyedropper(); the calendar without package identity.<glarea>without a vendor OpenGL driver (ANGLE).<video src>.- Compositor-side animation: transitions run on the frame clock, so a long JS block stalls them, unlike on Cocoa.
- Pen- and touch-specific input, touchpad inertia and pinch.
<foreign>, OLE embedding, window managers andssh -X— by design.