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

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

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, or Core Animation layers and CoreGraphics drawing on macOS. There is no DOM, no HTML 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, or as a native Mac app, with your React / React Native experience — flexbox layout, components, hooks, synthetic events.

Two backends, one tree

The same components, the same hooks and the same style objects run on both:

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 when the bridge is installed, X11 via $DISPLAY everywhere else, and X11 on a Mac without the bridge so an XQuartz setup keeps working. createRoot({ backend: 'x11' }) or REACT_X11_BACKEND=x11 pins it.

Layout is yoga-layout (WASM) on both, and text shaping is fontkit on the X11 side, CoreText on the Cocoa one. **npm install never compiles anything**: the X11 stack is JavaScript all the way down, and the two native addons in the tree — the Cocoa bridge and x11-dri for direct GL — 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 docs/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 servernpm 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. There is no Windows backend and none planned; if you need
Windows, use Electron or Tauri. Two targets ship — X11 and Cocoa — and they are not the same app: the desktop-shell half of X11 ( embedding, panel struts, substructure redirect, the window-manager example below) has no macOS equivalent, and react-x11/test drives the X11 backend only. docs/macos.md says which is which.
  • native Wayland — today. There is no Wayland backend yet. Ordinary
application windows work fine on a Wayland desktop through Xwayland, which is not going away — but the desktop-shell half of X11 (panel struts, global key grabs, screen capture, and the window-manager example below) needs a real X session. A native backend is researched and planned as a second target beside X11, not a migration: docs/wayland.md is the RFC, from the fd transport up to what the rendering would ride on.
  • 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.
  • rendering HTML. There is no HTML element and no webview. Rich
documents are markdown, through in @react-x11/components. 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/glx.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 | |