@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 install just works. The published release is 0.1.0; master carries 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

ComponentImport
Calendar@react-x11/components/calendarA month grid: one date or a range, any day blockable.
DatePicker@react-x11/components/calendarThat calendar on a popup, behind a field.
LineChart@react-x11/components/chartsCartesian charts; a million points is a normal input.
ColorPicker@react-x11/components/color-pickerA colour input: field, hue, alpha, swatches, eyedropper.
Code@react-x11/components/codeA static code block: highlighted, selectable.
CodeEditor@react-x11/components/code-editorMultiline code editing: highlighting, completion.
Flow@react-x11/components/flowA directed-graph editor: nodes, edges, pan and zoom.
Html@react-x11/components/htmlA static HTML + CSS document, selectable, with seams.
Markdown@react-x11/components/markdownStreaming-friendly GFM with cross-block selection.
MediaPlayer@react-x11/components/media-playermpv or VLC, embedded, with real transport control.
Table@react-x11/components/tableA data table: sortable, virtualized, any row height.
Terminal@react-x11/components/terminalA real terminal: an embedded emulator, or its own.
TerminalOutput@react-x11/components/terminal-outputA captured session, rendered. <Terminal>'s static sibling.
Timeline@react-x11/components/timelineA run of events: a mark per step, a line between.
TrayHost@react-x11/components/tray-hostThe system tray: applications dock their icons in.
Tree@react-x11/components/treeA disclosure tree: seams throughout, and virtualized.
(hook)@react-x11/components/desktop-calendarThe 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 ChartData append 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 / isBranch and 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 rowHeight and 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 styles bag 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 with streamLanguage(…) 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 (via vscode-textmate or 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 zoom 0.6 it is not mounted at all — the pane draws the card instead.
  • headerHeight is 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. colors overrides any of it, and colors={{}} leaves the emulator on its own defaults.
  • src, volume, muted and paused are 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 and onProgress never fires; handle.reportsProgress says which you have.
  • write() needs the pty to be ours, so it works on backend="vt" below and returns false on 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 the fallback: the ladder is xterm → urxvt → alacritty → vt.
  • write() is real, and with it cols/rows, resizeToFit(), selection(), scrollLines() and serialize() on the handle.
  • It is a native element, not a hole punched in the window. Theme colours apply exactly (colors.palette included, 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.ts is 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. onData accepts a Uint8Array (a node Buffer is 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 argv means "your default shell, wherever you are". The component does not substitute this machine's $SHELL, because over ssh that is the wrong answer; nodePtyHost fills it in locally, and a remote host opens a login shell on the far side.
  • A failed connection is 'exited', not 'unavailable'. fallback is for "this machine cannot run a terminal at all"; an ssh host that refused you is ordinary bad news, and it arrives through onError.

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, renders fallback, 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_VISUAL appears 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. sort is a comparator rather than a list you rebuild, because each <foreign> is keyed on the window id and its windowId never 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 reports onClientGone for 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's dbusmenu.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