Examples

August 27, 2026 · View on GitHub

Runnable demos, each a single file you can read top to bottom. They all need an X server — a Linux desktop, XQuartz on macOS, or Xvfb/Xephyr for something disposable — and they all read $DISPLAY.

npm run examples:simple        # start here

Every example also exports its App (and most export a panel) and skips auto-running under REACT_X11_NO_AUTORUN=1, so the screenshot scripts and tests can import them without opening a window.

The tour

Roughly in the order worth reading them:

simple.jsxhello world: flex layout and text, no pixel math. npm run examples:simple
simple-nojsx.jsthe same thing in plain node with React.createElement — no build step at all
xeyes.jsxthe <canvas> escape hatch: custom drawing plus hooks for state and polling
dashboard.jsxcontext for theming, useState/useEffect/useMemo, a custom hook, hover and focus states
tasks.jsxuseReducer, dispatch through context, list rendering, scrolling <box>, keyboard throughout
form.jsx<textinput>, Select, RadioGroup, Slider, Checkbox, and a modal Dialog
rules.jsxa WHEN/THEN rule builder: a recursive tree that edits itself, with drag-to-reorder and <svg> icons
widgets.jsxthe gallery: every standard component in one window, <textinput> and <textarea> included
monitor.jsxa process monitor, and the answer to "one keystroke changes hundreds of things — how do I keep the field responsive?" Two elements of its own via registerElement. Tested in test/monitor.test.js
chat.jsxthe same features doing their actual job: an IRC-shaped client where <Activity> is what keeps a draft and useOptimistic is what makes a send feel instant. Tested in test/chat.test.js
finder.jsxa file browser: drag files out and in, copy and paste them, a windowed 50,000-entry listing, and a terminal embedded in the window with <foreign>. Tested in test/finder.test.js
settings.jsxa preferences window: search that finds settings rather than pages, values that can be invalid, a reset that knows the default, and a language picker that mirrors the whole window. Tested in test/settings.test.js
timer.jsxeverything an app does besides draw: a single instance, a com.example.x11timer:// deep link, a desktop notification, keep-awake, and a compatibility ladder for the machine with none of them. Tested in test/timer.test.js
fonts.jsxa font explorer: what fc-match ranks for a query, the metrics react-x11 measures with drawn as guides, per-glyph shaping under the pointer, and a specimen with gradient ink and a server-side blurred shadow. Tested in test/font-explorer.test.js
configurator/a configure-to-order store page, styled like a current marketing site: display type from OFL fonts pulled in as devDependencies and loaded by path (loadFont, identical on every machine, nothing in git), hairline cards with boxShadow depth, size-query responsiveness, a live price, and a <glarea> product shot the scroll position opens and turns — with a flat <box> laptop for machines whose GL has no shaders. Tested in test/configurator.test.js
viewer3d.jsxa model viewer on raw <glarea>: indirect GLX, where geometry belongs in a display list and a frame is two matrices and one CallList. Tested in test/viewer3d.test.js
foreign.jsx<foreign>: another application's window in the layout — spawn a program into a pane, then let go of it
selection.jsx<box selectable>: selecting text across blocks, and the PRIMARY selection no headless test can reach
menu.jsxMenuBar and ContextMenu over real <popup> windows that flip at screen edges — File → Open… is a real file dialog, and the bar moves to the desktop's panel where there is one (npm run globalmenu:host)
tooltips.jsxhints that are text and hints that are components, direction="auto", and the arrow an ARGB popup can have
theming.jsxthe style engine end to end: three themes in light and dark, switched at runtime
appearance.jsxfollows the desktop's light/dark, accent, contrast and reduced motion — change your theme while it runs
windows.jsxmany top-level windows from one React tree, sharing state, closing via onCloseRequest
app.jsxthe showcase: SplitPane + Tabs hosting form, widgets and tasks as panels
icons.jsx400 icon-sized <svg>s that reflow and scroll, with a cached-glyph control — a paint benchmark
dnd-source.jsxdrag out of the app (XDND) + in-app drags with live payloads, a <popup> drag preview
dnd-target.jsxdrop zones: dropAccept groups, parsed e.files, custom MIME types, useDropTarget state
raster-gate.jsxa wall of live controls with ntk's local/server rasterization routing on a switch (ntk#177)
stress/the big one: six panels to poke at by hand, with a frame log — see below
wm.jsxa reparenting window manager — see below

app.jsx is where a new control should get demonstrated: it imports the panel each of form, widgets and tasks exports, so adding a widget there shows it off without yet another example file.

The stress app

npm run examples:stress            # the app, with a frame log per repaint
npm run examples:stress -- --quiet # only frames over 50kpx
npm run stress:check               # headless: does it still all render?
npm run stress:check -- --png      # ...and write a PNG per panel to /tmp

Six panels, built to be poked at by hand for correctness and for performance:

panel
Typographyevery text style axis on one paragraph, bidi and inline spans
Chartsfour SVG drawings whose geometry is recomputed from sliders
Dataa windowed 50,000-row table beside a table that ticks on a timer
Controlsevery component, in deliberately awkward nestings
Damagea cell grid with a chosen number of changes per commit
Mixedall of it at once, animating, for the worst case

Each panel's file opens with a "what to look for" list — the things that are easy to get subtly wrong and easy to miss unless you know to check.

The frame log goes to the terminal, not into the window. A HUD drawn in the window would claim damage every frame and so change the very number it was reporting; the same trap caught the Damage panel itself, which used to carry a live step counter until the counter's own re-measure turned every step into a full repaint. Read the log next to the window:

  frame  damage rect                            area      paint
     14  82x63 @ 210,404                        5.2kpx    0.8ms
     15  … ×4
     16  FULL WINDOW                            700.0kpx  9.1ms
     17  40x26 @ 8,96 + 40x26 @ 941,632  [box +9186%]  2.1kpx  0.6ms

FULL WINDOW is correct for a resize, a tab switch or anything that changes layout — text that re-measures makes the frame full by definition. It is a regression anywhere else.

A frame that changed things far apart from each other paints a rect each rather than the box around them, and prints them all. [box +N%] is how much bigger a single box would have been — the Damage panel's "scattered" mode is there to produce large numbers for it on purpose.

Wire-level numbers (requests, bytes, Composite pixels) are not here on purpose: npm run bench drives the same paths against an in-process server where a byte count is reproducible.

The two GL examples need a server with indirect GLX enabled — it is off by default nearly everywhere. Xorg takes +iglx on the command line or AllowIndirectGLX in the config; XQuartz has its own setting:

defaults write org.xquartz.X11 enable_iglx -bool true   # then restart XQuartz

Fonts on macOS

If the demos come up in what looks like a Japanese system font — and Cyrillic in particular renders spread out, one full-width advance per letter, while Latin looks fine — the wrong fc-match is first on your PATH. ntk resolves font families by shelling out to it, and Homebrew's fontconfig ships no macOS system-font aliases, so its idea of sans-serif is close to arbitrary: it answers Hiragino Sans, with Comic Sans as the runner-up. XQuartz's fontconfig is the one whose configuration matches the server you are drawing to.

PATH=/opt/X11/bin:$PATH npm run examples:theming

fc-match sans-serif from each of them shows the difference. Tracked in #86 — nothing in the source works around it yet.

Hot reloading

npm run examples:tasks:hot     # then edit examples/tasks.jsx while it runs

Edited components update in place through React Fast Refresh — the connection, the window and component state (the task list, half-typed input) all survive. The example runs under the supported entry point (node --import react-x11/refresh/register): tasks-hot.jsx is an ordinary entry, and tasks-context.js exists so context identity survives a reload. The constraints on what may live inside a hot module are enforced by the loader and documented in docs/ecosystem/dev-tooling.md.

The window manager

wm.jsx is a real reparenting window manager: it takes over the root window, puts every application's window inside a frame it draws, and moves, resizes, focuses and closes them.

  • wm-core.js — the protocol half: claim the root, answer map and configure requests, keep the client list, EWMH, alt+tab
  • wm.jsx — the React half: frames, taskbar, drag gestures

Every frame is an ordinary <window>. The titlebar, its three buttons and the eight resize handles are components with onMouseDown handlers, and the application being managed is a foreign X window reparented inside.

Drag the titlebar to move and any edge or corner to resize. The three titlebar buttons are minimize, maximize and close, in that order; double-clicking the titlebar also maximizes. Click anywhere in a window to focus and raise it, or alt+tab to cycle. Minimized windows go to the taskbar and come back when you click them there.

Where the icons come from

The icon in each titlebar and taskbar entry is the application's own, and there are two standards for finding it — clients use one or the other, so a window manager that reads only one shows blanks for half of them:

  • _NET_WM_ICON (EWMH, modern) — ARGB pixels in a property, in as many sizes as the application cares to offer. This is what GTK and Qt set, and the window manager picks the smallest size that is still big enough.
  • WM_HINTS (ICCCM, older) — the property names a pixmap living on the server, optionally with a 1-bit mask saying which of its pixels count. This is what xterm, xclock and xlogo still set, so it is the one you can see working locally. A 1-bit icon carries no colour of its own and is drawn as a stencil in the titlebar's foreground.

A third route exists on Linux desktops and is deliberately not implemented here: match WM_CLASS against a freedesktop .desktop file and look the Icon= name up in an icon theme. That is how applications that ship no icon at all still get one, but it is a filesystem convention rather than anything X knows about.

Only one window manager may run on a display at a time, so it cannot just be started alongside the one you already have — it will exit with another window manager is already running on this display. Pick one of the two ways below.

Xephyr is an X server that displays in a window on your existing desktop. Nothing outside that window is affected, and you can kill it at any time.

Xephyr :10 -screen 1200x800 &          # a 1200x800 "screen" in a window
DISPLAY=:10 npm run examples:wm        # the window manager owns :10
DISPLAY=:10 xterm &                    # give it something to manage
DISPLAY=:10 xclock -geometry 200x200 &

Any X client works, including the other examples in this folder:

DISPLAY=:10 npm run examples:widgets

On Linux, Xephyr is usually in a package called xserver-xephyr or xorg-x11-server-Xephyr. On macOS it ships with XQuartz at /opt/X11/bin/Xephyr, and runs inside your XQuartz session.

macOS: if DISPLAY=:10 fails with ECONNREFUSED, use DISPLAY=unix/:10. node-x11 used to read a plain :N on macOS as TCP port 6000+N; unix/ forces the socket and works on every version.

Replacing the window manager you already have

If you want it managing your real session, the previous window manager has to go. How depends on the platform.

Linux. Start a bare X session and make this the window manager it launches, rather than fighting a desktop environment:

echo "cd $PWD && exec npm run examples:wm" > ~/.xinitrc
startx

Replacing the window manager of a running session works with the standalone ones (openbox, i3, xfwm4 — stop it, start this) but not under GNOME, where mutter is the session compositor and killing it logs you out.

macOS. XQuartz starts quartz-wm from /opt/X11/etc/X11/xinit/xinitrc.d/99-quartz-wm.sh, whose first line is a hook for exactly this:

[ -n "${USERWM}" -a -x "${USERWM}" ] && exec "${USERWM}"

So point USERWM at an executable and XQuartz runs it instead of quartz-wm. Do not just kill quartz-wm: that script execs it, making it the X session's main process, so killing it takes the whole session down.

  1. Write a launcher, somewhere stable, and make it executable:

    cat > ~/react-x11-wm.sh <<'EOF'
    #!/bin/sh
    # XQuartz hands us DISPLAY=:N, which older node-x11 reads as TCP on macOS.
    # Harmless once that is fixed, and it cannot be worked around from outside:
    # the window manager is started *by* the server.
    [ "${DISPLAY#*/}" = "$DISPLAY" ] && DISPLAY="unix/$DISPLAY"
    export DISPLAY
    cd /path/to/react-x11
    exec node --import ./node_modules/tsx/dist/loader.mjs examples/wm.jsx \
      > /tmp/react-x11-wm.log 2>&1
    EOF
    chmod +x ~/react-x11-wm.sh
    

    Use an absolute path to node if you use a version manager — XQuartz is launched by launchd and will not have your shell's PATH.

  2. Put it in the environment launchd hands to XQuartz, then restart XQuartz so the new session picks it up:

    launchctl setenv USERWM ~/react-x11-wm.sh
    osascript -e 'tell application "XQuartz" to quit'
    open -a XQuartz
    
  3. Open something to manage — XQuartz's Applications menu, or:

    xterm &
    

    /tmp/react-x11-wm.log has anything that went wrong.

To put quartz-wm back:

launchctl unsetenv USERWM
osascript -e 'tell application "XQuartz" to quit'
open -a XQuartz

XQuartz runs rootless by default: each top-level X window becomes a macOS window and there is no visible root, so the desktop background and the taskbar have nowhere to appear. For the full effect turn that off before restarting —

defaults write org.xquartz.X11 rootless -bool false

— which gives a real full-screen X root; XQuartz's menu then has an item for switching between it and macOS. Set it back to true when you are done.

Writing your own

The things that are easy to get wrong — why a frame must not unmount while it holds a client, why the frame needs redirecting too, why ButtonPress cannot be selected on a client window — are collected in AGENTS.md. test/wm.test.js drives the whole thing headlessly against node-x11's in-process X server, which is the fastest way to try a change.