Skip to main content

Elements

Only <window>, <popup> and <glarea> are backed by real X11 windows, created top-down in React's commit phase so every CreateWindow names its actual parent. Everything else is a retained lightweight node — one yoga-layout node each — painted into the owning window's double-buffered 2d context on ntk's frame clock.

style and props

Everything CSS has a concept for goes in style; everything else is a prop. No name means both, which is why <window width> is unambiguously the real window's geometry. See styling.md for arrays, state blocks and createStyles.

<box style={{ flexDirection: 'row', gap: 8 }} onClick={pick}>
<text style={{ fontSize: 14, color: '#2d3436' }}>hello</text>
</box>

Layout properties (all drawn elements + windows)

Numbers are pixels, strings like '50%' / 'auto' pass through to yoga.

  • Size: width, height, minWidth, minHeight, maxWidth, maxHeight, aspectRatio. <image> and <svg> keep their aspect ratio when only one of width/height is given
  • Flex: flexDirection (row, column, row-reverse, column-reverse), justifyContent (flex-start, center, flex-end, space-between, space-around, space-evenly), alignItems, alignSelf, alignContent, flexWrap, flexGrow, flexShrink, flexBasis, gap, rowGap, columnGap
  • Position: position (relative, absolute, static), top, right, bottom, left
  • Spacing: margin, marginTop/Right/Bottom/Left, padding, paddingTop/Right/Bottom/Left
  • Visibility: display (flex, none), overflow (visible, hidden, scroll)

yoga defaults flexShrink to 0, where CSS defaults it to 1. An item whose base size comes from its content therefore refuses to shrink, and content wider than the space available pushes the row past its container instead of being squeezed into it. Write flex: 1 in full — { flexGrow: 1, flexBasis: 0, minWidth: 0 } — for anything that should take the space that is left rather than the space its content wants. <scrollview> applies exactly that to itself by default.

Paint properties

  • backgroundColor — any CSS color string ('#2980b9', 'rgba(0,0,0,.5)', 'red')
  • borderWidth, borderColor, borderRadius, borderStyle ('solid' default, 'dashed')
  • zIndex — paint/hit order among siblings (stable sort)
  • outlineWidth, outlineColor, outlineOffset — the focus ring, painted outside the border box and invisible to yoga, so it never moves what it surrounds. A focusable node draws one on :focus-visible without being asked; outlineWidth: 0 opts out (styling.md)
  • transition120, or { backgroundColor: 120, left: 200 }: how long a change to that property takes (styling.md)
  • any value may be a theme token: '$panel' resolves against the nearest theme prop above the node (styling.md)
  • '@width >= 600' blocks restyle for the window's size, layout included (styling.md)
  • opacity is not implemented yet (see NEXT_STEPS.md)

cursor ('pointer', 'text', 'wait', 'move', 'crosshair', resize arrows, … — the ntk cursor name map) and pointerEvents: 'none' are style too: CSS has both, and React Native has been moving pointerEvents the same way. So is hitSlop:

<box style={{ height: 16, hitSlop: { top: 4, bottom: 4 } }} /> // 24px target

hitSlop: 4 grows every side, the object form only the sides it names. It is hit testing and nothing else — not paint, not yoga — which is what lets a 16px control answer over the 24px WCAG 2.2 SC 2.5.8 wants without a taller control misaligning the row it sits in. The slop may overlap a sibling's box; hit testing runs front to back over paint order, so the sibling on top keeps its own pixels either way.

Interaction props

focusable, tabIndex (sequential focus order; -1 is focusable but not tabbable), autoFocus, trapFocus (own a focus scope — Tab and presses stay inside it, focus is restored when it unmounts), disabled (never focusable, and the trigger for a :disabled style block), and the event handlers listed in events.md.

Drag and drop is two more prop families on the same elements — dropAccept + onDrop to accept a drop, draggable + dragData to start a drag — and works with other X11 applications as well as inside the app. Their presence is what registers the node, so there is nothing else to mount: drag-and-drop.md.


<window>

A real X11 window; the flex, paint and event root for its subtree.

prop
titlewindow title (UTF-8, via WM_NAME + _NET_WM_NAME)
width, height, x, ywindow geometry (window state, not yoga style — the user may resize)
backgroundColorfull-window clear color (default white)
onResize(ev)ConfigureNotify — the tree reflows automatically
onExpose(ev)after a repaint was required
onCloseRequest(ev)WM close button (opts into WM_DELETE_WINDOW)
statesEWMH _NET_WM_STATE — controlled, see below
fullscreen, alwaysOnTopboolean sugar for two of those states
decorationsfalse asks the WM for no titlebar or border
transientForICCCM WM_TRANSIENT_FOR — the window this one belongs to (below)
onStatesChange(states)what the window manager actually did
themepalette that $token style values resolve against, for this subtree

Windows may be nested inside other windows (real X11 child windows). Ref: the live ntk WindowgetContext('2d'), requestAnimationFrame, setCursor, the whole ntk API.

Stacking

Nested <window>s stack the way drawn siblings paint: the later sibling sits on top, and zIndex wins over document order. Reordering them in JSX restacks the real windows — one ConfigureWindow pass per commit, and nothing at all when mount order already agrees.

<window>
<window key="back" /> {/* bottom */}
<window key="front" /> {/* on top */}
<window key="always" zIndex={1} /> {/* above both, wherever it sits here */}
</window>

Top-level windows are the window manager's to stack — it redirects the request — so their order in the tree carries no stacking meaning. Use alwaysOnTop (below) for those.

Window manager hints

Properties the window manager reads (ntk ≥ 3.5.0). All work at mount and update; unchanged values are not re-sent. The size hints are flat props like the geometry they constrain — with style in its own channel the yoga names are free, so no sizeHints object is needed.

prop
resizablefalse pins min and max size to the current size
size hintsminWidth, minHeight, maxWidth, maxHeight, widthInc, heightInc, baseWidth, baseHeight, minAspect, maxAspect, gravity
wmClass'instance', ['instance', 'Class'] or {instance, class}
windowType'dialog', 'utility', 'tooltip'… or an array of fallbacks
decorationsfalse asks for no titlebar or border
<window width={400} height={300} resizable={false} windowType="dialog" />
<window width={400} height={300} minWidth={320} minHeight={200} />

On a <window> these names are the window's, not yoga's: width/height are the real geometry the user can drag, and minWidth/maxHeight are what the WM enforces. A window's contents are laid out by its style.

transientFor — a window that belongs to another

Without it, every secondary top-level window an app opens is, to the window manager, an unrelated second application: its own taskbar and alt-tab entry, placed wherever new windows go, stacked independently, and it does not minimise with the window it belongs to.

const main = useRef(null);

<window ref={main} title="editor"></window>
<window transientFor={main} windowType="dialog" title="Preferences"></window>

Takes a ref to a <window>/<popup>, a ref to any drawn node (resolved to the window that owns it — so an out-of-flow <box> inside the owner is a perfectly good handle), a raw XID, 'root' for "transient for this client's whole window group", or null to clear.

Measured against quartz-wm — the thinnest EWMH implementation in the stack, so this is a floor rather than a best case:

the client setsthe WM allowsthe WM adds to _NET_WM_STATE
nothingclose, minimize, move, resize, maximize horz/vert, fullscreen
transientForclose, minimize, move, resizeskip_taskbar, skip_pager
windowType="dialog"close, minimize, move, resizeskip_taskbar, skip_pager

Rows two and three matching is the EWMH rule working as specified: a managed window with WM_TRANSIENT_FOR and no _NET_WM_WINDOW_TYPE is a dialog. The converse does not hold — a dialog type with no owner is a dialog belonging to nobody, with nothing to stack above or iconify with — so set both, which is what real toolkits do. A fuller window manager also restacks the transient above its owner and iconifies it alongside; quartz-wm does neither, and both are policy rather than spec.

It is inert on an override-redirect window, which is every <popup> by default: the WM never manages those, so nothing reads the property. ICCCM 4.1.2.6 draws exactly that distinction — WM_TRANSIENT_FOR for windows the WM manages, override-redirect plus a pointer grab for menus. ntk warns if you ask it to write the property on one.

Resolution happens in the commit phase, not at element creation: a React ref is not something ntk should be asked to understand. Refs attach in the layout phase, after every mutation, so on the commit that mounts two sibling <window>s the second realizes while the first one's ref is still null — that owner is retried on the frame the mount schedules rather than dropped.

windowIdOf(refOrInstance) is the same resolution, exported: it returns the XID a ref points at, or null. It is also what an xdg-desktop-portal parent_window handle needs — `x11:${id.toString(16)}`, lowercase hex with no 0x prefix. Both shipping portal backends happen to tolerate a prefix, which is exactly why it is easy to get wrong and never notice; Qt's parser returns 0 on failure with no error path, so the symptom would be a silently unparented, non-modal dialog. useWindowId(ref) is the hook form and returns a getter, like useAnchor.

decorations={false} writes _MOTIF_WM_HINTS — honoured by Mutter, KWin, Xfwm, Openbox and i3. A window manager that ignores it simply decorates the window; there is no way to force the matter.

Window state

states is EWMH _NET_WM_STATE: modal, sticky, maximized (or maximized_vert/maximized_horz individually), shaded, skip_taskbar, skip_pager, hidden, fullscreen, above, below, demands_attention, focused. fullscreen and alwaysOnTop are boolean sugar for the two everyone reaches for — fullscreen and above — and they union with states rather than competing with it.

<window
states={['skip_taskbar']}
fullscreen={isFullscreen}
onStatesChange={(states) => setFullscreen(states.includes('fullscreen'))}
/>

These are controlled props, and the reason is not React convention. On X the window manager changes state behind the app's back constantly: the user hits maximize, a global hotkey leaves fullscreen, a tiling WM has its own opinion. react-x11 therefore diffs states against the previous props, never against what the window currently has — so a prop is re-sent only when the app changes its mind, and a WM that disagreed is not fought on the next commit. onStatesChange is the other half: it reports what the WM actually did, and subscribing to it is what makes react-x11 watch the property, so a window without a handler costs nothing.

States are applied before the window is mapped, which is what EWMH 7.7 requires and the only way to open already fullscreen instead of flashing at the normal size first.

alwaysOnTop falls back to Apple-WM window levels on XQuartz, where quartz-wm does not support _NET_WM_STATE_ABOVE.

Kiosk

There is no kiosk prop, because it is not one thing — it is a window with no decoration, no taskbar entry, nothing above it, and no pointer:

<window
fullscreen
decorations={false}
states={['above', 'skip_taskbar', 'skip_pager']}
style={{ cursor: 'none', flexGrow: 1 }}
/>

cursor: 'none' hides the pointer outright (ntk ≥ 4.2.0). It is not the same as leaving cursor unset, which inherits whatever the root window's cursor is — see styling.md.

An override-redirect top-level window at screen coordinates — the window manager ignores it (no decorations, no focus stealing): menus, tooltips, dropdowns. May appear anywhere in the JSX tree (its position in the tree does not affect its position on screen); it is its own paint and event root. Anchor with ev.nativeEvent.rootx/rooty (pointer in screen coordinates) or a ref's abs rect plus the owner window's x/y. Same props as <window>; conditional rendering controls its lifetime.

prop
grabhold a pointer grab while the popup is up — how menus behave on X (below)
onDismissa press landed outside the popup: close it
trapFocusown a focus scope: a modal (see events.md)
overrideRedirectfalse makes it a WM-managed window instead — a real dialog (below)
dragPreviewthis popup is a drag preview following the pointer, never a drop target — drag-and-drop.md

A popup never receives the X input focus, but nodes inside it can hold the owner window's focus and receive keys — with trapFocus and autoFocus that is a modal dialog. See Focus inside a <popup>.

grab is what makes a menu dismissable. Without it, a press that lands anywhere else — another application, the root window, or even this app's own window frame, which belongs to the window manager — never reaches the client, so the menu stays open behind whatever was clicked. With the grab, that press is redirected to the popup instead, arrives outside its bounds, and onDismiss fires. The client's own windows still receive their own presses (X owner-events), so submenus and the owner window keep working normally, and only the root popup of a menu needs the grab. Needs ntk ≥ 3.7.0; on older ntk the popup behaves as it did before.

Select, ContextMenu and MenuBar already do this.

A managed <popup> is a dialog

overrideRedirect defaults to true and is what makes a menu a menu. Passing false gives up all of that on purpose and hands the window to the window manager: it is reparented into a frame, so it has a titlebar, can be moved, and has a WM close button (onCloseRequest). Together with transientFor that is a real dialog — above its owner, out of the taskbar and alt-tab list, minimising with the owner.

<popup overrideRedirect={false} transientFor={anchor} windowType="dialog"
title="Preferences" trapFocus>

Turn grab off with it. A client-side pointer grab over a window the WM is trying to let the user drag swallows the press that would start the drag. That also means a press outside no longer dismisses it — which is correct: a real dialog does not close because you clicked elsewhere.

The Dialog component does exactly this, and takes managed={false} to go back to the override-redirect shape.

Defaults to windowType="dropdown_menu"; pass windowType to override ("tooltip", "popup_menu", …). The hint is additive — override- redirect is what keeps the WM from moving or decorating the popup, and stays on. The EWMH spec asks for the type hint on override-redirect windows too, so compositing managers can give menus and tooltips consistent shadows and animations.

<box>

The flex container. All layout + paint + interaction props above. Ref: the retained node — abs ({x, y, width, height} within the window, valid after layout).

<scrollview>

A clipped viewport over its (overflowing) children, on both axes. Wheel events scroll it by default; a scrollbar thumb is drawn on each axis that overflows, and the thumb can be dragged.

It is also a tab stop, and answers the keyboard, whenever it has somewhere to scroll — which is what lets a pane of unfocusable content (a log, a long <text>, a <markdown>) be read without a pointer at all:

key
arrowsone wheel notch (48px) on that axis
PageUp / PageDowna viewport, keeping a sliver of it
Space / Shift+Spacethe same, for the hand already there
Home / Endthe top and the bottom

One that fits its content is not a tab stop: it is a <box> with a clip, and stopping on it would be a stop that does nothing. The keys are a default action, so an onKeyDown of your own runs first and preventDefault() cancels them.

The bar belongs to the scroller, not to the content painted under it — the same rule a browser applies — so a press on the thumb never reaches the row behind it. Dragging keeps the grip where it was taken, so the thumb does not jump to the pointer, and a press on the track pages towards it, like PageUp/PageDown. <textarea> behaves the same way, and there a bar press never moves the caret.

With flexGrow and no explicit size it defaults to flex-basis: 0 — what CSS's flex: 1 means — so it takes the space left over instead of being sized by its content. Without that, a header/scrollview/footer window grows past its own bounds as rows are added and the footer is pushed out of view. flexShrink defaults to 1 and minHeight/minWidth to 0 for the same reason. Pass any of them explicitly to opt out.

prop
onScroll(ev){scrollX, scrollY, contentWidth, contentHeight, viewportWidth, viewportHeight}
onViewport(ev){width, height, contentWidth, contentHeight} whenever they change
scrollbar={false}hide the drawn scrollbars
scrollbarColorthumb color

Ref: the node, plus scrollTo / scrollBy / scrollIntoView(node) and scrollX / scrollY / contentWidth / contentHeight.

scrollTo(y) takes a number for the vertical axis, as it always has; scrollTo({x, y}) moves either, leaving alone whichever you omit. scrollBy matches. scrollIntoView(node) scrolls the minimum amount on both axes.

Horizontal content comes from children that will not shrink — a row of fixed-width cells, say. The extent is measured through the subtree, the way scrollWidth is in a browser: a row that stretches to the viewport while its own cells overflow it still reports something to scroll. Anything that clips its own children ends that measurement, since their overflow belongs to them.

X sends buttons 6 and 7 for a horizontal wheel; Shift + vertical wheel scrolls sideways too, for mice and touchpads that have none. When both bars show, each stops short of the other's corner.

onViewport fires from layout, not from scrolling, so it arrives for a list nobody has scrolled yet. That is what a virtualized list needs before it can decide how many rows are worth building: layout runs on the frame clock, after the commit that mounted the node, so an effect cannot read the size off the ref. Table is built on it.

scrollIntoView(node) scrolls the minimum amount that makes a descendant node fully visible, and is safe to call from an effect right after that node mounts: the request is resolved on the next layout pass, when the node actually has geometry.

<text>

Shaped, wrapped text through ntk's text stack (bidi, ligatures, kerning, font fallback). Strings/numbers are only legal inside <text>; nested <text> elements are style spans — the paragraph is laid out as one run list so wrapping spans the whole content.

prop
colortext color (inherited by spans)
fontSize, fontFamily, fontWeight, fontStylentk font style (fontconfig lookup + fallback)
textAlignleft, right, center, start, end (bidi-aware)
lineHeightmultiplier over the natural font line height

<textinput>

textinput

Single-line editable text. Caret and selection geometry come from ntk's TextLayout.caretPosition/indexAt (ntk ≥ 3.3.0), so positions are exact across kerning, shaping boundaries and trailing whitespace.

prop
value + onChange(ev)controlled mode (display follows the prop)
defaultValueuncontrolled mode
onSubmit(ev)Enter
namefield name, echoed on the event
placeholder, placeholderColorshown when empty
maxLengthcode-point limit
selectionColor, caretColorselection/caret paint
text style propsas <text>

The change event

onChange and onSubmit get a synthetic event, the same shape every other handler in the library gets, rather than a bare string:

<textinput
name="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
/>
on the event
ev.value, ev.target.valuethe text after the edit
ev.name, ev.target.namethe name prop
ev.type'change' or 'submit'
ev.target, ev.currentTargetthe node (they are the same — this does not bubble)
ev.nativeEventthe X key event, or null — see below
preventDefault, stopPropagationpresent for uniformity; nothing reads them

Two details worth knowing:

  • ev.target.value is the new value even in controlled mode, where props.value is still the old string until the parent re-renders. That is what makes e.target.value mean here what it means in the DOM, and it is why react-hook-form and formik work — see docs/ecosystem/forms.md. It is true while the handler runs: ev.target is the live node, so a handler that stashes the event and reads ev.target.value later sees whatever the control holds then. ev.value is a snapshot and always safe.
  • ev.nativeEvent can be null. A keystroke carries the X key event that produced it, but an edit can also come from a paste resolving, an undo, or a value the parent pushed back — none of which has an X event behind it. Guard it. (Not academic: downshift reads event.nativeEvent.preventDownshiftDefault unguarded, so a null there is a TypeError rather than a no-op.)

The node behind ev.target is writable, too. node.value = '' sets the text without firing onChange, the way assigning to a DOM input's value does — that is how react-hook-form's register() resets a field through its ref.

Interactions: click/drag selection, double-click word select, triple-click select all, Backspace/Delete, arrows (+Shift extends), Home/End, Ctrl+A, Ctrl+C/X/V on CLIPBOARD, middle-click paste from PRIMARY, selections own PRIMARY (X11 conventions, select-all included), Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y too) to undo and redo, and a right-click menu. Focusable by default; shows the text cursor. ev.preventDefault() in your onKeyDown/onMouseDown suppresses the built-in editing behavior. To copy or paste from anywhere else — a canvas, a list, your own menu item — see clipboard.md.

The right-click menu

the built-in edit menu

Right-clicking gives Undo / Redo / Cut / Copy / Paste / Select All with no wiring, the way a browser gives <input> one — each row live only when it would do something, and every row running the same code the keyboard shortcut does. Right-clicking inside a selection keeps it (the menu is about to act on it); outside one, the caret moves there first.

Paste is greyed when nothing owns the CLIPBOARD selection. The field subscribes to selection changes the first time its menu opens rather than asking on the way in — asking would mean a round trip against whatever foreign client owns the clipboard, and a wait if that client is wedged, at exactly the moment a menu should already be on screen. So the very first menu of a session still shows Paste enabled; every one after it knows. On a server without XFixes it stays enabled, which is where it started.

Arrows walk the rows, skipping the disabled ones, Enter chooses, Escape or a press anywhere outside closes. The selection stays visibly highlighted while the menu is up, even though the popup holds the keyboard.

To replace it with your own, set contextMenu={false} and render a ContextMenuonContextMenu still fires. To suppress it for one event, call ev.preventDefault() in an onContextMenu handler.

Ref: the node, plus value, undo() / redo() and canUndo / canRedo — enough for a toolbar button beside the field.

Undo

Consecutive typing coalesces into one undo step, so Ctrl+Z takes back a word rather than a keystroke; a run of Backspaces coalesces the same way. A run ends at whitespace, at anything that moves the caret (arrows, Home/End, a click, focus leaving the field), and around edits that are their own step whatever surrounds them: a paste, a cut, a replaced selection, Ctrl+Backspace, and a <textarea> newline. Undo restores the selection and puts the caret back where the undone edit started.

Undo is a stack of snapshots of the states the control has shown, capped at 200. A controlled value reports the restored text through onChange and waits for the prop to come back, exactly as typing does — so a parent that filters or rejects a value gets the same say over an undo that it has over an edit. A value changed from outside (a form reset) becomes its own entry, and undoing steps back through it.

<textarea>

Multi-line editable text on the same editing core as <textinput>: word-wraps at the content width, Enter inserts a newline (Ctrl+Enter fires onSubmit), Up/Down move between visual lines keeping a goal column, Home/End go to the start/end of the visual (wrapped) line, selection spans lines, and the view scrolls vertically to follow the caret (mouse wheel scrolls too).

prop
rowspreferred height in text lines (default 3)
everything elseas <textinput> (onSubmit fires on Ctrl+Enter)

<image>

prop
srcfile path (PNG/JPEG, decoded in JS)

Sized by style — style={{ width, height }}, never flat props, since both are style names. With only one of the two set the other follows the natural aspect ratio; with neither, the image measures at its natural size, shrunk to the width on offer.

<image src={photo} style={{ height: 64 }} /> // width follows the aspect ratio

<canvas>

The escape hatch: a retained node whose content you paint.

<canvas
style={{ flexGrow: 1 }}
onDraw={(ctx, { width, height, node }) => {
ctx.fillStyle = 'tomato';
ctx.fillRect(0, 0, width / 2, height);
}}
/>

ctx is ntk's canvas-like 2d context (paths, transforms, gradients incl. conical, setLineDash, round caps/joins, images, text — XRender-backed), translated to the node's origin and clipped to its bounds. onDraw runs on every repaint of the window.

cacheKey — draw it once

A drawing that does not change between frames does not have to be redrawn between frames. Give the canvas a cacheKey and its content is rendered once and composited on later repaints; two canvases with the same key share the one rendered copy.

<canvas cacheKey={`spark:${series.id}:${w}x${h}`} onDraw={drawSparkline} />

Opt-in, and the reason matters: onDraw is an opaque closure. Nothing in the renderer can know what it reads — a prop, a ref, a clock — and its identity changes on every render unless you memoize it, so it is not a key either. Only you know, so you say.

The key must name every input the drawing reads. One that leaves something out shows stale pixels, which is the hardest kind of bug to see. Develop with REACT_X11_PAINT_CACHE=verify, which turns exactly that mistake into a loud complaint — see Runtime diagnostics. Leave cacheKey unset for anything animated, or driven by state outside the props.

<svg> and <tex> do this automatically: their content is fully described by their props, so the renderer can build the key itself.


<glarea>

An OpenGL surface in the layout — the only drawn element that owns a real X window, because GLX needs a drawable created for a GL-capable visual and cannot share the XRender pipeline the rest of the tree paints through (NEXT_STEPS §4). Needs ntk ≥ 3.6.0 and a server with indirect GLX enabled (+iglx / AllowIndirectGLX — off by default on many).

<glarea
style={{ flexGrow: 1 }}
clearColor="#0b1021"
frameLoop="always"
onCreated={(gl) => gl.Enable(gl.DEPTH_TEST)}
onDraw={(gl, { width, height }) => {
gl.MatrixMode(gl.PROJECTION);
gl.LoadIdentity();
gl.Frustum(-1, 1, -height / width, height / width, 2, 20);
// ... immediate-mode or display-list drawing
}}
/>
  • onDraw(gl, { width, height, node }) — draw one frame. The viewport is set and the buffers are cleared before it, SwapBuffers follows it.
  • onCreated(gl, info) — runs once, when the context is current: one-time GL state, texture uploads, display-list compilation.
  • clearColor — CSS colour or [r, g, b, a] floats (default black).
  • frameLoop'demand' (default) redraws on prop, size and expose changes only; 'always' renders continuously on ntk's frame clock.
  • glx — a visual spec for ntk's chooseGLXConfig, e.g. { DEPTH_SIZE: 24 }. One query per app, shared by every <glarea>.
  • onError(err) — no GL surface (no GLX, no matching visual). Without a handler the failure is a console warning.

gl is ntk's indirect-GLX context: fixed-function OpenGL 1.4 (immediate mode, matrices, lighting, textures, display lists) — no shaders, no vertex arrays, since the GLX protocol does not encode them. Geometry belongs in display lists: every immediate-mode vertex is a command on the wire, so a mesh re-sent per frame costs kilobytes per frame, while a compiled list costs one CallList.

Layout treats it as a leaf: it is sized and positioned like any other node, and its X window follows that rect. The window is stacked above everything drawn in the parent, so 2D content cannot overlap it — a HUD needs a sibling <popup>. Pointer events over the surface go to its own window; <glarea> does not take part in the parent's hit testing yet.

onDraw is the raw escape hatch; for a scene, put 3D elements inside (below) and let the renderer drive the GL. See examples/gl.jsx for the raw form, examples/three.jsx for the declarative one.


3D scene: <mesh>, <group>, geometries, materials

Inside a <glarea> (or the Canvas3D component that wraps it) the children are scene elements, not drawn ones — a separate tree with no yoga and no 2D painting, using react-three-fiber's names wherever the concept survives the translation to fixed-function GL.

<Canvas3D style={{ flexGrow: 1 }} camera={{ position: [0, 2, 6], fov: 45 }}>
<group rotation={[0, angle, 0]}>
<mesh position={[-1.6, 0, 0]} rotation={[0.5, 0.4, 0]} scale={1.2}>
<boxGeometry args={[1.4, 1.4, 1.4]} />
<meshBasicMaterial color="#2980b9" />
</mesh>
</group>
</Canvas3D>
elementnotes
<group>transform only; nests children
<mesh>one geometry child + one material child
<boxGeometry>args={[width, height, depth, widthSeg, heightSeg, depthSeg]}
<planeGeometry>args={[width, height, widthSeg, heightSeg]}
<sphereGeometry>args={[radius, widthSeg, heightSeg]}
<cylinderGeometry>args={[radiusTop, radiusBottom, height, radialSeg, heightSeg, openEnded]}
<torusGeometry>args={[radius, tube, radialSeg, tubularSeg]}
<bufferGeometry>position / normal / uv / index arrays; normals are derived from the triangles when omitted
<meshBasicMaterial>unlit flat colour: color, map, wireframe, opacity, transparent, side (front | back | double)
<meshLambertMaterial>diffuse shading; the same props plus emissive
<meshPhongMaterial>+ specular, shininess (default 30)
<ambientLight>color, intensity — costs no light unit
<directionalLight>position is the direction the light comes from
<pointLight>position, plus distance/decay for attenuation
<spotLight>+ angle in radians, penumbra, target

Transforms are position, rotation (XYZ euler radians) and scale, each a [x, y, z] tuple (or one number for a uniform scale), plus visible.

Geometry lives on the server. GLX encodes no vertex arrays, so vertices can only travel as immediate-mode commands — a 1 000-triangle mesh re-sent every frame is ~96 KB per frame. Each geometry is therefore compiled into a display list once, and a frame is matrices + material state + one CallList per mesh, whatever the triangle count. Changing a transform or a material re-sends neither; changing a geometry's args recompiles just that list. test/scene3d.test.js asserts exactly this on the encoded command stream.

Textures

map on any material takes an ntk Image — or anything with { width, height, data } in RGBA byte order:

import { Image, loadImage } from 'ntk';

const texture = await loadImage('crate.png');

<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshPhongMaterial map={texture} color="#ffffff" />
</mesh>;

The pixels are uploaded once, on first use, and only rebound afterwards — the same rule as geometry, since a texture is kilobytes that must not cross the wire twice. The upload goes through RenderLarge, so it is one chunked request rather than a stream of commands. Texture coordinates come from the geometry (the primitives all generate them); the texture is applied in GL_MODULATE, so the material color tints it and lighting still applies. Filtering is linear and wrapping repeats.

Pointer events on meshes

<mesh> and <group> take onClick, onPointerDown, onPointerUp, onPointerMove, onPointerOver and onPointerOut, plus a cursor prop applied while the pointer is over them. <glarea>/<Canvas3D> takes onPointerMissed for clicks that hit nothing.

<mesh
cursor="pointer"
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
onClick={(ev) => console.log('hit at', ev.point, 'distance', ev.distance)}
>

The event carries object (the mesh that was hit), point (world coordinates), distance, face, uv, the pixel x/y, nativeEvent, and stopPropagation() — events bubble from the mesh up through its <group> ancestors. Only the nearest hit is dispatched.

Picking is client-side raycasting, not GPU picking: a ray through the clicked pixel is intersected with the same CPU-side geometry the display lists were compiled from, using the world matrices of the last frame drawn. Reading pixels back would be a round trip per event, and on XQuartz GL output is not readable through GetImage at all. Only meshes that — or whose ancestors — have handlers are tested, and the surface asks the X server for pointer events only when the scene has at least one handler.

Lighting is the fixed-function pipeline: per-vertex Gouraud shading and 8 light units in total — more than eight non-ambient lights warns and uses the first eight. <ambientLight> costs no unit; its colour rides on the first light's ambient term. A lit material in a scene with no lights falls back to flat colour rather than rendering black. Light positions are world space, so a light inside a rotating <group> moves with it.

Not implemented, and failing with an error naming the reason: <shaderMaterial> (the protocol encodes no shaders), <instancedMesh>, <points>, <line>, post-processing — and no shadows, which need framebuffer objects. There are no camera elements either: the camera prop is the whole camera API. See glx.md for why the transport forces all of this.


Rich content

Thin wrappers over ntk's document widgets in standalone mode. The widget's own layout feeds a yoga measure function: given the width the flexbox offers, the element reports the document's content height — so rich content participates in flex layout and scrolls naturally inside a <scrollview>. Spacing comes from the box model (padding prop), not a widget page margin.

Async content — an <img> inside <html> — reflows when it arrives via ntk's onInvalidate widget hook (added in ntk 3.4.0; the declared dependency is ntk 5). <markdown> lays out synchronously and never needs a second pass.

<markdown>, <html> and <tex> take their content as a string child (the react-markdown convention) or a source prop; the child wins when both are present. Use a template-literal expression — JSX collapses newlines in literal text:

<markdown onLink={open}>{`
# Hi

Some *markdown*.
`}</markdown>

<markdown>

ntk MarkdownView: headings, emphasis, lists, quotes, tables, syntax-highlighted code fences, math/latex fences (KaTeX).

prop
childrenmarkdown text (string), or use source
sourcemarkdown text
onLink(href, ev)a rendered link was clicked
themeMarkdownView theme overrides ({size, color, family, linkColor, codeTheme, …})

<html>

ntk HtmlView: its own CSS cascade (document <style>s plus the stylesheet prop), block/flex layout, images.

prop
childrenHTML markup (string), or use source
sourceHTML document or fragment
stylesheetextra author CSS (string or array)
baseUrlresolve relative image src against this
loadResource(url, {element})custom resource loader (or null to disable)
onLink(href, ev, element)a link was clicked
themebase look ({family, size, color})

<svg>

ntk SvgView (static SVG via Path2D — paths, shapes, gradients, transforms, basic text). Sized like <image>, and by the same style properties: natural viewBox size, aspect-preserving when the style sets only one of style={{ width, height }}, and scaled into the content box when it sets both.

Content is JSX children, like SVG in React DOM — SVG elements are declarative children with camelCase props (strokeWidth, fillRule; native-camelCase attributes like viewBox stay as-is), re-rendered on any prop change:

<svg viewBox="0 0 24 24" style={{ width: 40, height: 40 }}>
<circle cx={12} cy={12} r={10} fill={active ? '#2980b9' : '#ccc'} />
<path d="M8 12l3 3 5-6" stroke="white" strokeWidth={2} fill="none" />
</svg>

A source markup string is also accepted (children win when both are present). Supported elements/attributes are SvgView's (unsupported tags are skipped); per-child event handlers are not dispatched — put handlers on the <svg> element itself.

prop
childrendeclarative SVG elements
sourceSVG markup string
viewBoxcoordinate system (children form)

currentColor and recolouring. fill="currentColor" and stroke="currentColor" resolve to the node's color style, so one icon serves every state the UI puts it in:

<svg
source={icons.gauge}
style={{
width: 20,
height: 20,
color: '$fg',
':hover': { color: '$accent' },
}}
/>

A drawing whose paint is entirely currentColor — or entirely one colour — is cached as coverage rather than as pixels, so recolouring it is a composite and not a re-render, and every colour of it shares one rendered copy. Drawings with two colours or a gradient bake their colours in, which is right: those colours belong to the drawing rather than to the UI. Nothing to configure either way. REACT_X11_DEBUG_PAINT_CACHE=1 shows what is being kept.

<tex>

A KaTeX formula via ntk layoutTex — an intrinsically-sized box (no wrapping), drawn as server-side glyphs/rects.

prop
childrenTeX source (string), or use source
sourceTeX source
sizebase font size (the formula em), px
displayModeKaTeX display mode (default false)
katexextra KaTeX options (macros, strict, …)

The ink colour is style={{ color }} (default #222222), like <text>color is a style name, so it is not a prop here.