node.js desktop UI toolkit for X11
A set of wrappers around the low level node-x11 module to simplify X Window UI programming — window creation, DOM-style event handling, 2d/3d graphics — using API concepts you already know from the web.
Everything, including font rasterization, is pure JavaScript: npm install
never compiles anything.
Docs & live playground: https://sidorares.github.io/ntk/ — the playground runs ordinary ntk code in your browser against node-x11's in-browser pure-JS X server (XRender included) with bundled fonts. The same server also works headless in node — see docs/xserver.md — so ntk apps and tests can run with no real X server at all.
npm install ntk
Requires Node.js >= 20.19 and an X server. Full documentation lives in docs/.
import { createClient } from 'ntk';
const app = await createClient();
const wnd = app.createWindow({ width: 500, height: 300, title: 'Hello' });
wnd.on('mousedown', (ev) => wnd.setTitle(`click: ${ev.x},${ev.y}`));
wnd.map();Each window (or pixmap) can create a 2d canvas implementing the HTML
context2d api
via the
XRender extension —
paths (arcs, beziers, Path2D with SVG path data, non-zero/even-odd fill
rules), transforms with save()/restore(), clipping, globalAlpha and
Porter-Duff composite ops (docs/context-2d.md).
Most operations are performed on the X server side (image composition,
scaling, blur, text composition, gradients etc). Text is fully shaped in
pure JS — OpenType kerning/ligatures and complex scripts (fontkit), bidi
(bidi-js), automatic font fallback — rasterized by a built-in scanline
rasterizer and cached server-side as XRender glyphs, so drawing a line of
text costs about a byte per glyph on the wire. Font names resolve through
fontconfig (fc-match). Very large and continuously animated sizes render
as server-side trapezoids instead of cached bitmaps. A TextLayout engine
wraps styled text to a target width — see docs/text.md.
PNG/JPEG images decode client-side (loadImage) and composite server-side
via ctx.drawImage (docs/images.md). An SvgView widget
renders static SVG (shapes, gradients, transforms, use) through the same
2d pipeline (docs/svg.md).
Rendering documents — markdown, formulas, rich text — is not ntk's job:
it draws, and a document is a tree of layout decisions on top of that.
@react-x11/components
is where those live, over the react-x11 renderer.
import { createClient } from 'ntk';
const app = await createClient();
const wnd = app.createWindow({ width: 800, height: 600 });
const ctx = wnd.getContext('2d');
wnd.on('mousemove', (ev) => {
const gradient = ctx.createRadialGradient(0, 0, 10, ev.x, ev.y, 500);
gradient.addColorStop(0, 'red');
gradient.addColorStop(0.5, 'green');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, ctx.width, ctx.height);
});
wnd.map();ctx.drawImage() also accepts a node-canvas
canvas as a source — for images with lots of drawing calls it might be more
efficient to draw locally and transfer pixels to the server when ready.
Noisy events (resize, mousemove, expose) are coalesced into paced
frames — the latest state wins, nothing queues up — and each frame is
fenced with a server round-trip, so rendering automatically slows to the
connection's real throughput instead of drawing a trail of stale updates
over ssh-forwarded displays. Animation uses the DOM-style
requestAnimationFrame:
function frame(now) {
// ... draw ...
wnd.requestAnimationFrame(frame); // ~60fps locally, RTT-paced remotely
}
wnd.requestAnimationFrame(frame);See docs/window.md for the knobs (frameInterval,
frameSync, coalesceEvents) and the raw uncoalesced event stream.
Server-side resources support using / await using (Node 24+):
{
await using app = await createClient();
using pixmap = app.createPixmap({ width: 256, height: 256, depth: 24 });
// ... draw ...
} // pixmap freed, connection closedOnly indirect GLX is supported, with most of the OpenGL 1.4 api implemented. Note that on many systems indirect GLX is disabled by default — you'll need to enable it for gl to work.
import { createClient } from 'ntk';
const app = await createClient();
// GLX drawables need a GLX-capable visual, chosen before the window exists
const glx = await app.chooseGLXConfig({ DEPTH_SIZE: 24 });
const wnd = app.createWindow({ width: 300, height: 300, visual: glx.visual, depth: glx.depth });
wnd.map();
const gl = wnd.getContext('opengl', glx);
gl.ClearColor(0.3, 0.3, 0.3, 0.0);
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.Begin(gl.TRIANGLES);
gl.Color3f(1, 0, 0);
gl.Vertex3f(-1, -1, 0);
gl.Vertex3f(1, -1, 0);
gl.Vertex3f(0, 1, 0);
gl.End();
gl.SwapBuffers();See examples/ for more (teapot, GL clock, textures, text rendering).
Likely to be implemented outside as part of a react renderer (react-x11).