Profile
Back to NewsBack
GitHub Trending 27 min
Reader Mode
sidorares/react-x11: React renderer with X11 as a target

sidorares/react-x11: React renderer with X11 as a target

12 hours ago

react-x11

CI</a>

Documentation · Playground — edit react-x11 and watch it render, in your browser, against a JavaScript X server running on the page.

A React renderer for desktop applications, whose host is a display system rather than a document. React's job in a renderer is to compute what changed; the renderer's job is to turn that into side effects on some host — in react-dom those are DOM mutations, here they are X11 protocol requests written to a socket, Core Animation layers and CoreGraphics drawing on macOS, Direct2D drawing on DirectComposition surfaces on Windows, or GL frames handed to a Wayland compositor. There is no DOM and no browser engine underneath: this is not Electron with a different skin, and

is not an element that exists. Build GUI programs for a Linux desktop, for a display at the other end of an ssh connection, as a native Mac app or a Windows one, with your React / React Native experience — flexbox layout, components, hooks, synthetic events.

Four backends, one tree

The same components, the same hooks and the same style objects run on all of them. The two that are mature:

forwarded over ssh -X, Xvfb in CI, a thin client, or macOS through XQuartz. Everything is JavaScript all the way down: ntk / node-x11 implement the X11 protocol in pure JS (think xlib rewritten in node.js). the menu bar at the top of the screen, AppKit's own control bezels, native open/save panels, native notifications, Core Animation compositing. No X server anywhere. It rides on @windowkit/appkit, a thin mechanism-only Objective-C++ bridge — an optional dependency shipping prebuilds, absent on Linux installs.

createRoot() picks for you: Cocoa on macOS and win32 on Windows when the matching bridge is installed, X11 via $DISPLAY everywhere else — and X11 on either of those without its bridge, so an XQuartz or WSLg setup keeps working. createRoot({ backend: 'x11' }) or REACT_X11_BACKEND=x11 pins it. On macOS the app's JS runs on a worker while AppKit keeps the main thread, so a menu, a drag or a live resize never stops it — node app.js is enough, and REACT_X11_THREADED=0 keeps the main thread (docs/macos.md).

Two more backends exist beside these, serving the goal of **full cross-platform support**, and both are younger than the two above. native Wayland (docs/wayland-backend.md) — the wire protocol in JavaScript and a GLES rasterizer of its own, on mutter, sway and labwc, with no Xwayland in the path — is opt-in (REACT_X11_BACKEND=wayland) rather than automatic, because X11 stays the default on Linux: the remote case is the flagship reason this project exists and Wayland has no network transparency. Windows (docs/windows.md) — Win32 windows, Direct2D and DirectWrite, DWM compositing through DirectComposition — is the default on Windows, with the keyboard, an IME, a screen-reader bridge and most of the desktop integrations in place (docs/windows-integrations.md is the measured list). Each is a backend beside the others rather than a replacement for any of them: the same tree, the same components, the same style objects.

Layout is yoga-layout (WASM) on every backend, and text shaping is fontkit through ntk on X11 and Wayland, CoreText on Cocoa and DirectWrite on Windows. npm install never compiles anything: the X11 stack is JavaScript all the way down, and the native addons in the tree — the Cocoa and win32 bridges, and x11-dri for direct GL and the Wayland socket — are optional dependencies that ship prebuilt. And npm test doesn't even need an X server (node-x11 ships an in-process pure-JS X server that the tests render into and read pixels back from; every screenshot below was rendered that way too, by driving the real examples through the real event pipeline).

On X11, the wire carries drawing, not pixels

react-x11 does not rasterize a frame on the client and ship the buffer across. React reconciles the component tree, the renderer turns that diff into drawing operations — rounded rectangles, composited gradients, clip regions, runs of glyph indices — and the X server executes them. The server owns the pixels; the client never had them.

That is what X's RENDER extension is for. Text is shaped once and its glyphs uploaded once, so drawing a line afterwards names them by index, about a byte per glyph; gradients, scaling, alpha compositing and clipping are single server-side requests rather than loops over a pixel array; nothing is read back. An update costs what the _drawing_ costs, not what the window's area costs. Going full-screen on a 4K panel does not multiply your bandwidth, because you were not sending pixels at 1080p either — which is why this stays comfortable on a display forwarded over ssh. Mounting a window with forty rows and their labels is 110 requests and 4.2 KB on the wire, and stalls the pipeline on none of them.

Because that is the design it is measured rather than assumed: npm run bench reports requests, bytes, replies, blocking round trips, RENDER composites and the pixel area those composites touch, against a checked-in baseline (scripts/bench/baseline.json, which is where the numbers in these docs come from).

The Cocoa backend inverts this, and design/macos.md is the design record for why: there is no wire, the WindowServer is a retained compositor, and a React commit maps onto property mutations of persistent objects. Scrolling is a layer's bounds.origin rather than a repaint, animations run in the render server while the JS thread is busy, and Retina is composited at native resolution instead of quadrupling every rasterized pixel.

Where this fits

X11 is the wire protocol, which means the display can be somewhere the program is not, and the program can be somewhere a browser engine cannot go. That is the shape of the problem this is good at:

  • the display is elsewhere — a headless server over ssh -X, a
container pointed at the host, a thin client, an X terminal, a deliberately dumb workstation. A whole window appears for about four kilobytes, because what crosses the link is drawing rather than pixels (docs/remote.md);
  • the machine cannot afford a browser engine — a kiosk, an appliance, an
instrument panel, an ARM board with 512 MB, a locked-down box where installing must not compile anything and root is not on offer;
  • you want the UI in the same process as the rest of your program —
fs, serialport, pg and your components in one heap, one event loop, no IPC bridge and no second bundler;
  • you want GUI tests that run in CI with no display server — npm test
here renders real pixels through the real protocol into node-x11's in-process X server, on a machine with no $DISPLAY, on macOS. That harness is published as react-x11/test (docs/testing.md).
  • you want a Mac app out of the same source — the Cocoa backend is a
real one: system menu bar, native panels and notifications, layer-backed compositing, and a Developer-ID-signed .app from either node or bun (the App Store's sandbox takes node only — docs/packaging.md).

And the shape it is not good at, so you can stop here rather than in week three:

  • Windows — today. The Windows backend is young, and further along than
that sounds. Win32 windows composited by DWM through DirectComposition, Direct2D and DirectWrite behind the same contracts the Cocoa backend proved, OpenGL, the keyboard and all five mouse buttons, popups, the clipboard, file dialogs, the tray, the taskbar, global hotkeys, notifications and window states — on a mechanism-only bridge shaped like the Cocoa one, with a test suite and CI of its own. The examples run. Drag and drop works both ways with the desktop, and the taskbar's own surfaces — a thumbnail toolbar, a jump list, recent documents — are there behind a capability, so an app that also runs elsewhere asks useSupports() rather than the platform. CJK input goes through an IMM32 input method and screen readers through a UI Automation provider of the project's own, both with gaps worth reading before you rely on them: no voice typing or handwriting (those want a TSF text store), and no caret-by-caret reading inside a text field yet. Nor is there activation — file associations, URL schemes and second launches — so an app that needs any of that on Windows _this month_ still wants Electron or Tauri. docs/windows-integrations.md is the measured status and design/windows.md the design. The three targets are not the same app: the desktop-shell half of X11 ( embedding, panel struts, substructure redirect, the window-manager example below) has no macOS or Windows equivalent, and react-x11/test drives the X11 backend only. docs/macos.md says which is which.
  • native Wayland — today. The native backend is opt-in and young:
windows, text, input, an input method, the clipboard, popups, drag and drop and layer-shell docks and panels run on mutter, sway and labwc, and docs/wayland-backend.md lists what is left — no multisampling, no panes, no drag icon. Ordinary application windows also work on a Wayland desktop through Xwayland, which is not going away — but the desktop-shell half of X11 ( embedding, global key grabs, and the window-manager example below) needs a real X session. It is a target beside X11, not a migration; design/wayland.md is the RFC it was built from.
  • reusing web components. There is no DOM. Your MUI, your Tailwind and
your recharts do not come with you; the state, data-fetching, validation and math libraries mostly do. docs/ecosystem.md says which is which, and what the failure looks like when it is the wrong one.
  • being a browser. There is no webview and no browser engine to embed.
HTML is a component rather than a substrate: in @react-x11/components renders an HTML + CSS document with a layout engine of its own — selectable, with real widgets for its form controls, and with a page's scripts run against a DOM when scripts turns them on — and beside it renders markdown. Both are for documents an app shows; an app that _is_ a web app still wants Electron or Tauri. The screenshots below are the X11 backend, rendered headlessly into node-x11's in-process server by npm run screenshots — which is why they are pixel-stable enough to check in.

| examples/dashboard.jsx — context theming, hooks | examples/tasks.jsx — useReducer, textinput, scrolling | | ------------------------------------------------- | ------------------------------------------------------- | | !dashboard | !tasks |

| examples/form/index.jsx — textinput + Select | the open Select menu (a real window) | | ---------------------------------------------- | ---------------------------------------------- | | !form | !select menu |

examples/viewer3d.jsx — a model viewer over indirect GLX: the GL protocol sent over the X connection, geometry compiled into a display list, a frame costing two matrices and one CallList. No native bindings, no GPU driver bindings — the same "JavaScript all the way down" story as the rest. (GL renders where the X server cannot read it back, so there is no screenshot of it here; see docs/gl.md.)

Quick start

npm install react-x11 react
import React, { useState } from 'react';
import { createRoot } from 'react-x11';

function Counter() { const [n, setN] = useState(0); return ( <window width={240} height={120} title="counter" style={{ backgroundColor: '#f4f4f4' }} > <box style={{ flexGrow: 1, alignItems: 'center', justifyContent: 'center', gap: 10, }} > <text style={{ fontSize: 24 }}>{String(n)}</text> <box style={{ backgroundColor: '#2980b9', borderRadius: 6, padding: 8, cursor: 'pointer', ':hover': { backgroundColor: '#1f6693' }, }} onClick={() => setN(n + 1)} > <text style={{ color: 'white' }}>+1</text> </box> </box> </window> ); }

const root = await createRoot(); // Cocoa on macOS, else X11 via $DISPLAY root.render(<Counter />);

Run it with tsx or any JSX-capable loader — or skip JSX entirely with React.createElement (see examples/simple-nojsx.js, plain node, no build step).

Elements

Only , , and are real windows of the display system. Everything else is a retained lightweight node — one yoga node each — drawn client-side into the owning window's double-buffered 2d context, with events dispatched by front-to-back hit testing over the drawn tree. See docs/elements.md for the full reference.

| element | what it is | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | | a real toplevel — an X11 window, or an NSWindow; the flex, paint and event root | | | an undecorated window placed at screen coordinates — menus, tooltips, dropdowns. anchor places it against a node; grab and trapFocus make it modal | | | flex container: layout props → yoga, plus backgrounds, borders (solid/dashed, radius), overflow clipping, zIndex. overflow: 'scroll' makes it a wheel-scrollable viewport with a drawn scrollbar | | | shaped, wrapped text (bidi, ligatures, font fallback); nested elements are style spans | | | single-line editor: caret/selection, clipboard, word select, undo/redo, right-click menu | |