@react-x11/components
August 23, 2026 · View on GitHub
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. react-x11 2.0.0 is on npm, so the peer range this package declares resolves and
npm installjust works. The published release is0.1.0;mastercarries components added since, so use a checkout if you want what is 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 — it is a
real X window on a GLX visual, which is renderer internals. A Three.js-shaped
scene graph drawn into it is not: that is composition over a public element,
and it belongs here.
Install
npm install @react-x11/components react react-x11
react 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.0.0 or newer: that is the release the subpaths this
package imports (react-x11/host, /node, /style) arrived in.
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. |
Html | @react-x11/components/html | A static HTML + CSS document, selectable, with seams. |
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. |
Table | @react-x11/components/table | A data table: sortable, virtualized, any row height. |
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. |
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. |
| (hook) | @react-x11/components/desktop-calendar | The user's real calendar events, over D-Bus. |
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 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.
- Server-side drawing commands by default, pixels when they win. A
dense line goes out as one batched
FillRectangles(~8 bytes per pixel column); a sparse one 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 a mouse-up
with a selection takes the X11 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 is on the roadmap, not in the box: the AST reserves a component node
and the renderer is ordinary React composition, so user components can
interleave — including mid-stream — once the parser learns the syntax.
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.
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 still
Colour 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.
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, X11 clipboard including PRIMARY
and middle-click paste, 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 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 does not scale with the zoom. The box does; there is no transform
here. Content is laid out to the zoomed box at its natural size and
clipped, and below
zoom0.6 it is not mounted at all — 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 user's real calendar
useDesktopCalendarEvents reads the calendars the desktop already has —
Google, Microsoft, CalDAV, local — through Evolution Data Server over D-Bus.
Your app never sees a credential and never runs an OAuth flow, because the
desktop did that already, in Settings.
import { Calendar, useDesktopCalendarEvents } 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.
Expanding recurring events needs ical.js,
which is an optional dependency — install it if you want events:
npm install ical.js
Without it, or with no session bus, or on a desktop with no Evolution Data
Server, status is 'unavailable' and the calendar simply renders without
dots. None of those is an error; they are ordinary states of a healthy
machine. npm run examples:calendar in this repo is the whole thing working.
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.
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 with
XRender glyph runs into a retained offscreen surface, scrolls with a
server-side copy, and coalesces onto react-x11's vblank-paced frame clock.
<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-pty
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, 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-ssh
npm 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.
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.
Roadmap
Candidates to move here:
- The 3D scene graph and a Three.js / react-three-fiber-shaped layer, with
<glarea>itself staying in core. <Tabs>, undecided — it may well stay in core.- MDX support in
<Markdown>— see the note in that section. - 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; whether core's remainder is stripped down or removed outright
is core's decision, still open — docs/prd-table.md records the contract.
<Markdown> above replaces core's ntk-backed <markdown> element, and
<Html> now replaces HtmlView and core's <html> (ntk's document widgets
are being deprecated). That reverses a decision this README used to record —
"no <html> successor and no plan for one" — and the reasoning is worth
keeping straight: markdown still does not render through an HTML pass,
because it has an AST of its own and box-and-text composition is better for
it. <Html> exists because HTML turns up as an input in its own right —
mail, release notes, a CMS, an exported report — with no markdown upstream of
it to render instead. See docs/prd-html.md. <svg> and
<tex> stay in ntk; mermaid was dropped rather than extracted — 155 MB of
install closure for a grammar.
Contributing
AGENTS.md is the contributor guide: the rule for what belongs here, the layout, the tree-shaking constraints, and the two ways a registered element fails silently.
License
MIT