@react-x11/components
v0.9.0
Published
Components for react-x11 that do not belong in the core package
Maintainers
Readme
@react-x11/components
Components for react-x11 that do not belong in the core package.
Everything here is built on react-x11's public API — the built-in host
elements, or the registerElement seam in react-x11/host. Nothing here
needs a change to core to exist, and core does not grow to carry it.
Documentation — one
reference page per component, rendered from docs/. This
README is the tour; that is the detail.
Installable now. The published release is
0.8.0, and the react-x11 range it declares resolves off the registry, sonpm installjust works.mastercarries what has landed since, so use a checkout if you want something not in that release yet.
What is here, and what is in core
react-x11 itself carries an element or component when any of these hold:
- the vast majority of UI apps use it;
- it depends on renderer internals — implementing it outside would mean exposing details that should not be public, or giving up performance;
- it needs enough standards compliance that the behaviour is hard to agree on or implement piecemeal.
This package carries it when all of these hold:
- a smaller fraction of apps need it;
- it can be built on the public react-x11 API;
- it is big enough that core would pay for it, in install closure or in maintenance.
So <box>, <text>, <window>, buttons, menus, dialogs and the rest of the
widget set are core. Heavier, more specialised things live here.
The line can also fall inside a single feature. <glarea> is core — a real
child surface on a GL visual, created in the commit phase, which is renderer
internals whichever backend is under it. A Three.js-shaped scene graph drawn
into it is not: that is composition over a public element, and it belongs
here.
Two backends, and the three components that only run on one
react-x11 has two backend families now — X11, and a native macOS one that
speaks to Cocoa with no X server anywhere (createRoot({ backend }), or
REACT_X11_BACKEND). Almost everything in this package is neutral about
which, because almost everything here is either composition over core's
host elements or a registered element that draws through core's 2D context,
and both are backend contracts rather than X ones. <Map>, the vt terminal,
<Flow>, <Html>, <Markdown>, the charts and the rest render on either.
The exceptions are the components built on XEmbed, and they are
exceptions because cross-process window embedding does not exist on macOS at
all — there is no <foreign> to build on, which react-x11's own
docs/macos.md
names this package in as much:
| Component | On the Cocoa backend |
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <Terminal>, embedded backends (xterm, urxvt, alacritty) | No equivalent. backend="auto" skips them and lands on backend="vt", which is native and needs no emulator installed anyway; one named outright reports status: 'unavailable' and renders fallback. |
| <MediaPlayer> | No equivalent — mpv's --wid and VLC's --drawable-xid are X mechanisms. Reports status: 'unavailable' and renders fallback. |
| <TrayHost> | Reports status: 'unavailable' and renders fallback; there is no manager selection to take. Putting an icon in a Mac's status bar is the other direction, and is core's useTray(). |
<Three> is the one that looks like it should be on that list and is not: it
draws through <glarea>, which the Cocoa backend implements as GL into a
CALayer, so the scene graph runs there too — on a third GL path rather than
none. Its reference has the table.
All three find out from the app, not the machine. <Terminal backend="auto">
asks whether the app can host an embedded window before it probes PATH, so
a Mac with XQuartz's xterm installed still gets the vt terminal on the Cocoa
backend, and an app that runs on both can leave backend alone. An emulator
named outright is refused out loud: status: 'unavailable', fallback, and
an EmbedUnsupportedError for onError.
The other X-shaped behaviour to know about is PRIMARY: selecting text publishing to the X PRIMARY selection, and middle-click pasting it, are what X11 desktops do and what this package's document surfaces take part in. On macOS there is one pasteboard and no such convention, so what is described as PRIMARY below is the X11 backend's half of the story.
Install
npm install @react-x11/components react react-x11react and react-x11 are peer dependencies — deliberately. Registering a
host element mutates state inside react-x11, so a second copy of the renderer
would leave you with an element that lays out correctly and never paints.
Core must be 2.11.0 or newer. The floor is a running one rather than a
one-time gate — it moves whenever a component here adopts something core
just landed, and the last few moves were the Cocoa glyph-run seams
(^2.5.0), the chunked Cocoa stroke <Map> wanted (^2.6.1), the desktop
calendar's move into core (^2.9.1) and the eyedropper's macOS rung
(^2.11.0). The subpaths this package imports are react-x11 itself plus
/host, /node, /style, /keysyms, /ntk, /yoga and
/jsx-runtime.
Usage
import { Code } from '@react-x11/components';
function App() {
return (
<window width={480} height={240} title="components">
<box style={{ flexGrow: 1, padding: 16 }}>
<Code
source={'const x = 1;\nconsole.log(x);\n'}
lang="ts"
lineNumbers
/>
</box>
</window>
);
}Importing a component is what teaches react-x11 its element, so there is no setup call to remember and no registration to run at startup.
Tree-shaking
Use one component, pay for one component. Each is its own module with its own
entry point, the package declares "sideEffects": false, and importing the
barrel for nothing at all bundles to nothing. That last property is a test in
this repo, not an aspiration.
Deep imports work too, for apps without a bundler:
import { Code } from '@react-x11/components/code';TypeScript
The package is written in TypeScript and ships its own declarations, so
there is no @types package to install. Point your compiler at react-x11's
JSX namespace and the host elements type-check:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react-x11"
}
}Importing a component teaches JSX its element too, so <codeeditor> is a
typed tag as soon as CodeEditor is in scope. Props types are exported
under their component's name:
import type { CodeEditorProps } from '@react-x11/components';Components
| Component | Import | |
| ---------------- | ---------------------------------------- | ------------------------------------------------------------ |
| Calendar | @react-x11/components/calendar | A month grid: one date or a range, any day blockable. |
| DatePicker | @react-x11/components/calendar | That calendar on a popup, behind a field. |
| LineChart … | @react-x11/components/charts | Cartesian charts; a million points is a normal input. |
| ColorPicker … | @react-x11/components/color-picker | A colour input: field, hue, alpha, swatches, eyedropper. |
| Code | @react-x11/components/code | A static code block: highlighted, selectable. |
| CodeEditor | @react-x11/components/code-editor | Multiline code editing: highlighting, completion. |
| Flow | @react-x11/components/flow | A directed-graph editor: nodes, edges, pan and zoom. |
| Formula | @react-x11/components/formula | TeX mathematics: KaTeX layout, native ink, selectable. |
| Html | @react-x11/components/html | A static HTML + CSS document, selectable, with seams. |
| Map | @react-x11/components/maps | A 2D vector-tile map: pan, zoom, markers, overlays. |
| Markdown | @react-x11/components/markdown | Streaming-friendly GFM with cross-block selection. |
| MediaPlayer | @react-x11/components/media-player | mpv or VLC, embedded, with real transport control. |
| QmlView | @react-x11/components/qml | Qt's QML language as an authoring layer. No Qt. |
| ReorderList … | @react-x11/components/reorder | A drag-and-drop list, over core's own drag and drop. |
| RichTextEditor | @react-x11/components/rich-text-editor | WYSIWYG editing over ProseMirror; markdown in and out. |
| Table | @react-x11/components/table | A data table: sortable, virtualized, any row height. |
| Tabs … | @react-x11/components/tabs | One visible panel at a time, five strip styles. |
| Terminal | @react-x11/components/terminal | A real terminal: an embedded emulator, or its own. |
| TerminalOutput | @react-x11/components/terminal-output | A captured session, rendered. <Terminal>'s static sibling. |
| Canvas … | @react-x11/components/three | A three-fiber-shaped 3D scene over either GL backend. |
| Timeline … | @react-x11/components/timeline | A run of events: a mark per step, a line between. |
| TrayHost | @react-x11/components/tray-host | The system tray: applications dock their icons in. |
| Tree | @react-x11/components/tree | A disclosure tree: seams throughout, and virtualized. |
Five shared modules sit underneath and are importable on their own:
/richtext (the styled-text element a document selects across),
/codeblock (the look of a block of code, shared by <Code> and
<Markdown>'s fences), /code-language (the pluggable tokenizer seam,
the built-in languages and the token palettes), /ansi (a captured
terminal session reduced to a document of styled spans) and /embed (the
spawn, watch and hand-back lifecycle both XEmbed wrappers are built on).
Selecting text is core's, not this package's: a <box selectable> is
a surface, everything under it that answers for its own text is in the
selection, and the drag, the word and block granularities, Ctrl+A, Ctrl+C
and (on X11) PRIMARY come with it (react-x11#291). <Markdown> and <Code> set
that prop and say which parts are chrome; the elements underneath answer
textContent/textIndexAt/textCaretRect/textRangeRects, which is all
an element of your own has to do to join a document.
Charts
A shadcn/charts-shaped component set for cartesian charts — line, area, bar, scatter — with the composition you expect and a cost model you usually do not: every frame is bounded by pixels, never by points.
import {
ChartContainer,
LineChart,
LineSeries,
XAxis,
YAxis,
CartesianGrid,
ChartTooltip,
ChartLegend,
} from '@react-x11/components/charts';
const config = {
cpu: { label: 'CPU', color: '$accent' },
mem: { label: 'Memory', color: '#e17055' },
};
<ChartContainer config={config} style={{ height: 240 }}>
<LineChart data={rows}>
<CartesianGrid />
<XAxis dataKey="time" type="time" />
<YAxis />
<LineSeries dataKey="cpu" />
<LineSeries dataKey="mem" curve="monotone" />
<ChartTooltip />
<ChartLegend />
</LineChart>
</ChartContainer>;The children are config carriers, recharts-style; one registered element
paints the grid, the axes and every series in a single pass. data takes
rows (shadcn-familiar), columns ({ length, columns } of typed arrays —
the fast path), or a ChartData streaming store whose appends extend the
decimation index incrementally and never rescan. A live feed should window
by time, not only by count: maxAge: { key: 't', ms: 60_000 } keeps
"the last minute", where a count window silently means "however long that
many points took" — an OS throttling a hidden window's timers leaves a
minutes-wide, points-thin era that a count window then renders as fresh
data squeezed into a sliver. Age eviction drops it on the first append
after resume, hard stalls included; ChartData.clear() is the manual
reset for switching feeds.
Pan and zoom are a controlled domain: pass <XAxis domain={[a, b]}> from
app state, and use plotRef — the imperative snap query the tooltip
itself uses — to convert a drag's pixels into domain units (the hit
carries the plot rect and the x value under any window x). The demo's
million-point chart pans by drag and zooms by buttons this way; the
pyramid keeps every frame O(width) at any zoom.
What "put a lot of effort into performance" means here, concretely:
- Off the viewport costs nothing. Core already culls the paint of
scrolled-away nodes; a
ChartDataappend to a fully offscreen chart skips even the invalidation — scrolling back repaints from current data. - Too small to see costs nothing to draw. Every series renders through a per-pixel-column min/max index (a pyramid over the data, built lazily and extended on append), so a million points in a 90px cell cost ~90 rectangles. A million points that fall on one pixel render one pixel.
- Drawing commands by default, pixels when they win. A dense line goes
out as one batched
fillRects— on X11 that is aFillRectanglesat ~8 bytes per pixel column, and the wire costonFrameStatsreports is that one; a sparse line goes as a real antialiased path. The one place a pixel push wins — a scatter covering most of the plot — is detected by comparing the actual byte costs, and flips to one composited density image.
Tooltips snap to the nearest point in O(log n) through a ref into the
element. The value bubble is a real popup window by default — anchored
to the data point through core's anchor system, stacked above everything
(content that flows after the chart included), flipped at screen edges,
never focused. <ChartTooltip mode="overlay"> keeps it as a
hit-transparent box inside the chart instead — one window, one paint
surface — with the documented trade that later siblings can overdraw
whatever part of it would have left the chart's box. The crosshair and
point markers are part of the plot and stay in-window either way, and the
hover's React re-render contributes no damage of its own. Pass
onFrameStats to see
what any frame cost: per-series mode, commands issued, estimated wire
bytes, prep and paint time. npm run examples:charts is a live tour —
streaming at 60 points/s, a million-point walk, small multiples, stacked
bars and areas, a 200k-point density scatter — with that HUD under every
chart. docs/prd-charts.md is the design record.
Pie/radial charts and a second y axis are deliberately not in this first cut; the cartesian perf story is.
Markdown
A GFM renderer built for streamed model output — the
Streamdown use case, rendered natively. Feed it a
growing source and every instant renders clean: unclosed **bold,
`code or a half-arrived [link](… never flash their raw markers, an
ambiguous --- tail is held until it can be read, an open fence is already
a code block. When the stream ends, flip partial off.
import { Markdown } from '@react-x11/components/markdown';
<box style={{ overflow: 'scroll', flexGrow: 1 }}>
<Markdown
source={streamed}
partial={stillStreaming}
onLink={(href) => open(href)}
style={{ padding: 16 }}
/>
</box>;The feature set is GFM: headings (ATX and setext), emphasis with the real
CommonMark delimiter algorithm, inline code, links and autolinks, images
(rendered as their alt text, linked to the source — no remote fetches),
nested and task lists, blockquotes, tables with alignment and measured
column widths, thematic breaks, fenced code highlighted through the same
language seam as <CodeEditor> (resolveLanguage is where tags the
built-ins do not cover come from — hljsLanguage wraps highlight.js). The parser is this package's own — no
markdown→HTML pass anywhere — and is exported (parseMarkdown) with the
AST types.
Selection is the point. Text selects across every block — drag,
double-click a word, triple-click a block, Ctrl+A, Ctrl+C — and on X11 a
mouse-up with a selection takes the PRIMARY selection, so middle-click paste
works everywhere. All of that is core's selectable (react-x11#291); what
this component adds is which parts are chrome, so copied text is clean:
list markers stay behind, and the separators come from the layout, which
for a table is exactly cells with tabs and rows with newlines. Rendering is cached
per top-level block on the raw source text, so appending to the tail
re-renders the tail alone. npm run examples:markdown streams a document
in live.
MDX, in block position. components={{ Chart }} lets a document put a
component between two paragraphs, with markdown children, mid-stream — and
evaluating nothing: an attribute is a string, true, or the JSON.parse of
a {…}. A tag is a component iff its name is a key in that map, so a
document that never passes the prop parses exactly as it did before, and one
from a stranger can only reach what you already exposed.
Adding scope={{ quarters }} is the second rung and a different statement:
{…} compiles, {...spread} works, and a brace in the prose renders its
value. There is no sandbox, so components decides what a document may
reach and scope decides whether it may compute — which matters when the
input is model output. A tag in the middle of a sentence is still text; the
inline half needs a <richtext> that can reserve advance width for an
element. docs/prd-mdx.md is the design record.
HTML
import { Html } from '@react-x11/components/html';
<box style={{ overflow: 'scroll', flexGrow: 1 }}>
<Html
source={html}
partial={false}
onLink={(href) => openInBrowser(href)}
onResource={(r) => (r.kind === 'image' ? readImage(r.url) : null)}
/>
</box>;A document an application is handed — mail, release notes, a help page, an
exported report — rendered with selectable text and real widgets for its form
controls. Block flow with margin collapsing, an inline formatting context
with full shaping and bidi, floats, lists, tables and positioning are this
package's; display: flex is Yoga's, which is already in the process.
Nothing is fetched and nothing is executed, and neither is a setting.
onResource is asked for every <img>, <link rel=stylesheet> and
@import — absent, images draw a frame at their attribute size and linked
sheets are skipped. onScript is handed a <script>'s type, src and text
verbatim; there is no parser and no sandbox, because a renderer that
half-runs a script is one nobody can reason about. An application that wants
scripting brings an engine and drives the result through the DOM handle.
The form controls are the point where this differs from every HTML widget
that came before it here: a <select> in a document drops the same menu as a
<Select> in the window around it, because it is one. They mount as
positioned siblings of the element at the rectangles layout reserved — the
escape hatch <Flow> opened for a node whose body is a form.
Unlike every other document surface in this package, <Html> draws the
document rather than composing it from <box> and <richtext>. Partly for
cost — a document is thousands of elements — but mainly because CSS layout is
not the host's layout: block flow, floats and table column sizing are not
flexbox, and composing would mean approximating the model. What it reuses
from /richtext is everything that was never about the element — the
TextRun vocabulary, the per-run decoration painter, the bidi-correct
selection bands.
handle.document is the live DOM (domhandler's tree, which domutils
speaks); mutate it and call handle.refresh(). That is explicit rather than
observed on purpose: watching a plain object graph costs a proxy per node,
and the budget went on the static render instead. npm run examples:html
drives both seams for real.
Code
The static sibling of <CodeEditor>: a read-only, selectable code block
for showing code rather than editing it.
import { Code } from '@react-x11/components/code';
<Code source={snippet} lang="ts" lineNumbers />;Highlighting goes through the same language seam (lang tag or an
explicit language={…}) and the look is shared with <Markdown>'s fenced
blocks, so the two agree in one window. Selection and copy are core's; the
line-number gutter is selectable={false}, so copied code pastes clean.
Mathematics
import { Formula } from '@react-x11/components/formula';
<Formula tex="x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}" display selectable />;TeX, rendered natively. KaTeX — an optional dependency — parses the source
into its virtual DOM, and this package's formula element lays that tree out
and draws it through the app's font manager, using KaTeX's own faces. Every
glyph answers core's four text accessors, so the mathematics is part of the
selection rather than an opaque picture: on its own with selectable, or as
one block inside any selectable document. partial holds the last tree that
parsed while more source is still arriving, which is what makes it safe to
append to a formula a model is still writing. A ```math fence in a
document becomes one through <Markdown>'s fences map, which is opt-in —
that seam is how <Markdown> hosts a component without importing it.
The reference has the rest.
A terminal session you already have: <TerminalOutput>
The static sibling of <Terminal>, exactly as <Code> is <CodeEditor>'s.
You ran something in a pty somewhere and kept the bytes; this draws what the
terminal would have drawn, with no pty, no process and no input.
import { TerminalOutput } from '@react-x11/components/terminal-output';
<TerminalOutput data={await readFile('build.log')} lineNumbers />;A log is a document, not a grid, and that is the whole design. A build
log has lines, not rows, and no column count of its own — so it renders as
styled spans in one <richtext>, which wraps if you ask, flows in a page,
and selects like any other block. Putting it on a fixed grid would mean
inventing a cols the capture never had and then wrapping at it.
\r is honoured, which is most of the value: every progress bar and
npm install line is a carriage return plus an overwrite, and a renderer
that reads \r as a newline turns a three-line install into nine hundred.
So are SGR in full (the 256 cube, truecolor, and the : sub-parameter forms,
so 4:3 curly underlines and 58 underline colours work), \e[K, the
in-line cursor moves, and OSC 8 hyperlinks — which cargo, gcc and
ls --hyperlink all emit, and which arrive clickable through onLink.
A capture from a full-screen program (vim, htop) is a different animal: those
bytes address the cursor and mean nothing except at the grid they were made
at. That case is not rendered faithfully yet, and the component says so
rather than guessing — onDocument hands over a document whose needsScreen
is true, with dropped naming every sequence that went unhonoured and how
often. A real cell-grid renderer for it is phase 2 in
docs/prd-terminal-output.md, which is the
design record.
The parser is its own dependency-free shared module and is useful without a terminal in sight:
import {
parseAnsi,
stripAnsi,
parseCast,
castOutput,
} from '@react-x11/components/ansi';
stripAnsi(log); // the text, escapes resolved away
parseAnsi(log).lines[0].spans; // colour kept as intent: { kind: 'ansi', index: 2 }
parseAnsi(castOutput(parseCast(rec), { until: 12.5 })); // an asciinema stillColour stays intent through the parse and resolves at paint, which is
what lets one parsed capture render correctly against a light theme and a
dark one. npm run examples:terminal-output is a test run, a progress bar, a
compiler capture with live hyperlinks, and a vim session reporting what it
needed.
Timeline
A vertical run of events — a delivery, a deploy, an audit log, a wizard's
progress. The API is
Chakra UI's Timeline with
its parts spelled flat, so Timeline.Root is <Timeline> and a snippet
copied from their docs is otherwise the same tree:
import {
Timeline,
TimelineItem,
TimelineConnector,
TimelineSeparator,
TimelineIndicator,
TimelineContent,
TimelineTitle,
TimelineDescription,
} from '@react-x11/components/timeline';
<Timeline variant="outline" size="lg">
<TimelineItem>
<TimelineConnector>
<TimelineSeparator />
<TimelineIndicator accent="$success">
<Icon name="check" size={12} />
</TimelineIndicator>
</TimelineConnector>
<TimelineContent>
<TimelineTitle>Product shipped</TimelineTitle>
<TimelineDescription>13th May 2021</TimelineDescription>
</TimelineContent>
</TimelineItem>
</Timeline>;It registers no element: a timeline is <box> and <text>, and the line
down the gutter is one absolutely-positioned pixel spanning the item — so
its length is a consequence of the content beside it rather than a height
anyone has to name. npm run examples:timeline runs a live release
pipeline beside galleries of the sizes and variants;
the reference has the rest, including why
every indicator's chip is opaque.
Tabs
One visible panel at a time. The API is
Chakra UI's Tabs with its parts
spelled flat, exactly as <Timeline> spells its own:
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from '@react-x11/components/tabs';
<Tabs defaultValue="members">
<TabsList>
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="projects">Projects</TabsTrigger>
</TabsList>
<TabsContent value="members">…</TabsContent>
<TabsContent value="projects">…</TabsContent>
</Tabs>;The vocabulary is Chakra's too — value/defaultValue/onValueChange, five
strip variants (line, subtle, enclosed, outline, plain), size,
orientation, activationMode, fitted, lazyMount — so a snippet from
their docs is the same tree with the dots removed. It supersedes core's tabs
the way <Tree> supersedes core's tree: the keyboard and RTL behaviour a
user has already learnt, without the items-array API. The one prop Chakra has
no counterpart for is overflow: a horizontal strip that runs out of room
puts the tabs that do not fit in a menu at its end rather than off its own
edge. The reference has the rest.
A drag-and-drop list
<ReorderList> is a list the user reorders by dragging its items — or by
lifting one from the keyboard and walking it with the arrows — and a
kanban board when several lists share a group. It is the sortable layer
over react-x11's own drag and drop, the position @dnd-kit/sortable holds
over @dnd-kit/core: the threshold, the auto-scroll, the preview window
and the promotion of the same drag to XDND when the pointer leaves the app
are core's, and what is here is the insertion arithmetic, the indicator,
the keyboard model and an event vocabulary that speaks list-and-index.
import {
ReorderList,
ReorderItem,
arrayMove,
} from '@react-x11/components/reorder';
<ReorderList
onReorder={(e) => setTodos((list) => arrayMove(list, e.from, e.to))}
>
{todos.map((todo) => (
<ReorderItem key={todo.id} id={todo.id}>
<text>{todo.title}</text>
</ReorderItem>
))}
</ReorderList>;Every rung is a small diff on that: a <ReorderHandle> inside an item
makes the grip the only press target, group on several lists makes a
board (onInsert on the list it landed in, onRemove on the one it
left), canDrop refuses a drop the group would otherwise take, combine
turns the middle of an item into a merge target, selected makes a drag
carry several rows at once, dragActions={['copy']} turns a list into a
palette, dragData on an item lets a file manager take it, accept on the
list lets it take files, and children as a function of the item's state
(or useReorderItem() deeper inside) lets the content say what is
happening to it. The list's own onDragStart / onDragUpdate /
onDragEnd report the gesture from either input, and preview decides
whether the ghost is a popup that follows the pointer over other
applications — the default — or a copy drawn inside the list. npm run examples:reorder shows all four;
the reference has the rest, and
the PRD has the survey of dnd-kit, hello-pangea,
pragmatic-drag-and-drop, React Aria and Framer's Reorder it was designed
against.
The disclosure tree
<Tree> is a successor to react-x11's own <Tree>, which core is
retiring — nothing here imports it, and the two share no code. What it
keeps is the behaviour a user has already learnt: the keyboard map,
type-ahead, and the twisty being its own hit target, so peeking into a folder
does not select it.
import { Tree } from '@react-x11/components/tree';
<Tree items={[{ id: 'src', label: 'src', children: [...] }]} />;The default look is plain on purpose — a chevron, no branch lines, just indentation — and three things underneath are why the successor is out here rather than in core:
- It reads your data where it lies.
getId/getChildren/isBranchand friends mean a filesystem listing, an AST or a normalized store is rendered without being copied into a shape the component preferred. The defaults describe{ id, label, children }, so a tree of that shape configures nothing. - It virtualizes. Past a couple of hundred visible rows it builds only the slice on screen and stands two spacers in for the rest, so a hundred thousand rows cost what forty do.
- Every visible part is a seam — the twisty, the branch edge down the
indent, the label, the row's contents, and the subtree container in
layout="nested"— each with a style override beside it.
npm run examples:tree is a file explorer over the real filesystem, lazily
listed, with folder glyphs and a dotted branch edge through those seams.
The reference has the rest.
The data table
<Table> is a successor to react-x11's own <Table> — the same
relationship the tree has to core's: nothing here imports it, and the prop
names core call sites already use mean migrating is changing the import.
import { Table } from '@react-x11/components/table';
<Table
rows={files}
columns={[
{ id: 'name', label: 'Name', flex: 1 },
{ id: 'size', label: 'Size', width: 96, align: 'end' },
]}
/>;That is the whole basic setup — header, sort on click, selection, resizable columns, theme colours — and the design rule above every other one is that ceremony is additive: sorting, multi-selection, custom cells, and virtualization are independent opt-ins on this same element, never a second API. Two things underneath are why the successor lives out here:
- Rows may be any height. Declare
rowHeightand the visible slice is arithmetic, core's model; omit it and drawn rows are measured, the tree's model — so a cell that wraps or stacks lines keeps an honest scrollbar, at a hundred thousand rows. - Every visible part is a seam — the cell, the header cell, the row's
content, the empty state, and a
stylesbag whose row/cell entries follow row state.
npm run examples:table shows the ladder in one window.
The reference has the rest;
the PRD has the prior-art survey and the reasons.
The code editor
A multiline editor for code-shaped input — a SQL box, a shell one-liner, a config field, a small IDE pane:
import {
CodeEditor,
sql,
sqlCompletionSource,
keywordCompletionSource,
} from '@react-x11/components/code-editor';
<CodeEditor
language={sql()}
value={query}
onChange={(ev) => setQuery(ev.value)}
completionSources={[
sqlCompletionSource({ users: ['id', 'name'] }),
keywordCompletionSource(),
]}
lineNumbers
style={{ flexGrow: 1 }}
/>;Editing is the full expected set: selection (keyboard and mouse, word and
line variants), undo/redo with coalescing, the system clipboard — including
PRIMARY and middle-click paste on X11 — auto-indent, Tab/Shift+Tab indentation, Ctrl+/
comment toggling, bracket matching, and LSP-shaped diagnostics squiggles.
Escape then Tab leaves the field. Ctrl+Space asks for completions.
Languages are pluggable, three ways:
- Built-in, zero dependencies:
sql(),shell(),glsl(),javascript()({ typescript: true }for TS),json()— hand-written stream tokenizers on a CodeMirror-5-style line-state engine, or write your own withstreamLanguage(…)in ~50 lines. - The CodeMirror grammar world:
lezerLanguage({ name, parser })runs any@lezer/<lang>parser. Install the grammar you want; nothing lezer ships with this package. - The VS Code grammar world:
textMateLanguage({ name, grammar })runs an initialized TextMate grammar (viavscode-textmateor shiki's core) — their tokenizer is line-state shaped too, so it drops straight in.
Completion sources are one async function each, deliberately the shape of an
LSP textDocument/completion call, so a language-server client is "just
another source". npm run examples:code-editor shows the three input-field
use cases side by side.
The rich text editor
WYSIWYG editing — notes, comments, a chat composer, a document pane — with markdown as the value unless you say otherwise:
import { RichTextEditor } from '@react-x11/components/rich-text-editor';
<RichTextEditor
defaultValue={note.body}
onChange={(ev) => save(ev.value)}
placeholder="Write something…"
toolbar
style={{ flexGrow: 1 }}
/>;The model is ProseMirror's — its schema,
transactions, commands and plugin system, unmodified — and the view is this
package's: blocks are <box> composition, every paragraph is a retained text
element that draws its own caret and selection band, and the object a plugin
is handed is an EditorView in everything but the DOM. So keymaps, input
rules, history and decoration plugins written for a browser run as they are;
the ones that reach into the DOM do not. The document is GFM — headings,
marks, links, nested and task lists, quotes, fenced code highlighted by the
same language seam as <CodeEditor>, tables — with the markdown shortcuts
people type anyway (# , - , [ ] , **bold**), undo, a clipboard that
pastes a web page as structure and the editor's own copies back whole, the
right-click edit menu, IME preedit at the caret, and Escape then Tab to leave.
The props are one element all the way up: value + onChange never mention
ProseMirror; toolbar, placeholder, readOnly and submitOnEnter are the
behaviours an app would otherwise wire by hand; format="html", markStyles
and nodeViews change what the document is written in and how its parts
look; plugins, editorProps and schema are ProseMirror's own seams; and
state + dispatchTransaction hand the whole EditorState to the app. It is
imported from its subpath only — ProseMirror's declarations name DOM globals,
and the barrel would hand that to every app. npm run examples:rich-text-editor
shows a notes pane beside a chat composer.
The reference has the props;
the PRD has the survey and the reasons.
The graph editor
A directed graph you can edit — a pipeline, a state machine, a dependency map, a node-based tool. The surface is react-flow's, so a graph described for that is described for this:
import {
Flow,
useNodesState,
useEdgesState,
addEdge,
} from '@react-x11/components/flow';
const [nodes, setNodes, onNodesChange] = useNodesState([
{ id: 'read', position: { x: 0, y: 0 }, data: { label: 'read' } },
{ id: 'parse', position: { x: 0, y: 120 }, data: { label: 'parse' } },
]);
const [edges, setEdges, onEdgesChange] = useEdgesState([
{ id: 'r-p', source: 'read', target: 'parse', label: 'bytes' },
]);
<Flow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={(c) => setEdges((es) => addEdge(c, es))}
fitView
minimap
style={{ flexGrow: 1 }}
/>;Nothing mutates the arrays: every gesture arrives as a change the app
applies (applyNodeChanges, applyEdgeChanges, addEdge), which is what
makes nodes/edges an ordinary controlled prop — and undo a matter of not
applying one. defaultNodes/defaultEdges give the uncontrolled form.
Drag a node to move it, a handle to connect two, the pane to pan, Shift+drag
to box-select; the wheel zooms, Delete removes the selection (with the edges
that would dangle), Ctrl+A selects everything, the arrows nudge or pan, 0
frames the graph. Edges route as bezier, smoothstep, step or straight, carry
labels and arrowheads, and animate. background, minimap and controls
are props rather than child components.
The default node type is a paint, not a React component, and that is
the one place this is deliberately not react-flow. react-flow gives every
node a DOM subtree and pans and zooms with a CSS transform, so the browser
moves ten thousand boxes for free. This renderer has no transform — style
is yoga plus paint — so the same design would re-render and re-lay-out every
node on every pointer step of a pan. The pane draws the graph instead:
panning becomes two numbers and one node's repaint, zoom scales text along
with everything else, and React is not involved at all unless the graph
itself changed.
const nodeTypes = {
task: {
size: { width: 150, height: 52 },
handles: [
{ type: 'target', position: 'left' },
{ type: 'source', position: 'right', id: 'ok', label: 'ok' },
{ type: 'source', position: 'right', id: 'err', offset: 0.8 },
],
paint({ rect, zoom, selected, palette, painter, node }) {
painter.rect(rect.x, rect.y, rect.width, rect.height, 6 * zoom, {
fill: palette.nodeBackground,
stroke: selected ? palette.accent : palette.nodeBorder,
lineWidth: selected ? 2 : 1,
});
painter.text(
node.data.label,
rect.x + rect.width / 2,
rect.y + rect.height / 2,
{
size: 13 * zoom,
align: 'center',
baseline: 'middle',
color: palette.text,
},
);
},
},
};Nodes that hold real widgets
Drawing is right for the nodes there are a lot of. It is not right for a node
whose body is a form, so a node type may render one instead: an ordinary
react-x11 tree, mounted in a box the pane positions and sizes over the node,
and laid out by yoga inside it.
const nodeTypes = {
options: {
size: { width: 268, height: 212 },
headerHeight: 26, // the strip left for the title, and for dragging
handles: [{ type: 'source', position: 'right' }],
render: ({ node }) => (
<box style={{ flexGrow: 1, padding: 8, gap: 7 }}>
<text style={{ fontSize: 11, color: '$textMuted' }}>build options</text>
<Checkbox
label="strict"
checked={node.data.strict}
onChange={(ev) => patch(node.id, { strict: ev.value })}
/>
<textarea
value={node.data.text}
onChange={(ev) => patch(node.id, { text: ev.value })}
style={{ flexGrow: 1, flexShrink: 1, minHeight: 0 }}
/>
</box>
),
},
};Everything in there behaves the way it does anywhere else: the checkbox takes clicks, the textarea takes the keyboard — Delete deletes text while it has the focus, not the node — and the buttons draw their own hover and pressed states. Three things follow, and all three are the point:
- It re-renders as the viewport moves. That is the cost the drawn path exists to avoid, so it is paid by the nodes that ask for it and no others.
- It zooms with the pane. The subtree is mounted under a
scalebox, so everything in it is written in graph units — a plainfontSize: 11, nozoomanywhere — and comes out at the size the card is drawn at, text shaped at that size rather than stretched. Belowzoom0.6 it is not mounted at all: too small to read, and the pane draws the card instead. headerHeightis what keeps the node draggable. The body starts below it, so there is always somewhere to grab that is not a text field.
Add resizable to such a node and it grows eight grips on its border while
it is selected; drag one and the widgets inside reflow with it. The gesture
arrives as a dimensions change (with a position one when the grip moved
the node's origin), applied by the same applyNodeChanges as everything
else. minWidth/minHeight are the floor.
npm run examples:flow is a working pipeline editor, and
npm run examples:flow-stress is the measured one: two scene buttons, a pan
loop, and a live count of X requests and bytes per frame.
The map
A slippy map: vector tiles decoded and drawn, panned, zoomed, with markers you can click and lines, areas and circles over the top.
import { Map, osmVectorSource } from '@react-x11/components/maps';
// Nothing in this package fetches. You supply the request; the adapter
// supplies the URL, the schema and the attribution.
const source = osmVectorSource({
fetch: async (url, signal) => {
const response = await fetch(url, { signal });
if (response.status === 404) return null;
return new Uint8Array(await response.arrayBuffer());
},
});
<Map
sources={[source]}
defaultCamera={{ center: { lon: -0.1281, lat: 51.508 }, zoom: 13 }}
markers={[{ id: 'home', position: { lon: -0.1281, lat: 51.508 } }]}
onMarkerClick={(marker) => select(marker.id)}
style={{ height: 400 }}
/>;The format is Mapbox Vector Tile — what Mapbox, MapTiler, Protomaps,
Esri, TomTom, Azure Maps and OpenStreetMap's own tile server all serve — and
the default style is written against Shortbread, the schema OSM cuts its
own tiles in, so osmVectorSource() and nothing else is a working map.
Raster tiles work too, as pixels you decode.
Nothing here fetches, and that is the feature rather than an omission.
A component whose default made requests would decide, on your behalf, whose
servers your application talks to, what its user agent says and whose usage
policy it is now bound by. So a source is a load function you write, the
way <Html onResource> is — and the attribution a source carries is drawn
in the corner, because for open data that is a licence condition rather
than a nicety.
It is one element that draws the whole map, for the reason <Flow> is: pan
and zoom are a transform, this renderer has none, and a composed map would
re-render every road through React on every pointer step. What a map adds
to that case is that the scene arrives a tile at a time and a dense city
tile is 50-140 ms to rasterize — real work for a software rasterizer over a
hundred thousand vertices. So the component is built so that cost is never
in a frame: each tile is rasterized once into its own surface, a pan
composites those surfaces at new offsets and a fractional zoom composites
them scaled, and rasterization itself is budgeted and resumable, a style
layer at a time. Measured on real OpenStreetMap tiles for central London and
Tokyo, a pan and a zoom rasterize nothing and paint in 0-11 ms a frame on
both the X11 and the macOS backends; a cold dense city view fills in over
about a second, in frames that are individually cheap.
Markers are the client API you actually reach for, and the only thing on
the map that is an object rather than cartography — which is why markers,
and only markers, are what a screen reader meets. overlays covers the
rest: a route (decodePolyline reads what every routing engine answers
with), a traffic segment, a transit shape, a GeoJSON layer
(geoJsonOverlays).
npm run examples:maps is a working map over the real network — try
-- tokyo or -- --dark. docs/prd-maps.md is the
design record: which formats and providers are actually usable (Google's
vector schema is not published; Apple has no tile endpoint at all), what
"traffic, routes and transit" reduce to, and every measurement behind the
architecture.
A 3D scene
A react-three-fiber-shaped scene
graph over core's <glarea>. There is no three.js and no WebGL underneath —
the element names, the prop shapes, attach, dashed paths, useFrame /
useThree and extend() follow r3f, and what differs is the pipeline.
import { Canvas, useFrame } from '@react-x11/components/three';
<Canvas camera={{ position: [3, 3, 6], fov: 50 }} style={{ flexGrow: 1 }}>
<ambientLight intensity={0.4} />
<pointLight position={[5, 6, 6]} />
<mesh position={[0, 0.5, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="hotpink" />
</mesh>
</Canvas>;This is the worked example at the top of this file of a boundary running
through a feature: <glarea> is a real GL surface created in the commit
phase, which is renderer internals and stayed in core; the scene graph over it
is composition, and it is here.
Which pipeline draws is the connection's business, not the scene's. Indirect
GLX encodes GL 1.x into the X connection and survives a network hop, at the
cost of shaders and post-processing — the protocol encodes neither. Direct
rendering (the x11-dri addon: DRI3 on Linux, Apple-DRI under XQuartz, CGL
into a CALayer on the native macOS backend) is OpenGL ES 2 on the GPU, and it
is where <shaderMaterial> and <effectComposer> work. The same JSX renders
on all of them; the two direct-only families throw at creation naming the
reason rather than showing a blank surface, so a scene that would rather
degrade can branch. The reference is the table,
including the JSX pragma the intrinsic element names want.
QML, without Qt
Qt's declarative UI language as an authoring layer — the parser, the reactive binding graph and the object model are this package's own, with zero dependencies and no Qt anywhere:
import { QmlView } from '@react-x11/components/qml';
<QmlView
source={`
import QtQuick 2.15
Rectangle {
width: 300; height: 120; color: "#101418"
property int count: 0
Text { anchors.centerIn: parent; color: "white"; text: "clicks: " + count }
MouseArea { anchors.fill: parent; onClicked: count++ }
}
`}
/>;Everything visible is an ordinary <box>, <text> or <image> committed
through the renderer, so theming, damage tracking, accessibility and the test
harness all apply to QML content without knowing it is QML. It registers no
host element; the one import-time side effect is populating the family's own
QtQuick type registry, which touches no core state and shakes out with the
family. The reference has the rest.
The user's real calendar
<Calendar dayContent> is the seam the desktop's own events hang off, and
the events themselves come from react-x11, not from here:
useDesktopCalendarEvents
reads the calendars the machine already has — iCloud, Google, Microsoft,
Exchange, CalDAV, local — through EventKit on a Mac and Evolution Data Server
on a Linux desktop. Your app never sees a credential and never runs an
OAuth flow, because the desktop did that already, in Settings.
import { useDesktopCalendarEvents } from 'react-x11';
import { Calendar } from '@react-x11/components';
function Month({ from, to }) {
const { byDay } = useDesktopCalendarEvents({ from, to, watch: true });
return (
<Calendar
dayContent={(day, state) =>
(byDay.get(day) ?? []).slice(0, 3).map((ev, i) => (
<box
key={i}
style={{
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: state.selected
? state.color
: (ev.calendar.color ?? '$accent'),
}}
/>
))
}
/>
);
}The keys byDay uses are exactly the 'YYYY-MM-DD' days dayContent is
handed, so nothing sits between the two — that string format is the whole
contract between the two packages, and it is why the grid never had to know
what an event is.
This package shipped the D-Bus half through 0.6.0, as
@react-x11/components/desktop-calendar. It moved to core in react-x11 2.9.1
and 0.7.0 deleted the subpath: a calendar is one of the things an app does outside
its own windows, like notifications, the tray and the file dialog, and every
one of those is a ladder in core with a freedesktop rung and a macOS one — the
macOS rung here reaches EventKit through @windowkit/appkit, which only core
can see. docs/prd-desktop-calendar.md is the
design record and the survey behind that decision. Update an import of the old
subpath to react-x11; nothing else changed, except that status grew
'denied' and the result grew backend and openSettings.
Where no rung answers — no bus and no EDS on a Linux box, a Mac where the user
declined — status says so and the calendar simply renders without dots. None
of that is an error; they are ordinary states of a healthy machine.
npm run examples:calendar in this repo is the whole thing working, and its
footer names the rung that answered.
Hosting another X client: <Terminal> and <MediaPlayer>
These two are the same component twice, and they are what core's <foreign>
element was added for: a react-x11 app can now host another X client
rather than only drawing its own pixels.
Both are X11-only, and the heading says why: an X client is what they
host. macOS has no cross-process window embedding to build the same thing
on, so on the Cocoa backend <Terminal backend="vt"> is the terminal and
<MediaPlayer> has no counterpart — see
Two backends.
import { Terminal } from '@react-x11/components';
<Terminal
command={['bash', '-lc', 'npm test']}
cwd={projectDir}
style={{ flexGrow: 1 }}
onExit={({ code }) => setPassed(code === 0)}
onTitleChange={setTabLabel}
fallback={<text>Install xterm to use the console.</text>}
/>;import { MediaPlayer } from '@react-x11/components';
<MediaPlayer
src={file}
aspectRatio="16:9"
volume={0.8}
style={{ flexGrow: 1 }}
onProgress={({ position, duration }) => setScrub(position / duration)}
onEnded={next}
/>;Mechanically: a <foreign> with no windowId adopts whatever is put inside
it, the container's X window id arrives in onReady, and the component
spawns xterm -into $WID or mpv --wid=$WID into it. Layout, focus, the
ICCCM configure and handing the client back untouched on unmount are all
core's.
Nothing is a hard dependency. No emulator and no player is an ordinary
state of a healthy machine, so backend defaults to 'auto' and picks the
first of xterm / rxvt-unicode / alacritty (or mpv / VLC) that is actually
installed; with none of them, fallback renders and onError gets a
BackendUnavailableError naming what was looked for.
Four things worth knowing before reaching for them:
- The client's window stacks above everything you draw. Same rule
<glarea>has. A transport bar or a HUD cannot be a<box>over the surface — put it beside the element, or in a sibling<popup>. - The terminal is themed by default. Background, foreground and cursor
come from the react-x11 palette, so a pane looks like part of the app.
colorsoverrides any of it, andcolors={{}}leaves the emulator on its own defaults. src,volume,mutedandpausedare live commands, sent over mpv's JSON IPC socket — changing them does not respawn the player. Under VLC that channel is write-only, so play/pause/seek/volume work andonProgressnever fires;handle.reportsProgresssays which you have.write()needs the pty to be ours, so it works onbackend="vt"below and returnsfalseon the embedded emulators: the pty there is xterm's, and synthetic key events are refused by xterm (allowSendEvents) and dropped by alacritty. An app can feature-test with the call itself.
npm run examples:terminal and
npm run examples:media-player -- <file> are both working programs.
Both take a processes prop — the ProcessHost seam from
@react-x11/components/embed — so the child can be run somewhere other than
this machine, and so the test suite can assert what would have been spawned
without an xterm in CI.
<Terminal backend="vt"> — the terminal this package draws itself
One prop changes the terminal from a hosted X client into a native element: a
pty (through a pluggable PtyHost), @xterm/headless as
the escape-sequence state machine, and a cell-grid renderer that draws glyph
runs into a retained offscreen surface, scrolls it in place, and coalesces
onto react-x11's frame clock. One renderer, both backends: XRender runs and a
server-side copy on X11, CoreText runs into a CG bitmap on macOS.
<Terminal
backend="vt"
command={['bash', '-l']}
cursorStyle="bar"
bell="visual"
style={{ flexGrow: 1 }}
onSelectionChange={setCopied}
fallback={<text>Install a pty module: npm i node-pty</text>}
/>What it buys over the embedded emulators:
- It works with nothing installed — no xterm, no alacritty. That is why
backend="auto"(the default) now ends here instead of at thefallback: the ladder is xterm → urxvt → alacritty → vt. write()is real, and with itcols/rows,resizeToFit(),selection(),scrollLines()andserialize()on the handle.- It is a native element, not a hole punched in the window. Theme colours
apply exactly (
colors.paletteincluded, which urxvt cannot take at all), a<popup>composites above it, and focus follows the app's rules. - It is testable without a display. A fake pty plus the in-process X
server gives byte-in/pixel-out tests;
test/terminal-vt.test.tsis one.
The dependencies stay optional, and the split is deliberate:
@xterm/headless is an optionalDependency (2 MB, installs by default —
nothing else would bring it), while the pty is an optional peer, either
node-pty or @lydell/node-pty, probed in that order. node-pty unpacks to
64 MB and builds a native addon, which is not something a package a calendar
app installed may drag in. So an app installs the one it wants:
npm i node-pty # or: npm i @lydell/node-ptyUnder Bun 1.4 or newer, install neither. Bun ships a pty of its own
(Bun.spawn(argv, { terminal })), so the vt backend uses it and no native
module is probed, loaded or installed — the terminal works on a machine with
no C toolchain. An app that wants node-pty back under Bun passes
pty={nodePtyHost()}.
With neither present, status is 'unavailable' and fallback renders — an
ordinary state of a healthy machine, never a throw. onError says which
half is missing, and separates "nothing installed" from "installed but it
would not load", because a native module built for another Node ABI looks
exactly like a missing one from the outside and "install it" is then the
wrong advice.
None of it costs anything to an app that does not use it: the whole vt
module, registerElement('vtterm') included, sits behind a dynamic
import() taken only when the backend is selected, and
test/treeshake.test.ts asserts the terminal's entry chunk does not contain
it.
Keyboard, mouse and selection are what a terminal user expects: xterm-compatible
key encoding (application cursor/keypad modes, the modifier parameter
scheme, Alt as an ESC prefix), mouse reporting in the tracking mode the
program asked for (with Shift as the universal "let me select instead"
override), char/word/line selection that publishes PRIMARY on X11,
middle-click paste, Ctrl+Shift+C/V, bracketed paste, and OSC 52 clipboard writes —
never reads, which are answered with nothing whatever a program asks for.
Escape arms one pass-through Tab, so the terminal is not a keyboard trap; Escape still reaches the program, and the arming is off while an alternate-screen application (vim, htop) is up, because it owns Esc-then-Tab as real input.
Bring your own pty
pty takes a PtyHost, and when you pass one node-pty is never loaded.
Anything that carries bytes both ways and can be told a size is a terminal:
ssh2, a WebSocket, docker exec, a serial port, a device over TCP.
interface PtyHost {
available(): Promise<boolean>;
openPty(argv: readonly string[], opts: PtyOptions): Promise<PtySession>;
environment?(): Record<string, string | undefined>;
}
interface PtySession {
write(data: string): void;
resize(cols: number, rows: number): void;
kill(signal?: string): boolean;
onData(listener: (chunk: string | Uint8Array) => void): void;
onExit(listener: (info: ExitInfo) => void): void;
pause?(): void; // flow control, when the transport has it
resume?(): void;
readonly pid: number | null; // null is fine — SSH has no pid
}Three things worth knowing before writing one:
- Hand over bytes when you have bytes.
onDataaccepts aUint8Array(a nodeBufferis one), and passing it through untouched is not an optimisation — a.toString()on whatever boundary the network chose cuts multi-byte UTF-8 in half. The emulator's decoder carries a partial character across chunks; a per-chunk decode cannot. - Empty
argvmeans "your default shell, wherever you are". The component does not substitute this machine's$SHELL, because over ssh that is the wrong answer;nodePtyHostfills it in locally, and a remote host opens a login shell on the far side. - A failed connection is
'exited', not'unavailable'.fallbackis for "this machine cannot run a terminal at all"; an ssh host that refused you is ordinary bad news, and it arrives throughonError.
examples/terminal-ssh.tsx is a complete ssh2
adapter — about eighty lines, with the three gotchas marked — and runs against
a real host:
npm i --save-dev ssh2
SSH_HOST=example.com SSH_USER=me npm run examples:terminal-sshnpm run examples:terminal-vt is a working program, and
docs/prd-vt-terminal.md is the design document
behind it.
The system tray: <TrayHost>
The same protocol as the two above, pointed the other way. <Terminal> and
<MediaPlayer> spawn a program into a container they own; a tray is handed
windows by applications that were already running, and the
system tray spec
is XEmbed's biggest surviving consumer. X11-only, for the same reason the
two above are: on the Cocoa backend there is no manager selection to take, so
it reports status: 'unavailable' and renders fallback — the same posture
it has against the headless test server. Putting an icon in a Mac's status
bar is the other direction and is core's useTray().
import { TrayHost } from '@react-x11/components';
<TrayHost
orientation="horizontal"
iconSize={22}
onDock={(icon) => log(`docked ${icon.id}`)}
onUndock={(icon) => log(`gone ${icon.id}`)}
/>;Mounting it takes the _NET_SYSTEM_TRAY_S<screen> selection with a real
server timestamp, publishes _NET_SYSTEM_TRAY_ORIENTATION, and broadcasts
MANAGER to the root — which is what makes applications that started before
the panel go and dock themselves. Each SYSTEM_TRAY_REQUEST_DOCK becomes one
<foreign>; unmounting gives the selection back and hands every client to
the root untouched.
Four things that are decisions rather than gaps:
- One tray per display, and a second one says so. If the selection is
already owned, the host reports it through
onConflict, rendersfallback, and embeds nothing — a second panel is a configuration mistake, not an exception to throw. Losing the selection later (another tray started) releases every icon, because a panel still drawing icons it no longer holds is the failure users report as "my tray is empty". - A visual is advertised only when there is one.
_NET_SYSTEM_TRAY_VISUALappears only when the window the icons are embedded into genuinely carries a 32-bit ARGB visual — so put the tray in a<window transparent>and icons get real translucency, and anywhere else they fall back to guessing a background rather than drawing black boxes. - Icons are not tab stops. Every icon is
focusable={false}: a tray icon is a click target, and Tab walking through eleven of them (several of which may not have mapped yet) is the worst version of this. - Reordering moves nodes, it does not re-embed clients.
sortis a comparator rather than a list you rebuild, because each<foreign>is keyed on the window id and itswindowIdnever changes. Unmounting one node and mounting another with the same id parks the client at the root long enough for a window manager to frame it, and the new node then reportsonClientGonefor a live window.
Balloon messages — SYSTEM_TRAY_BEGIN_MESSAGE, the pre-notification-daemon
way an icon says something — are reassembled from their 20-byte chunks and
forwarded to the desktop's notification service by default. Pass onMessage
to draw your own bubble instead (which turns the forwarding off), or
notify={false} to drop them.
npm run examples:tray-host is a one-row panel that is the tray for its
display. StatusNotifierItem is not in this component: modern applications
publish a tray icon over D-Bus, a complete panel supports both, and SNI
shares nothing with this except intent — it belongs beside <TrayHost>
rather than inside it.
Applications built with react-x11
Both of these use this package as well as core, so they double as worked examples of its components inside a real program:
- react-x11-workbench
(
@react-x11/workbench) — a component workshop: develop, test and compare react-x11 components in isolation. It does the job Storybook does, for a toolkit with no browser to put an iframe in, and its sidebar is<Tree>. - x11-protocol-visualizer
(
x11vis) — a man-in-the-middle X11 proxy with a live protocol inspector that decodes every request, reply, event and error down to the byte. Its own UI is built from<Table>,<Tree>,<Tabs>,<Code>and<CodeEditor>: an X11 client inspecting X11 clients — or, on macOS, drawn by AppKit through core's Cocoa backend.
Roadmap
Candidates to move here:
- The inline half of MDX — a component in the middle of a sentence, which is
gated on a
<richtext>run that can reserve advance width for an embedded element. Block-position components and expressions have shipped; docs/prd-mdx.md has what is left. - A StatusNotifierItem host, beside
<TrayHost>rather than inside it: the D-Bus way modern applications publish a tray icon. It pairs with core'sdbusmenu.js, and a complete panel wants both.
<Table> above supersedes core's <Table> the way <Tree> supersedes
core's tree, and <Tabs> supersedes core's tabs the same way; whether core's
remainder in each case is stripped down or removed out
