Native Linux desktop apps written in TypeScript and TSX.
No DOM, no Electron, no GTK/Qt: your components are drawn by a small native core
(Wayland/X11 + OpenGL ES), with SolidJS-style reactivity. The result starts fast, uses
between a quarter and half the memory of an equivalent Electron app, and becomes an
AppImage, .deb or .rpm with one command.
import { Button, Column, Text, Window, createSignal, run } from "@nodebuilderjs/core";
function App() {
const [count, setCount] = createSignal(0);
return (
<Window title="Counter" width={400} height={300}>
<Column grow={1} align="center" justify="center" gap={16}>
<Text fontSize={40} bold>{count}</Text>
<Button label="+1" variant="primary" onClick={() => setCount((c) => c + 1)} />
</Column>
</Window>
);
}
run(() => <App />);Status: version 0.4, experimental. The API may still change. See the roadmap.
Requirements: Linux with Wayland or X11 and Bun 1.4+.
bun create nodebuilderjs my-app
cd my-app
bun run dev # opens the window and restarts on saveThe project comes with an example test (bun test, no window), TypeScript already
configured (bun run typecheck) and an AGENTS.md that explains the framework to coding
agents such as Claude Code.
Window does not open, text looks wrong or something fails to load? Run the doctor:
bun run doctor # or: bunx nodebuilder doctor✓ Bun 1.4.3
✓ native core: @nodebuilderjs/linux-x64 (glibc 2.34 required, system 2.39) · FreeType 2.13.2 · HarfBuzz 8.3.0
✓ Wayland (WAYLAND_DISPLAY=wayland-0)
✓ compose table for pt_BR.UTF-8 (dead keys, accents)
✓ GLES offscreen: Mesa Intel(R) Xe Graphics (OpenGL ES 3.2 Mesa 25.2)
✓ UI font: Noto Sans
! no color emoji font: emoji draw as boxes or in monochrome
→ sudo apt install fonts-noto-color-emoji
- libheif not found: AVIF/HEIC images disabled
It checks Bun, the native core and the system libraries it links to (naming the apt, dnf or
pacman package of each missing one), the display server a window would use, the keyboard's
compose table, GL, the UI font and the emoji and CJK fallbacks, and libheif. It exits 1 when
something is broken. --json is for scripts and coding agents; --window tests GL by opening
a small window for a moment (by default GL is tested offscreen, and on a laptop with two GPUs
the window may use the other one). Paste its output in bug reports.
The AGENTS.md and skills in .agents/skills that coding agents read describe the framework
version the app was created with. After updating @nodebuilderjs/core, bring them along:
bun run agents:update # or, in apps created before 0.4.1: bunx nodebuilder agents updateIt replaces only docs the framework released (it knows the hash of every version). A doc
edited in the app is kept and listed (--force replaces it), and in AGENTS.md only the block
between the nodebuilder:begin/end markers is the framework's: notes after it stay. The
doctor warns when the docs are behind.
- Components run once. There is no re-render and no virtual DOM. State is
createSignal, derived values arecreateMemo. - Only functions are reactive.
<Text>{count}</Text>updates on its own;<Text>{count()}</Text>reads the value once. The same goes for styles:background={() => (active() ? "#3584e4" : "#ffffff")}. - Styles are props, not CSS:
padding,gap,grow,align,justify,background,radius,color,fontSize... The layout is flexbox. - Built-in widgets:
Window(withWindowControlsfor a custom title bar),Column,Row,Scroll,Text,Image,Icon,Canvas,Button,TextField,TextArea,Checkbox,Toggle,RadioGroup,Slider,Select,Tabs,Tooltip,Dialog,ProgressBar,Spinner,Card,Divider,Spacer, plus<For>/<Index>,<Show>/<Switch>and<ErrorBoundary>for lists, conditions and errors, andcreateContextfor values shared down the tree.
Complete examples in examples/: counter, to-do list, images, icons,
freehand drawing, charts, overlays (menu, dialog and tooltip), text (paragraphs, alignment,
ellipsis), scrolling (horizontal, both axes, scrollIntoView) and a dashboard
with tabs, a table and a chart.
A simplified flexbox: Column/Row, gap, padding, grow, align (stretch by default;
alignSelf overrides it for one child), justify, min/max sizes, wrap, margin, plus
Grid and aspectRatio. Sizes (width, height, min*, max*) also take a percentage of the
parent's content box, as in CSS (width="50%"):
-
margin(8,[v, h]or[top, right, bottom, left], likepadding): space outside the border that the parent keeps free. It adds to the child's size in everything around it (natural sizes,grow, shrinking,Gridcells,wraplines); a stretched child gets the room minus its margins. Negative values pull a neighbor over or let a child bleed into its parent's padding (margin={[0, -16]}in aCardruns edge to edge). It is not painted and not clickable. On an absolute child it adds toleft/top/right/bottom. Margins do not collapse (unlike CSS block margins) and add togap. -
<Row wrap gap={6} rowGap={8}>(CSSflex-wrap: wrap): children that do not fit start a new line (chips, tags). Each line is laid out on its own (grow,justify,alignSelfinside the line), is as tall as its tallest child and lines stack from the top,rowGap(defaultgap) apart. Its natural width is a single line and its minimum the widest child, so a stretched or shrunk Row wraps at whatever width it gets and its height follows. A<Column wrap>breaks into columns at its height (give it one). On<Text>,wrapis word wrapping instead.import { Box, Row, Text } from "@nodebuilderjs/core"; const Tags = (props: { tags: string[] }) => ( <Row wrap gap={6}> {props.tags.map((t) => <Box padding={[4, 10]} radius={12} background="#00000010"><Text>{t}</Text></Box>)} </Row> );
-
<Grid columns={4} gap={8} rowGap={12}>: children flow into rows of equal-width cells (CSSgrid-template-columns: repeat(4, 1fr)); the last row keeps empty cells, so widths stay equal. Each row is as tall as its tallest cell and cells stretch to it (align/alignSelfchange that).rowGapdefaults togap. Works with<For>(items stay keyed; adding one reflows the cells). -
<Grid minColumnWidth={180}>: as many columns as fit at least 180 px wide, sharing the leftover (CSSrepeat(auto-fill, minmax(180px, 1fr))): the grid reflows as the window resizes.columnsnext to it is the most per row. -
aspectRatio(width / height) on any widget: the free side follows the other, like CSSaspect-ratio. In a column (or aGridcell) the height follows the width; with a fixedheight, or stretched in aRowof known height, the width follows the height.min/maxstill clamp; children taller than the ratio make a box taller (anImagetakes the ratio as is). In aGrid, anaspectRatiocell keeps its shape instead of stretching to the row.import { Grid, Image } from "@nodebuilderjs/core"; const covers = ["a.jpg", "b.jpg", "c.jpg", "d.jpg"]; const Covers = () => ( <Grid columns={3} gap={8}> {covers.map((src) => <Image src={src} aspectRatio={1} fit="cover" radius={8} />)} </Grid> );
-
Equal or proportional columns in a row:
width={0} grow={1}on each child (CSSflex: 1 1 0); the widths follow thegrowvalues (1:1:2), not the content. In a column with a height,height={0} grow={1}does the same for rows. -
position="absolute"withleft/top/right/bottom(from the parent's padding box, as in CSS: inside its border, ignoring itspadding): out of the flow and at its natural size, unless bothleftandright(ortopandbottom) are set without awidth(height): then it stretches between them, soleft={0} right={0} top={0} bottom={0}fills the parent.rightalone places it from the right edge. Inside aScrollit scrolls with the content, like CSS. What sticks out of a parent that does notclipis drawn and clickable there too. -
zIndex(default 0) orders siblings: higher is drawn and hit above, ties keep child order (so without it, a later child covers an earlier one). It only orders siblings, with no global stacking context: a raised node stays inside its parent's place in the order. Overlays stay above everything. -
radiusis one number or one per corner,[topLeft, topRight, bottomRight, bottomLeft](radius={[0, 12, 12, 0]}); borders,clipandshadowfollow it. -
clipcuts the children to the box, rounded byradius;shadow={{ color, blur, y }}is drawn around the box, never under it (as in CSS): without abackground, or with a translucent one, the inside stays clear. Like CSSoverflow: hidden, a clipping node may shrink below its content (what it cuts is neither seen nor clicked); give it aminHeightwhere that must not happen.run()warns about it in development. -
Gradients:
background={{ linear: ["#1db954", "#121212"], angle: 180 }}is a CSSlinear-gradient:anglein degrees (0 = to top, 90 = to right, default 180 = to bottom), stops as colors or[color, offset](offset 0 to 1; missing offsets are spread evenly). Corners,radius, borders andopacitywork as with a color, and it can be reactive (background={() => ({ linear: [theme.accent.base, "transparent"] })}follows the theme). Colors are interpolated in premultiplied space like CSS, so fading to"transparent"never turns gray. Borders, text and shadows stay solid colors. -
Colors are CSS strings: hex (
"#3584e4","#00000010"with alpha),"transparent", and the wide-gamut formscolor(display-p3 1 0.3 0),color(srgb ...),oklch(0.7 0.2 145)andoklab(...)(channels as numbers or percentages, alpha after a slash:oklch(0.7 0.2 145 / 50%)). Hex is sRGB, as in CSS. The window asks the compositor for a Display P3 surface (Wayland color management,wp_color_manager_v1), so P3 and OKLCH colors show past sRGB on a wide-gamut display while hex colors look exactly as before; where the compositor cannot (X11, older compositors) those colors are clipped to sRGB.outputColorSpace()says which one the window got (reactive);run(app, { colorSpace: "srgb" })orNB_COLOR_SPACE=srgbkeeps sRGB.run(app, { deepColor: true })(NB_DEEP_COLOR=1) asks for a 16-bit float window buffer: smoother gradients in wide gamut, at twice the GPU memory for the window (off by default). -
Overflow warnings (development): when children need more room than their container gives them (after wrapping text and
growchildren have shrunk) and the excess is hidden by aclipancestor or the window,run()prints it once per node, e.g.layout: Row > Text("Artists") overflows Box(panel) by 42px (children need 342px, 300px available), anddebugTree()marks the container[overflow +42]. Content past aScrollon its scrolling axis is not reported (scrolling shows it). Silent withNODE_ENV=productionand in packaged executables. Fix it with aScroll horizontal, wrapping,minWidth={0}plusclipon the child that may be cut, or more room.
hoverStyle and pressStyle work on every widget (CSS :hover and :active). They take
any style props, static or as accessors, shown over the base style while the pointer is over
the widget or pressing it:
<Box padding={12} radius={8} background="#ffffff"
hoverStyle={{ background: "#f2f2f4" }} pressStyle={{ background: "#e6e6ea", opacity: 0.9 }}>
<Text>Row</Text>
</Box>pressStylewins overhoverStyle; leaving brings back the base values, which stay reactive underneath (a signal that changed meanwhile shows its latest value).focusStyleapplies while the widget has the keyboard focus (a custom focus ring); hover and press win over it.- They win over a widget's own hover colors (a
Button's theme background); adisabledwidget ignores them. hoverStylealone leaves presses alone;pressStyle(andcreateInteraction().props) make the widget handle the press, so inside a window's drag region it no longer moves the window.- Like
:active, a parent'spressStylealso shows while a child (a button in the card) is pressed;onPointerDown={(e) => e.stop()}on the child (or on aBoxaround aButton) keeps the press to the child.
When something else depends on the state, such as a play button that only shows while its card
is hovered, createInteraction() gives hovered, pressed and focused accessors and
props to spread on the widget:
const card = createInteraction();
<Box {...card.props} padding={12} background={() => (card.hovered() ? "#f2f2f4" : "#ffffff")}>
<Text>Song</Text>
<Show when={card.hovered}>
<Button label="Play" onClick={() => {}} />
</Show>
</Box>;The pointer is still over the card while it is over the play button. props adds to the
widget's own handlers and to onPointerEnter/onPointerLeave/... written next to it,
before or after the spread; nothing is replaced, and several interactions can share a widget.
focused only changes on focusable widgets (Button, TextField...).
Any widget takes onClick, onPointerDown/Up/Enter/Leave/Move, onContextMenu,
onScroll, onKeyDown, onKeyUp, onFocus and onBlur (controls too: they are added to the
control's own handlers, never replace them). Pointer events carry x/y (window),
localX/localY (node), button (0 left, 1 middle, 2 right) and clicks (2 = double click).
Pointer, wheel and key events carry the modifiers ctrl, shift, alt and meta (Super):
Ctrl+click, Shift+click to extend a selection, Ctrl+wheel to zoom.
import { Column, Overlay, Show, Text, createSignal, type Point } from "@nodebuilderjs/core";
const [menu, setMenu] = createSignal<Point | null>(null);
<Column onContextMenu={(e) => setMenu({ x: e.x, y: e.y })}>
<Text onClick={(e) => (e.ctrl ? console.log("open link") : console.log("edit"))}>[[note]]</Text>
<Show when={menu}>
{(at) => (
<Overlay at={at} onDismiss={() => setMenu(null)}>
<Text>Rename</Text>
</Overlay>
)}
</Show>
</Column>;-
onContextMenuruns on a right press (afteronPointerDown, unless it callede.stop()) and on the Menu key or Shift+F10 over the focused node (at its bottom-left corner,button-1); only the innermost handler runs.<Overlay at={{ x, y }}>opens the menu there and moves it to the other side of the point when it does not fit. -
Key names are xkb's (
"Return","Escape","Tab","BackSpace","Delete","space","Up"/"Down"/"Left"/"Right","Home","End","Page_Up","Page_Down","Insert","F1"…"F12","Menu", letters and digits as typed), normalized: Shift+Tab is"Tab"withshift, keypad keys have the plain names ("KP_Enter"is"Return"), and with Ctrl or Alt a letter is always lowercase, soe.ctrl && e.key === "o"works with Shift or Caps Lock too.textis what the key types (""for control keys and with Ctrl); modifier keys alone come as"Control_L","Shift_R","Alt_L","Super_L"… -
A press focuses the nearest focusable ancestor of what was pressed (or nothing: pressing empty space blurs).
-
onClickis the left button only and runs on the innermost node that has one (pressed and released on it): a button inside a clickable card clicks the button, not the card. -
onPointerDown,onPointerUp,onPointerMove,onScrollandonKeyDownbubble from the innermost node to the root until a handler callse.stop(), also past nodes withonClick: a list'sonPointerDownwithe.button === 2(context menu) sees a right press on any of its clickable rows. On keys and the wheel,stop()also keeps the surroundingScrollstill. -
While a button is held,
onPointerMovekeeps reaching the node where the press started. -
Keys start at the focused element; with nothing focused they go to the
Window(and, with a modal open, to the modal first):<Window onKeyDown={...}>sees every key nothing below took, which makes window-wide shortcuts. On a control, the app'sonKeyDownruns first ande.stop()takes the key from the control. -
focusableon a container (<Column focusable onKeyDown={...}>) lets a page or a menu hold the keyboard: a click focuses it, without a focus ring;tabStopadds it to the Tab order.
import { Column, Image } from "@nodebuilderjs/core";
import logo from "./logo.png" with { type: "file" };
import photo from "./photo.jpg" with { type: "file" };
<Column gap={8}>
<Image src={logo} width={64} /> {/* height follows the aspect ratio */}
<Image src={photo} width={200} height={120} fit="cover" radius={12} opacity={0.8} />
</Column>fit can be contain (default: the whole image, centered), cover (fills and crops)
or fill (stretches). Without width/height, the image uses its own size in pixels. The
with { type: "file" } makes nodebuilder package embed the file in the executable; src
also accepts a path, a file: URL, the file's bytes (Uint8Array) or already decoded
pixels ({ width, height, data } in RGBA, plus colorSpace: "display-p3" when they are P3).
Formats: PNG, JPEG and WebP are decoded in TypeScript (JPEG honors the EXIF
orientation, and all of them produce the same result, byte for byte, as libpng, libjpeg-turbo
and libwebp). AVIF and HEIC use the system libheif, loaded only when needed: without it,
only those formats fail, and the generated packages suggest it without requiring it. SVG is
drawn by <Icon> (below).
Color profiles are honored: an image tagged Display P3 (iPhone photos, P3 PNGs, AVIF with
P3 CICP) keeps its wide-gamut colors, and other RGB ICC profiles (Adobe RGB, ProPhoto...) are
converted to Display P3 when decoding; decodeImage() says which in colorSpace. On a P3
window the GPU draws sRGB and P3 images as they are; on an sRGB one, P3 images are clipped to
sRGB. Untagged images are sRGB, as in browsers.
import camera from "lucide-static/icons/camera.svg" with { type: "text" }; // bun add lucide-static
import heart from "@phosphor-icons/core/assets/duotone/heart-duotone.svg" with { type: "text" };
import { Star } from "lucide"; // or the lucide data
import { Button, Icon, Row, Text } from "@nodebuilderjs/core";
<Row gap={8}>
<Icon svg={camera} /> {/* 20 px, color inherited like text */}
<Icon svg={heart} size={32} color="#e01b24" />
<Button variant="primary"><Icon svg={Star} size={16} /><Text>Favorite</Text></Button>
<Icon svg={Star} fill /> {/* solid: outline icons filled with their color */}
</Row><Icon> draws SVG with its own renderer, written in TypeScript and built for icons: paths
(with arcs and curves), shapes, strokes with caps and joins, transform, opacity and
currentColor. Lucide and Phosphor come out practically identical to librsvg (mean difference
of 0.8 out of 255 on stroke edges). A single-color icon is rasterized once, at the exact
screen size (sharp on HiDPI too), and the color is applied on the GPU: changing the color, on
hover for example, costs nothing. strokeWidth changes the thickness of outline icons, as in
Lucide. fill makes them solid, also as in Lucide: it replaces the fill of the root <svg>
(Lucide's is none), so the shapes are filled with the icon's color (fill) or another one
(fill="#e01b24") and keep their stroke; a fill that a group or shape sets itself, none
included, still wins. It can be reactive (fill={liked}). Gradients, text, filters, masks and <use> are not supported yet, so complex
illustrations should be turned into PNG. React packages (lucide-react,
@phosphor-icons/react) don't work: use lucide-static, lucide or @phosphor-icons/core.
Many icons: let the CLI write the imports into one module instead of by hand.
bun add lucide-static
nodebuilder icons add play pause heart arrow-left # -> src/icons.ts (--out to change)
nodebuilder icons add heart-fill house --from @phosphor-icons/core # weights by suffix
nodebuilder icons list arrow # names containing "arrow"Each icon becomes one sorted, deduplicated line, import arrowLeft from "lucide-static/icons/arrow-left.svg" with { type: "text" }; export { arrowLeft }; (camelCase
name; the rest of the file is kept), so import { arrowLeft } from "./icons.js" gives the SVG
text and nodebuilder package embeds it. Each file is checked in node_modules; an unknown
name fails with close matches (did you mean heart-crack?) and writes nothing. Not
export { default as x } from "x.svg" with { type: "text" }: Bun drops the attribute on a
re-export and gives the file's path; the command rewrites such lines it finds.
<Canvas height={160} draw={(g) => {
const pts = data().flatMap((v, i) => [i * 10, g.height - v]); // signals read here trigger a redraw
g.polygon([0, g.height, ...pts, g.width, g.height], "#3584e433"); // area
g.polyline(pts, theme.accent.base, 2); // line
g.arc(80, 80, 60, 40, 0, Math.PI, "#26a269"); // donut: radii, start, sweep
}} /><Canvas> draws with antialiased GPU primitives: thick lines with round, butt or square
caps, filled polygons (concave ones included), circles and arcs (pie, donut, gauges) and
text. Coordinates are local to the canvas; g.width and g.height give its size. draw is
reactive: it runs inside an effect, so the signals it reads (data, theme colors) are tracked
and changing one of them redraws. Geometry is computed when the data changes, not every frame.
g.text(text, x, y, color, size?, bold?, { maxWidth, ellipsis, weight, italic, letterSpacing, family, features, variations })
draws one line; with maxWidth text that does not fit is cut and ends in "…" (ellipsis: false just cuts it; nothing is drawn when not even the "…" fits); weight wins over bold.
g.measureText(text, size?, bold?, { weight, italic, letterSpacing, family, features, variations })
({ width, height }) measures it, to align it. Without family, features or variations,
canvas text uses the fontFamily, fontFeatures and fontVariations around the canvas, so
charts follow the app's font.
Lines take a dash pattern (g.line(..., cap, [4, 3]), g.polyline(..., { dash })).
g.linearGradient(x0, y0, x1, y1, stops) returns a gradient (from offset 0 at the first point
to 1 at the second, in canvas coordinates) that every shape takes in place of a color:
g.polygon(area, g.linearGradient(0, 0, 0, g.height, ["#3584e480", "transparent"])).
Limits: a self-intersecting polygon is not drawn, and lines with a translucent color get darker
at the joins.
A canvas that draws a long document can size itself and draw only what shows:
import { Canvas, Scroll, createSignal } from "@nodebuilderjs/core";
const [lines, setLines] = createSignal<string[]>([]);
<Scroll grow={1}>
<Canvas
measure={(width) => lines().length * 20} // height for the width it gets (wrap here with measureText)
draw={(g) => {
const { y, height } = g.visibleRect; // the part on screen; reading it redraws on scroll
for (let i = Math.floor(y / 20); i < Math.min(lines().length, Math.ceil((y + height) / 20)); i++)
g.text(lines()[i]!, 0, i * 20, "#222");
}}
/>
</Scroll>;measure={(width) => height}: the height of the content for the width the canvas gets (inside its padding), as wrapping text does; the signals it reads lay it out again when they change.measureText(exported) measures with the same metrics outsidedraw.g.visibleRect({ x, y, width, height }, local): the part on screen, cut by everyScrollandcliparound the canvas and by the window. Only adrawthat reads it runs again on scroll.
import { LineChart, Line, Area, AreaChart, BarChart, Bar, PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Legend } from "@nodebuilderjs/core/charts";
const sales = [{ month: "Jan", revenue: 120, target: 100 }, { month: "Feb", revenue: 150, target: 110 }];
<LineChart data={sales} height={240}> {/* data can be a signal: the chart follows it */}
<CartesianGrid vertical={false} />
<XAxis dataKey="month" />
<YAxis tickFormatter={(v) => `$${v}`} />
<Line dataKey="revenue" name="Revenue" />
<Line dataKey="target" stroke="#e5a50a" type="monotone" dot={false} />
<Legend />
</LineChart>The API follows Recharts: LineChart, AreaChart, BarChart, ComposedChart and
PieChart, with Line, Area, Bar, Pie/Cell, XAxis, YAxis, CartesianGrid and
Legend as children. Axes use round steps (like d3), the y axis includes zero, and
type="monotone" smooths the curve. There is also <Tooltip> (guide line and the values of
the category under the mouse; on a pie, the slice), stacking with stackId, strokeDasharray
on lines and grid, label on slices and bars, and the x axis drops labels that overlap.
Everything is drawn with <Canvas>, on the GPU, and ships in the core itself: if you don't
import @nodebuilderjs/core/charts, none of it ends up in the executable (if you do, ~15 KB).
Scatter and radar charts, ReferenceLine, Brush and secondary axes don't exist yet.
As in SolidJS: components that decide when to create (and dispose of) their children.
import { Button, Column, ErrorBoundary, For, Index, Match, Show, Switch, Text, createSignal } from "@nodebuilderjs/core";
const [user, setUser] = createSignal<{ name: string } | null>(null);
const [status, setStatus] = createSignal<"loading" | "error" | "ok">("loading");
const [tasks] = createSignal([{ title: "Buy bread" }]);
const [scores] = createSignal([7, 9, 10]);
// Show: with a function child, it receives the (truthy) value as an accessor.
<Show when={user} fallback={<Text>Sign in to your account</Text>}>
{(u) => <Text>{() => `Hello, ${u().name}`}</Text>}
</Show>;
// Switch: the first truthy Match; the content is only recreated when the chosen Match changes.
<Switch fallback={<Text>All good</Text>}>
<Match when={() => status() === "loading"}><Text>Loading...</Text></Match>
<Match when={() => status() === "error"}><Text color="#c01c28">Failed</Text></Match>
</Switch>;
// For: keyed by reference (the row follows the object); i is an accessor.
<For each={tasks} fallback={<Text>Nothing to do</Text>}>
{(t, i) => <Text>{() => `${i() + 1}. ${t.title}`}</Text>}
</For>;
// Index: by position (good for primitives); item is an accessor, i is a fixed number.
<Index each={scores}>{(score, i) => <Text>{() => `Test ${i + 1}: ${score()}`}</Text>}</Index>;
// ErrorBoundary: an error while creating the children or in one of their effects shows the fallback.
<ErrorBoundary
fallback={(error, retry) => (
<Column gap={8}>
<Text>{`Something went wrong: ${error instanceof Error ? error.message : String(error)}`}</Text>
<Button label="Try again" onClick={retry} />
</Column>
)}
>
<Text>{() => user()!.name}</Text>
</ErrorBoundary>;- Unlike Solid, there is no compiler: an expression written in JSX runs when that JSX is
evaluated, even inside a
<Show>whose condition is false.<Show when={() => t.due}><Text> {shortDate(t.due!)}</Text></Show>callsshortDate(undefined)at once. Put what depends on the condition in a function:{() => shortDate(t.due!)}, or take the value from a function child:{(due) => <Text>{shortDate(due())}</Text>}. Components (<Row/>,<MyCard/>) do wait: their bodies run only when shown. whenaccepts a value or an accessor. The function child must declare its parameter ({(u) => ...}); a function without parameters ({() => ...}) is still a plain reactive child. The accessor keeps the last truthy value, so an effect in the row that runs before the row is disposed doesn't seenull.<Switch>only accepts<Match>as a child (orfalse/null, for{cond && <Match>});<Match>outside a<Switch>is an error.<ErrorBoundary>catches what is thrown while creating the child components and, after that, by their effects and memos (including when a click changes a signal and an effect fails). Whatever the children created is disposed of (effects,onCleanup, overlays). Thefallbackcan be content or a function(error, reset);reset()creates the children again. It doesn't catch what the handler itself (onClick...) throws: that error reaches whoever called the handler, as in React and Solid. Errors without a boundary still show up as before.
import { Box, Column, Scroll, TextField, Text, createContext, createSignal, onMount, useContext, type ElementRef } from "@nodebuilderjs/core";
// Context: a value any descendant can read, without passing props through every level.
const Language = createContext("en");
function Greeting() {
return <Text>{useContext(Language) === "en" ? "Hello" : "Olá"}</Text>;
}
<Language.Provider value="pt"><Greeting /></Language.Provider>;
// ref: the widget's element, to focus, scroll and read the box from the last layout.
const [name, setName] = createSignal("");
<TextField ref={(el) => el.focus()} value={name} onInput={setName} />;
// onMount: runs once the component's nodes are in the tree and its refs are set.
function Chat() {
let list: ElementRef | undefined;
onMount(() => list!.scrollTo(1e9));
return <Scroll ref={(el) => (list = el)} height={200}><Text>...</Text></Scroll>;
}
// onResize: the node's size after layout (the first time and whenever it changes).
// What it changes is laid out in the same frame.
const [width, setWidth] = createSignal(0);
<Box grow={1} onResize={(s) => setWidth(s.width)}>
<Column>{() => (width() > 600 ? "wide" : "narrow")}</Column>
</Box>;- For a value that changes, pass an accessor in the context (
value={mode}, withmodea signal) and call it where you use it (useContext(Mode)()). refreceives anElementRef:x,y,widthandheightfrom the last layout (window coordinates),focus(),blur(),scrollIntoView()and, on aScroll,scrollTo(y)orscrollTo({ x, y }),scrollXandscrollY.refis called when the widget is created, before layout; afocus()orscrollIntoView()at that point takes effect as soon as the widget enters the tree. Code that uses the element once it exists goes inonMount(an effect in the component body runs before the JSX below it, so the ref is still unset).ariaLabel(any widget, fixed or a function) names an element that has no text, such as an icon-only button:<Button ariaLabel="Play">. Tests find it withfindByLabel(see Testing). The window's title-bar buttons are labelled"Minimize","Maximize"/"Restore"and"Close", and aTooltipgives its text to a trigger without a label. There is no screen reader support yet (see the limitations).
import { Button, Card, For, Row, Scroll, Text } from "@nodebuilderjs/core";
import type { ElementRef } from "@nodebuilderjs/core";
const items = Array.from({ length: 200 }, (_, i) => `Item ${i}`);
const rows: ElementRef[] = [];
<>
<Scroll horizontal gap={12}> {/* strip that scrolls sideways */}
<For each={items}>{(t) => <Card width={160}><Text>{t}</Text></Card>}</For>
</Scroll>
<Button label="Go to 150" onClick={() => rows[150]?.scrollIntoView()} />
<Scroll grow={1}>
<For each={items}>{(t, i) => <Row ref={(el) => (rows[i()] = el)} padding={8}><Text>{t}</Text></Row>}</For>
</Scroll>
<Scroll horizontal vertical height={300}>{/* wide, long table */}</Scroll>
</>;<Scroll>scrolls vertically;horizontalmakes a row whose children keep their width (text inside doesn't wrap, unless it haswidth/maxWidth);horizontal verticalscrolls on both axes, in a column where each child is at least as wide as the visible area.- Mouse wheel and touchpad on both axes; Shift+wheel scrolls sideways. The scrollbars are thin, drawn over the content (as in GTK and macOS), and widen under the mouse: dragging the thumb scrolls along, clicking the track moves one page. They only take the click (in a 14 px strip along the edge) when there is something to scroll.
- Keyboard: PageUp/PageDown, Home/End and the arrow keys scroll the
Scrollaround the focused element (with no focus, the one under the mouse), unless anonKeyDowncallse.stop().TextFieldandTextAreakeep the keys they move the caret with. scrollIntoView()scrolls eachScrollaround the element by the minimum needed, on the next frame (also for an element that was just created or that is far away, off screen).scrollIntoView({ x, y, width, height })shows only that region of the element (from its top-left corner): the caret of an editor drawn in aCanvas.- The content of a
Scrolldoesn't hold up the size of its container: a column with aScroll grow={1}inside shrinks withoutminHeight={0}. - An open
<Overlay modal>blocks the wheel, keys and scrollbars of what is behind it.
import { ProgressBar, RadioGroup, Slider, Toggle, createSignal } from "@nodebuilderjs/core";
const [wifi, setWifi] = createSignal(true);
const [size, setSize] = createSignal("m");
const [volume, setVolume] = createSignal(40);
<Toggle checked={wifi} onChange={setWifi} label="Wi-Fi" />;
<RadioGroup value={size} onChange={setSize} options={[
{ value: "s", label: "Small" },
{ value: "m", label: "Medium" },
{ value: "l", label: "Large", disabled: true },
]} />;
<Slider value={volume} onChange={setVolume} min={0} max={100} step={5} />;
<ProgressBar value={() => volume() / 100} />; // without value: indeterminate- Every control accepts
disabled(value or function): it is dimmed (theme.disabledOpacity), leaves the Tab order, loses focus and doesn't respond. - Keyboard:
Toggle/Checkbox/Buttonwith Space or Enter;RadioGroupis a single Tab stop and the arrow keys change the option (skipping disabled ones);Sliderwith the arrow keys (onestep), PageUp/PageDown (one tenth), Home/End. Toggleis the on/off switch: the nameSwitchis already taken by the control flow component.Spinnerspins while it is on screen (size,color).ProgressBarandSlidertakecolor(the filled part) andbackground(the track).Button'svariantmay be a function: the colors, hover and weight follow it.<TextField password>draws•for each character (the value stays real; a function toggles it for a "show password" button, and nothing can be copied out of it). ATextFieldkeeps the app'sborderColorandborderWidthwhen focused;focusBorderColor/focusBorderWidthrestyle its focus ring.
import { TextArea, TextField, createSignal, type TextSelection } from "@nodebuilderjs/core";
const [title, setTitle] = createSignal("");
const [body, setBody] = createSignal("");
const [caret, setCaret] = createSignal<TextSelection>({ anchor: 0, focus: 0 });
<TextField value={title} onInput={setTitle} onSubmit={() => {}} placeholder="Title" />;
<TextArea value={body} onInput={setBody} rows={6} placeholder="Write here" onSelectionChange={setCaret} />;- Both edit like a desktop text field: Shift+arrows, Shift+Home/End and a drag select;
Ctrl+arrows move by words; a double click picks a word, a triple click the line (
TextField: everything); Shift+click extends; Ctrl+A, Ctrl+C, Ctrl+X, Ctrl+V (also Ctrl+Insert, Shift+Insert, Shift+Delete); Ctrl+Backspace/Delete delete a word; Ctrl+Z undoes (a run of typing at a time) and Ctrl+Shift+Z or Ctrl+Y redoes. Typing replaces the selection. selection(an accessor of{ anchor, focus }, in code points;focusis the caret) moves the caret whenever it changes, andonSelectionChangereports every change: enough to split a line at the caret in a block editor. A value the app sets keeps the selection inside it and clears the undo history.TextAreawraps at the width it gets (a word longer than the line is split), is at leastrowslines tall (3) and grows with its content: inside aScrollthe caret stays in view as it moves. With aheight(ormaxHeight) that cuts the content, it scrolls by itself (wheel, caret). Return inserts a line, Ctrl+Return callsonSubmit; Up/Down keep the column, Home/End stay on the visual line, Ctrl+Home/End go to the ends, PageUp/PageDown move a page.
import { Button, Dialog, Match, Select, Switch, Tabs, Text, Tooltip, createSignal } from "@nodebuilderjs/core";
const [city, setCity] = createSignal<string | undefined>(undefined);
const [tab, setTab] = createSignal("general");
const [confirming, setConfirming] = createSignal(false);
<Select value={city} onChange={setCity} placeholder="City" options={[
{ value: "poa", label: "Porto Alegre" },
{ value: "sp", label: "São Paulo" },
]} />;
<Tabs value={tab} onChange={setTab} tabs={[{ value: "general", label: "General" }, { value: "account", label: "Account" }]} />;
<Switch>
<Match when={() => tab() === "general"}><Text>General</Text></Match>
<Match when={() => tab() === "account"}><Text>Account</Text></Match>
</Switch>;
<Tooltip text="Deletes permanently"><Button label="Delete" onClick={() => setConfirming(true)} /></Tooltip>;
<Dialog open={confirming} onClose={() => setConfirming(false)} title="Delete file?"
actions={<Button label="Delete" variant="danger" onClick={() => setConfirming(false)} />}>
<Text>This action cannot be undone.</Text>
</Dialog>;Select: the list opens below (or above, if it doesn't fit) with the width of the field; the arrow keys move the highlight, Enter picks, Esc closes. Focus stays on the field.Tabsis only the bar (one Tab stop, the arrow keys switch tabs); the content of each tab is up to you, with<Switch>/<Show>.Tooltipappears afterdelayms (500) with the pointer still, on theplacementside ("top"); it hides on leave or click.Dialogis a ready-made<Overlay modal>: it focuses the first control when it opens and gives focus back when it closes; Esc and the backdrop callonClose(dismissible={false}turns that off).inset={[theme.window.titlebarHeight + 1, 0, 0, 0]}leaves the title bar usable while it is open (the same prop works on<Overlay>).
import { Box, Text, Toggle, colorScheme, createPresence, createSignal, createTween, setTheme, theme } from "@nodebuilderjs/core";
// Theme: switches everything at once; colors read inside functions follow it.
<Toggle checked={() => colorScheme() === "dark"} onChange={(on) => setTheme(on ? "dark" : "light")} label="Dark theme" />;
<Text color={() => theme.text.secondary}>caption</Text>;
// A number that moves smoothly to its target; and something that fades out before leaving the tree.
const [open, setOpen] = createSignal(false);
const width = createTween(() => (open() ? 240 : 64), { duration: 200 });
const panel = createPresence(open, { enter: 180, exit: 120 });
<Box width={width} opacity={panel.progress} />;setTheme("dark" | "light")or a custom theme ({ ...darkTheme, accent: { ...darkTheme.accent, base: "#9141ac" } });colorScheme()tells which one is in use.theme.x = ...also updates.- Colors by name: every color prop also takes a theme path,
color="text.secondary",background="accent.base",borderColor="divider", like a CSS variable. It is resolved when painting, so it followssetThemewith no accessor and nothemeimport. createThemeTokens({ light: {...}, dark: {...} })adds the app's own colors (sidebar, links, code background), one value per scheme, usable by name in the same way:createThemeTokens({ light: { sidebar: "#f3f3f3", graph: { node: "#b3a37c" } }, dark: {...} }), thenbackground="sidebar"orcolor="graph.node"(an app name wins over a theme path of the same name; a token may name another,link: "accent.base"). It also returns an object for code (colors.sidebar, read inside a function). A custom theme takes the set of itsscheme. An unknown name paints magenta and warns once.- Text variants:
<Text variant="muted">(secondary color),"caption"(12 px, secondary) and"title"(20 px, bold);defineTextVariants({ label: { fontSize: 12, bold: true, color: "accent.base" } })adds the app's own or replaces these. Props on the Text win. - Theme values inside functions: in code,
color={() => theme.text.secondary}follows the switch;color={theme.text.secondary}keeps the color from that moment. The built-in widgets already follow it. animate(from, to, onUpdate, { duration, easing, onDone })returns a function that stops it;createTween(source, { duration, easing })follows a value;createPresence(when, { enter, exit, easing })givesmounted(staystrueuntil the exit finishes, for use in<Show>) andprogress(0 to 1). Durations in ms (default 200; presence 180 in, 120 out), default curveeasing.easeOut(alsolinear,easeIn,easeInOut, or any(t) => number).mixColor(a, b, t)blends two colors into"#rrggbbaa"(acolor(display-p3 ...)when the result is past sRGB), alpha included, in premultiplied space like CSS:mixColor("#ff0000", "transparent", 0.5)is red at half alpha, not gray.Dialog,Select,TooltipandTogglealready animate. Without a window (in tests) every animation finishes immediately;frames()from@nodebuilderjs/core/testingcontrols time.
<Text> wraps at spaces to fit the width it gets, as on the web; \n always breaks the
line. A word longer than the line overflows (it isn't cut in the middle).
import { Row, Text, theme } from "@nodebuilderjs/core";
<Text textAlign="center" lineHeight={1.4}>A long paragraph that wraps across several lines.</Text>;
<Row gap={8}>
<Text grow={1} maxLines={1}>a-very-long-file-name.pdf</Text>
<Text color={theme.text.secondary}>12 MB</Text>
</Row>;wrap={false}: a single line (apart from\nbreaks), even if it overflows.maxLines: truncates and ends the last line with "…";maxLines={1}shortens a single line.textAlign("start","center","end") andlineHeight(a multiple of the font's line spacing, default 1).fontWeight(1..1000, as in CSS: 300 light, 400 regular, 600 semibold, 700 bold, 900 black) picks the closest face the system font has (a variable font gets exactly that weight);boldis shorthand for 700 (bold={false}: 400), and on the same widgetfontWeightwins.italicuses the italic face, or slants the upright one when the family has none.letterSpacing(px, may be negative) goes after every character, the last one too, as in browsers; it turns ligatures off. All three are inherited, likefontSize:<Column fontWeight={500}>. Wrapping, selection andunderlinefollow them.
import { Text } from "@nodebuilderjs/core";
<Text fontWeight={300} fontSize={28}>Light headline</Text>;
<Text fontSize={12} bold letterSpacing={1.5}>SECTION</Text>;
<Text italic>An aside</Text>;<RichText> is one paragraph made of spans, each with its own style, wrapping across them
(rendered markdown, chat messages, a link in the middle of a sentence):
import { RichText, theme } from "@nodebuilderjs/core";
<RichText spans={[
"Run ", { text: "bun test", family: "monospace", background: "#8882" }, " or read ",
{ text: "the docs", color: theme.accent.base, underline: true, onClick: (e) => console.log(e.ctrl) },
". ", { text: "Bold", bold: true }, " and ", { text: "italic", italic: true }, " words.",
]} />;- A span is a string or
{ text, color, size, bold, weight, italic, family, letterSpacing, underline, background, onClick }; unset fields come from the inherited style, as inText.spansmay be an accessor.lineHeightandtextAlignwork as inText. - Spans of different sizes share a baseline. A span with
onClickis a link: the pointer cursor shows over it and a click on it runs it (with the modifiers, for Ctrl+click); a click elsewhere goes to the parent'sonClick. - Not selectable yet, and a word longer than the line overflows, as in
Text.
fontFamily takes a CSS family list, inherited like fontSize: fonts the app registers,
installed families by name, and the generic sans-serif (the default, the system UI font),
serif, monospace, cursive, fantasy, emoji, math and system-ui. Names match without
case and may be quoted. Each character uses the first family of the list that has it, then the
system's fallback; families that are not there are skipped.
registerFont(source, options?) adds a font file to the families, like CSS @font-face:
source is a path or the file's bytes, and an import with { type: "file" } keeps working in
the packaged executable. TrueType (.ttf), OpenType/CFF (.otf), collections (.ttc, every
face), WOFF and WOFF2; family, weight and slant come from the file ({ family, weight, italic }
override them; weight: [100, 900] for a range). Register every weight and slant under the
same family: text picks among them with the CSS rules (a variable font covers its whole wght
range), and synthesizes what the family lacks: bold (thicker outlines) and italic (slanted
glyphs). It returns the faces it added. Text already on screen is measured and drawn again.
fontFeatures turns OpenType features on and off ("tnum", "smcp, onum", "-liga",
'"ss01" 1': CSS font-feature-settings syntax, or HarfBuzz's) and fontVariations sets the
axes of variable fonts ("wdth 75, opsz 12", CSS font-variation-settings; a wght there wins
over fontWeight). Both are inherited. fontFamilies() lists the installed and registered
families (sorted), e.g. for a font picker.
import { Column, Text, registerFont } from "@nodebuilderjs/core";
import interRegular from "./fonts/Inter-Regular.woff2" with { type: "file" };
import interBold from "./fonts/Inter-Bold.woff2" with { type: "file" };
registerFont(interRegular);
registerFont(interBold);
<Column fontFamily="Inter, sans-serif">
<Text bold>Inter Bold</Text>
<Text fontFamily="monospace" fontFeatures="zero">0O 1l</Text>
<Text fontFeatures="tnum">1,234.50</Text>
</Column>;underline: a line under each line of text, in the text color (it follows wrapping,textAlignand the "…" ofmaxLines). Likecolor,fontSizeandboldit is inherited, so<Button underline>or aRow underlineunderlines the text inside;underline={false}turns it off again below.TextFieldignores it. A link that underlines on hover:<Text underline={hovered} onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)}>.- In a
Rowwithout room, children withgrowshrink first (like Flutter'sExpanded); text that wraps can shrink down to its longest word, and withmaxLinesdown to the "…".
<Overlay> draws its content on a layer above the whole window, outside the clipping of its
container, and still sees the context and text color of the place where it was declared.
import { Button, Card, Overlay, Show, Text, createSignal } from "@nodebuilderjs/core";
import type { ElementRef } from "@nodebuilderjs/core";
// Menu anchored to a button: below it (above, if it doesn't fit), with the same width.
const [open, setOpen] = createSignal(false);
let button: ElementRef | undefined;
<>
<Button ref={(el) => (button = el)} label="Options" onClick={() => setOpen((o) => !o)} />
<Show when={open}>
<Overlay anchor={() => button} matchWidth onDismiss={() => setOpen(false)}>
<Card padding={4}><Text>Rename</Text></Card>
</Overlay>
</Show>
</>;
// Modal dialog: covers the window, dims the background and blocks clicks and Tab behind it.
<Overlay modal background="#00000059" align="center" justify="center" onDismiss={() => setOpen(false)}>
<Card width={320}><Text>Delete file?</Text></Card>
</Overlay>;- Without
anchor, the layer covers the window and positions its children with the flex props (align,justify,padding) or withposition="absolute". Empty space lets clicks through, unless it ismodal. - With
anchor:placement("bottom","top","right","left"),offset(4) andmatchWidth. Pass the anchor as a function (() => button), becauserefis only filled in when the button is created. Too wide to start at the anchor, it ends where the anchor does. at={{ x, y }}(or an accessor) instead of an anchor: a point in window coordinates (a context menu at the pointer), placed like a zero-size anchor (offset0) and flipped to the left or above when it does not fit.onDismissis called on a click outside (for a modal, on the backdrop; never on the anchor) and on Esc.- An overlay opened later stays on top of the earlier ones.
opacity (0 to 1) and translateX/translateY work on any widget and are meant for
transitions, as in the bun run overlays example. They update with a signal and
requestAnimationFrame. translate moves the node and its children after layout, without
pushing its siblings, like CSS transform.
import { Button, Row, clipboard, createSignal } from "@nodebuilderjs/core";
const [text, setText] = createSignal("");
<Row gap={8}>
<Button label="Copy" onClick={() => clipboard.writeText(text())} />
<Button label="Paste" onClick={async () => setText(await clipboard.readText())} /> {/* "" if there is no text */}
</Row>Static text can be selected and copied with <Text selectable>: dragging selects,
double-click picks the word, triple-click the line, Ctrl+A everything and Ctrl+C copies. It is
off by default, so labels and buttons don't get selected by accident.
It is the system clipboard (Wayland and X11), text only. TextField and TextArea copy, cut
and paste with the usual keys (in a TextField line breaks become spaces). Without a window, as in tests, it is an in-memory
clipboard. On Wayland, the compositor only accepts a copy right after a key press or click
(the usual case: Ctrl+C, a "Copy" button).
<Window> draws its own title bar (title and the minimize, maximize and close buttons), border,
rounded corners and resize edges. For a header merged with the title bar (a search field next
to the window buttons, as in music players and browsers), turn it off with
decorations={false} and build your own:
import { Row, Spacer, Text, TextField, Window, WindowControls, createSignal } from "@nodebuilderjs/core";
const [query, setQuery] = createSignal("");
<Window title="Player" decorations={false}>
<Row dragRegion height={56} align="center" gap={12} padding={[0, 12]}>
<Text bold>Player</Text>
<TextField width={280} value={query} onInput={setQuery} placeholder="Search" />
<Spacer />
<WindowControls />
</Row>
{/* content */}
</Window>dragRegionworks on any widget: pressing on it moves the window and a double click maximizes or restores it. A press on something inside it that handles the press (a button, a text field, anything clickable, focusable or withonPointerDown) stays with that child, so plain text and empty space drag, controls don't.<WindowControls />is the built-in bar's three buttons, labelled "Minimize", "Maximize"/"Restore" and "Close"; it takes style props (gap,padding...).- Without decorations the border, corners and resizing from the edges stay. For your own
buttons,
windowControls.minimize(),.toggleMaximize(),.close()and.setTitle(t);windowState.maximized(),.activated()(the window has focus),.width()and.height()are reactive.
Icon. <Window icon={icon}> sets the icon the taskbar and the window switcher show:
SVG markup or an .svg, PNG, JPEG or WebP file, imported with with { type: "file" } so it
gets into the package. Without one, the window gets the nodebuilder icon instead of the
compositor's generic one. On Wayland it needs the xdg-toplevel-icon protocol (KDE Plasma 6.1+);
without it (GNOME), the compositor shows the icon of the installed .desktop file whose name
matches appId, which nodebuilder package installs.
import { Window } from "@nodebuilderjs/core";
import icon from "./icon.svg" with { type: "file" };
<Window title="Notes" appId="com.example.notes" icon={icon}>{/* content */}</Window>;Hiding. windowControls.hide() takes the window off the screen and the taskbar while the
app keeps running (in the tray, below); windowControls.show() brings it back and
windowState.visible() is reactive.
createTray shows an icon in the system tray while the scope that created it lives (closing
the window disposes it). Every option may be an accessor; the item updates when it changes:
import { Window, createSignal, createTray, run, windowControls, windowState } from "@nodebuilderjs/core";
run(() => {
const [unread, setUnread] = createSignal(0);
let quitting = false;
const tray = createTray({
tooltip: () => `${unread()} unread`,
onClick: () => (windowState.visible() ? windowControls.hide() : windowControls.show()),
menu: () => [
{ label: "Mark all read", onClick: () => setUnread(0), enabled: unread() > 0 },
"separator",
{ label: "Quit", onClick: () => ((quitting = true), windowControls.close()) },
],
});
// Closing hides to the tray, if there is one.
return <Window title="Mail" onClose={() => quitting || !tray.available() || (windowControls.hide(), false)} />;
});icontakes the same inputs as<Window icon>(default: the nodebuilder icon);titlenames the item;onMiddleClickhandles the middle button. WithoutonClick, a left click opens the menu too.- Menu items:
label,onClick,enabled,visible,checked(a checkmark; withradio, a radio dot) andchildren(a submenu);"separator"draws a line. tray.available()is true while a panel shows the item. It is a StatusNotifierItem over D-Bus (our own client, no libdbus): KDE Plasma, Xfce, Cinnamon, Budgie and others show it; GNOME only with the AppIndicator extension. Without a tray,available()stays false: keep a way to quit that does not depend on it.
@nodebuilderjs/core/testing runs the app in bun test without a window, with fake text
metrics (8 px per character, 20 px lines) so layout is deterministic:
import { expect, test } from "bun:test";
import { Button, Row, Text, createSignal } from "@nodebuilderjs/core";
import { click, findAllByText, findByLabel, interact, mount } from "@nodebuilderjs/core/testing";
test("play and pause", () => {
const [playing, setPlaying] = createSignal(false);
const { root } = mount(() => (
<Row>
<Text>Blue in Green</Text>
<Button ariaLabel={() => (playing() ? "Pause" : "Play")} label="▶" onClick={() => setPlaying((p) => !p)} />
<Text>Blue in Green</Text>
</Row>
));
click(findByLabel(root, "Play")!);
expect(playing()).toBe(true);
const ui = interact(root); // real event delivery: hit testing, hover, focus, capture
ui.press(findByLabel(root, "Pause")!);
ui.release(findByLabel(root, "Pause")!);
expect(playing()).toBe(false);
expect(findAllByText(root, "Blue in Green")).toHaveLength(2);
});findByText/findAllByTextmatchTextcontent (a string or a RegExp), in tree order with open overlays last;{ in: "Button" }keeps only text inside that kind of widget (the "Quit" button, not the dialog title that says the same).findByLabel/findAllByLabelmatchariaLabel;findByValue/findAllByValuematch what aTextFieldholds andfindByPlaceholderan empty one;shownText(node)is what a node draws ("•••"for a password);findAll(root, predicate)is free-form. All of them also search open overlays.- The
mount(andrun) callback runs once, untracked, like a component body: reading and setting a signal there does not loop. An effect that keeps writing a signal it reads throws "reactive update loop detected" instead of hanging. - A
refthat callsfocus()while the tree is created focuses as soon as it is inserted:interact(root).key(...)right aftermountreaches it. Nodes built by anonResizehandler are placed by the samerelayout(). click(node)calls the nearestonClickdirectly;interact(root)delivers pointer and keyboard input like a window does (hover,press,release,click,contextMenu,drag,scroll,key,keyUp,type, plusdown/up/moveat window positions; modifiers as{ ctrl: true }).frames()controls time for animations.mount(view, { width, height, paint }): withpaint: trueevery layout also paints, soCanvasdraw callbacks run as in the app. To find a custom widget, give it anariaLabel(<Canvas ariaLabel="Editor">) and usefindByLabel.clickand the node helpers ofinteractthrow when a user could not reach the node: it is culled (outside the visible area in the last layout, e.g. overflowing aclippanel or aScroll) or its center is clipped by an ancestor or the window. The error names the node and the ancestor; bring it into view withscrollIntoView()andrelayout(), or give it room. Window positions (down(x, y)...) are not checked.layoutWarnings()(returned bymount, orlayoutWarnings(root)) lists the overflow warnings of the last layout (see Layout);expect(layoutWarnings()).toEqual([])catches a child hidden by a too-narrow panel before a click finds it.mountdoes not fail on them by itself: a deliberate overflow (a clipped marquee) is allowed.- Text metrics: fake by default.
useFakeTextMetrics({ charWidth, lineHeight })changes them;useRealTextMetrics()makes this and every latermountmeasure with the installed fonts (the native core's FreeType + HarfBuzz, still no window) and returns a function that puts the previous metrics back. Real metrics catch overflow that 8 px per character hides, but they depend on the machine's fonts: assert relations ("fits", "narrower than"), not exact pixels, and guard withtest.skipIf(!canUseRealTextMetrics()).
import { afterEach, expect, test } from "bun:test";
import { Row, Text } from "@nodebuilderjs/core";
import { canUseRealTextMetrics, layoutWarnings, mount, useFakeTextMetrics, useRealTextMetrics } from "@nodebuilderjs/core/testing";
afterEach(() => useFakeTextMetrics());
test.skipIf(!canUseRealTextMetrics())("the header fits with real fonts", () => {
useRealTextMetrics();
const { root } = mount(() => (
<Row width={240} clip>
<Text>Recently played</Text>
</Row>
));
expect(layoutWarnings(root)).toEqual([]);
});Tests check boxes and draw commands, not how the result looks. A screenshot renders the real pixels (fonts, colors, shadows, icons, images) with the GPU but no window and no display server (EGL surfaceless: works in CI, containers and agent sandboxes):
nodebuilder screenshot src/main.tsx shot.png # the app at its <Window> size
nodebuilder screenshot src/main.tsx [email protected] --scale 2 --theme dark --width 1024 --height 700
nodebuilder screenshot src/main.tsx shot-p3.png --color-space display-p3 # P3 pixels, a P3-tagged PNGThe command runs the app with NB_SCREENSHOT=<out.png> set, so run() lays out, lets
animations finish on a simulated clock (--wait-frames <n> to stop earlier), renders one
frame offscreen, writes the PNG and exits. To shoot a given page, let the top component take
its initial state as props and render the component a file exports, with those props (the
file must not call run() itself; --export picks a named export instead of the default):
nodebuilder screenshot src/App.tsx settings.png --export App --props '{"page":"settings"}'Arguments after -- reach the app (process.argv.slice(2)):
nodebuilder screenshot src/main.tsx out.png -- --vault ./sample. isScreenshot() tells the app
it is being shot, to use sample data instead of the user's files or the network.
In tests:
import { expect, test } from "bun:test";
import { Box } from "@nodebuilderjs/core";
import { canScreenshot, mount, savePNG, screenshot } from "@nodebuilderjs/core/testing";
test.skipIf(!canScreenshot())("the banner is red", () => {
const { root } = mount(() => <Box height={40} background="#e01b24" />, { width: 200, height: 40 });
const shot = screenshot(root, { scale: 2 }); // 400x80 device pixels, real text metrics
expect(shot.pixel(10, 10)).toEqual([0xe0, 0x1b, 0x24, 255]);
savePNG(shot, "banner.png"); // or shot.toPNG()
});screenshot(root | view, { width, height, scale, background }) returns { width, height, scale, rgba, pixel(x, y), toPNG() } (a view that is a <Window> is shot at its width and
height, filled by it, as in the app; so is mount() without a size); rgba has straight (not premultiplied) alpha, rows top
to bottom, and what nothing covers stays transparent (the window's rounded corners too). The
tree is laid out with real fonts for the shot and gets its own metrics (fake, by default) back afterwards.
Pixels depend on the installed fonts and GPU driver: compare colors at points, not whole images.
bun run packageGenerates in dist/ an AppImage, a .deb, an .rpm and a .tar.gz, each with
a single executable (Bun + your app + native core), a menu entry and an icon. Works on
Ubuntu 22.04+, Debian 12+, Fedora, RHEL 9+ and derivatives.
The same two scenes built with five technologies, measured in the same session:
| Language | How it draws | |
|---|---|---|
| Electron 44 | JS + HTML/CSS | bundled Chromium |
| Tauri 2 | JS + HTML/CSS (Rust only in the shell) | system WebKitGTK |
| Flutter 3.47 | Dart | own engine (OpenGL ES) |
| egui 0.36 | Rust | immediate mode, wgpu (Vulkan) |
| nodebuilder | TypeScript | own core (OpenGL ES) |
Electron and Tauri run exactly the same HTML/JS, with no framework. Machine: i7-12700H, Intel Iris Xe, KDE Plasma (Wayland), 240 Hz monitor.
Tauri and Flutter don't go above 60 fps on Linux, even on a 240 Hz monitor. To compare the same work, the table caps everyone at 60 fps. Each cell: fps · CPU · memory; 100% CPU is one full core, memory is the summed PSS of all processes.
| Scene | Electron | Tauri | Flutter | egui | nodebuilder |
|---|---|---|---|---|---|
| Dashboard (dashboard.tsx): overview | 59 · 58% · 337 MB | 60 · 66% · 267 MB | 59 · 45% · 126 MB | 59 · 27% · 105 MB | 59 · 33% · 91 MB |
| Dashboard: table with 500 rows | 59 · 64% · 383 MB | 59 · 62% · 292 MB | 59 · 41% · 133 MB¹ | 54 · 72% · 112 MB | 49 · 54% · 161 MB |
| Dashboard: live logs | 59 · 94% · 408 MB | 59 · 142% · 298 MB | 59 · 47% · 135 MB¹ | 59 · 42% · 108 MB | 56 · 45% · 157 MB |
| Dashboard: settings | 59 · 44% · 308 MB | 60 · 51% · 267 MB | 60 · 38% · 123 MB | 60 · 23% · 105 MB | 59 · 24% · 83 MB |
| Dashboard: tab switch every 30 frames | 58 · 70% · 384 MB | 59 · 85% · 321 MB | 59 · 45% · 141 MB | 58 · 45% · 114 MB | 55 · 43% · 166 MB |
| Grid (bench.tsx): 1000 static cells | 0% · 310 MB | 0% · 216 MB | 0% · 147 MB | 0% · 106 MB | 1% · 81 MB |
| Grid: 1000 cells, one text changes | 58 · 58% · 321 MB | 60 · 37% · 228 MB | 59 · 51% · 155 MB | 59 · 49% · 108 MB | 57 · 39% · 110 MB |
| Grid: 1000 cells, all change | 59 · 128% · 413 MB | 29 · 147% · 277 MB | 21 · 96% · 173 MB | 59 · 68% · 113 MB | 58 · 44% · 139 MB |
| Grid: 3000 cells, all change | 49 · 172% · 447 MB | 20 · 123% · 351 MB | 7 · 140% · 243 MB | 46 · 84% · 128 MB | 50 · 94% · 194 MB |
¹ Flutter uses virtualized lists (ListView.builder), its default: it only builds the
visible rows. In the other four, every row exists.
In short:
- The two web runtimes are the heaviest: 216 to 447 MB, and much more CPU when a lot of text changes. Tauri uses 13% to 33% less memory than Electron, but can't sustain 60 fps in the heaviest scenes (29 and 20 fps).
- egui is nodebuilder's closest rival: almost constant memory (105 to 128 MB, no garbage collector) and the lowest CPU on the light dashboard tabs. Being immediate mode, it rebuilds the whole interface every frame, and that weighs on it when there are many elements: it loses on the table (72% vs. 54%) and when 1000 texts change (68% vs. 44%).
- Flutter uses little memory and little CPU on the dashboard, but is locked at 60 fps and drops to 21 and 7 fps when thousands of texts change every frame.
- nodebuilder has the lowest memory when idle (81 MB) and on the light dashboard tabs, and the lowest CPU when 1000 texts change every frame (44%; with 3000, egui uses less, but at 46 fps vs. 50). In this round it lost fps on the 500-row table (49 vs. 54 to 59) because it measured and drew what was off screen; that has since been fixed (see below). On the table, the logs and the tab switching it uses more memory than egui and Flutter (157 to 166 MB): that is the JavaScript heap.
- With a 120 fps cap, only Electron, egui and nodebuilder go above 60: Electron stays between 107 and 117 fps, egui between 102 and 119 and nodebuilder between 80 and 118 (with 3000 cells, all of them at 45–51 fps).
- After this round, nodebuilder started skipping what is outside the visible area (culling). Measured before and after in the same session: the 500-row table went from 78 to 104 fps with a 120 cap (CPU from 82% to 51%) and, with a 60 cap, from 55% to 40% CPU; "one text changes out of 1000" dropped from 39% to 23% CPU. With a 60 cap, fps still stays below 59 in every scene, because of frame scheduling (roadmap).
- Size: Tauri is 5.7 MB (it uses the system WebKit), egui 15 MB, Flutter 23 MB, nodebuilder ~91 MB (~31 MB compressed, almost all of it the Bun runtime) and Electron ~280 MB.
Ergonomics matter too: with egui you write Rust; with Flutter, Dart; with Electron and Tauri, HTML/CSS/JS (with Rust for Tauri's native side); with nodebuilder, TypeScript with JSX, which is what most web devs (and LLMs) already write.
Tauri swaps Electron's browser (Chromium) for the system one (on Linux, WebKitGTK), but it is still a browser. The expensive work, which is running the JS, applying the CSS, laying out the DOM and painting, happens in a full engine in both cases; Tauri's Rust only opens the window. Measuring process by process in the logs scene:
| Process | Tauri | Electron |
|---|---|---|
| Page (JS, DOM, CSS, layout, painting) | 172 MB · 131% CPU | 163 MB · 82% CPU |
| Window / main process | 87 MB | 86 MB |
| GPU, network and utilities | 33 MB (1 process) | 115 MB (5 processes) |
- Memory: the page process costs almost the same in both. Tauri only saves on the helper processes, which gives 13% to 33% less memory than Electron in the measured scenes.
- CPU: WebKitGTK paints the page on the CPU (
SkiaCPUWorkerthreads, ~45% of a core), while Chromium paints on the GPU. That is why Tauri spends more precisely when a lot of text changes every frame. - "Tauri is lightweight" mostly refers to the download size: it doesn't ship a browser along with it.
nodebuilder has no browser engine: there is no CSS cascade or DOM to recompute, a signal updates the node that changed directly, everything is drawn on the GPU with one draw call per frame, and it is a single process. In exchange, it offers fewer features than the web (see limitations below).
These numbers apply to Linux. On Windows Tauri uses WebView2 (Chromium) and on macOS the system WebKit; the results there may differ.
Measured on 2026-10-11. To reproduce, see ARCHITECTURE.md.
- Linux x64 only (arm64, Windows and macOS are on the roadmap).
- Screenshots (
nodebuilder screenshot,screenshot()) need a GPU driver with EGL (Mesa or NVIDIA); a process that took one can't open a window afterwards. - A family without the weight or slant asked for uses the closest face, emboldened or slanted (synthetic, like browsers). A word longer than the line overflows. Characters the UI font lacks (symbols, CJK, emoji) come from the system's fallback fonts and color emoji are drawn in color (Noto Color Emoji and other CBDT, sbix or COLRv0 fonts; COLRv1-only fonts are not rendered in color). No bidi: a right-to-left run is shaped, but lines are not reordered.
- Text input without IME (input methods for Asian languages), middle-click paste or dragging text; a
TextAreathat scrolls itself has no scrollbar. - The window icon needs the
xdg-toplevel-iconWayland protocol (KDE Plasma 6.1+); GNOME shows the.desktopicon matchingappId(installed bynodebuilder package). The tray needs a StatusNotifierItem host (GNOME: the AppIndicator extension). - SVG only in the icon subset (no gradients, text, filters); AVIF/HEIC depend on the system
libheif. - The dark theme doesn't follow the system preference on its own (on Linux it comes from the
XDG portal over D-Bus, not read yet): call
setThemefrom an app option. - An indeterminate
ProgressBarandSpinnerrequest frame after frame while they are shown (low CPU, but not zero); hide them when the wait is over. - Animations of numbers only (
opacity,translate, sizes, colors withmixColor): noscale/rotate, declarative style transitions (CSStransition) or springs. No accessibility or IME (keyboards for Asian languages). opacityis applied to each thing drawn, not to the group: overlapping children show through each other.- Linear gradients only (no radial or conic ones), as backgrounds and Canvas fills; borders, text and shadows are solid colors.
- Wide gamut: Display P3 only (no Rec. 2020 or HDR); X11 windows are sRGB. Image profiles that are not RGB matrix/TRC (LUT-based, CMYK, gray) are ignored, and the colors painted into multicolor icons are sRGB (a one-color icon is tinted on the GPU, so it shows P3).
- Scrolling without inertia or animation (the wheel and keys jump straight there), without auto-scroll when dragging a selection to the edge and without repeat when holding the click on the scrollbar track.
git clone https://github.com/felipebrgs1/nodebuilderjs && cd nodebuilderjs
bun install
bun run build:native # compiles the C core (needs the Wayland, X11, EGL... headers)
bun run counter # runs an example
bun test && bun run typecheckDevelopment follows TDD: new behavior or a bug fix starts with a failing test
(bun test runs without a window, in ~0.1 s). ARCHITECTURE.md explains how
the framework is organized internally, and AGENTS.md has the rules, the
conventions and how to test each layer.