Skip to content

Cross-Cutting Capabilities: Reactive Rendering, each/show Directives, Events, Accessibility, Layering, Animations ​

Reactive & List Rendering ​

Stable

These APIs from gx/solid are the answer to "why does the UI update". For the full exports, signatures, and edge cases see the API Reference · gx/solid.

APISignatureDescription
createSignal(initial) => [get, set]Creates a signal. Call get() to read (which also registers a dependency), set(v) or set(fn) to write; no notification when the old and new values are === equal
createEffect(fn) => disposeRuns fn immediately and subscribes to all signals read inside it; the return value is a dispose function
createMemo(fn) => getterDerived computed value, lazy: dependency changes only mark it dirty; it recomputes on read
createResource(fetcher) => [data, res]The async data trio. data() gets the current value; res.state() is "pending" / "ready" / "refreshing" / "error"; res.error() gets the error; res.refetch() refetches (keeping the old value displayed). The controls must be destructured as the second element — nested destructuring like [data, {refetch}] is not supported by the engine
onMount(fn)Runs once after the current generation of the subtree is mounted
onCleanup(fn)Runs when the current generation of the subtree is replaced / destroyed — use it to stop timers and unbind callbacks
untrack(fn) => valueRuns fn without registering dependencies and returns the result. The router uses it internally to call page components, avoiding "signals read in the page body becoming dependencies of the router's effect"
devStats() => objectDevelopment-time statistics (signal count / subscription count, etc.), used with the gx/dev debug panel

Function children are the unified entry point for list and conditional rendering: the result of evaluation can be an element, an array, a scalar, or null/false (rendered as nothing).

js
const [items, setItems] = createSignal(["alpha", "beta"]);
const [tab, setTab] = createSignal(0);

// List rendering: the function returns an array; each element is mounted
<column gap={6}>
  {() => items().map((t) => <text>{t}</text>)}
</column>

// Conditional rendering: the function returns an element or null
{() => (tab() === 0 ? <text>Panel A</text> : <text>Panel B</text>)}

// Static array children are also supported: writing {rows} directly expands them into sibling nodes
<scroll height={120}>{rows}</scroll>

Function children rebuild the whole group

When the array or the condition changes, the whole group of children is rebuilt (effects in the old subtree are disposed): adding or removing a few rows is fine, but each row's input focus, scroll position, and local signals are lost. For keyed reuse (rebuilding only the rows that actually changed) use the each directive; for very long lists use <scroll vlist> to virtualize, materializing only the visible range.

Lists & Conditionals (element-level directives + gx/view) ​

Stable

Lists and conditionals are element-level directives (written on the element); for multiple branches use Switch / Match from gx/view. In Vue terms this is the v-for / v-show / v-if chain. As of 2026-09-20: the <For> / <Show> components have been removed in favor of the directives below (semantics unchanged: <view each={x}> is pixel-for-pixel identical to the old <For each={x}>).

js
import { Switch, Match } from "gx/view";   // each / show are h()-level directives, no import needed

<view each={rows} key="id" fallback={<text>No data</text>}>
  {(row, i) => <row><text>{(i + 1) + ". " + row.title}</text></row>}
</view>

<view show={open} fallback={<text>Hidden</text>}>
  <input width={150} model={draft} />
</view>

<Switch fallback={<text>Unknown state</text>}>
  <Match when={() => phase() === "loading"}><text>Loading…</text></Match>
</Switch>
Directive / APISignatureDescription
each{each, key?, fallback?, stable?, gap?} + (item, i) => nodeList loop (element-level directive, written on the element). Rows with the same key + same row reference + same index are reused as-is (inline inputs, scroll positions, and local signals are kept); only rows that actually changed re-render; each also accepts a number (0..n-1); key="id" is shorthand for key={(r) => r.id}
show{show, fallback?} + childrenConditional visibility (keep-alive): hiding only removes the subtree from layout flow; it stays mounted and switches back instantly on re-show. Write the condition as show={open} (a signal is itself a getter)
view— (tag)A layout-transparent container (Fragment): with a single child its size follows the child exactly; with multiple children they stack in the parent's direction, and it takes up no box of its own. It is the "box-less template" for the directives — <view each={rows}> matches the old <For>; <row each={rows}> gives each item its own box
Switch / Match({fallback?}, ...<Match/>) / ({when}, ...children)Multi-branch: picks the first branch in declaration order whose when is true; if none match, uses fallback (still exported components from gx/view)

Misuse now makes noise instead of failing silently

each / show expect getter functions: writing each={rows()} / show={open()} only captures a snapshot, and the UI won't re-render when the signals change. Each of these invalid forms (each receiving a string/object, a static boolean with the parens forgotten, key receiving a number, stable receiving a function) now emits one deduplicated warning (stderr + the gx/dev buffer).

js
import { Switch, Match } from "gx/view";   // each / show are element-level directives, no import needed

<view each={() => rows()} key={(r) => r.id} fallback={<text>No data</text>}>
  {(row, i) => <row><text>{(i + 1) + ". " + row.title}</text></row>}
</view>

<view show={() => open()} fallback={<text>Hidden</text>}>
  <input width={150} model={draft} />
</view>

<Switch>
  <Match when={() => phase() === "loading"}><progress value={0.5} /></Match>
  <Match when={() => phase() === "error"}><text>Something went wrong</text></Match>
</Switch>

each / when take functions

Props are evaluated at the call site: writing each={rows()} only captures a snapshot, and the UI won't re-render when the signal changes; writing each={() => rows()} is what "follows the signal". This is the same discipline as the controlled input's value having to be a function; passing an array / number literal is a valid static list (rendered once).

The same goes for children — but this one is silent: <text>count: {count()}</text> evaluates count() before h() is even called, so it stays frozen on the first frame; write <text>{() => `count: ${count()}`}</text> instead. A text child takes a scalar by nature (<text>hello</text> is itself a string child), so at runtime there is no way to tell "static text" from "a snapshot" — this one is on discipline alone.

Ternaries / short-circuits hide a silent pitfall of the same family: a subscription-type reading (the useXxx() family) written inside a branch the first frame doesn't take is never called at all ⇒ no subscription is established, the effect ends up with zero dependencies and never re-runs, with no warning. Take the subscription reading unconditionally first, then branch:

js
// ❌ hasFold() is false on the first frame ⇒ useReservedRegions()() never runs ⇒ never updates
const bad = () => hasFold() ? useReservedRegions()().division.length + " rows" : "no fold";

// ✅ subscribe first, then branch
const good = () => {
  const r = useReservedRegions()();   // establish the subscription first
  if (!hasFold()) return "no fold";
  return r.division.length + " rows";
};
  • Reuse checks include the index: reordering or deleting a middle item shifts the indexes of later rows, and those rows re-render in place (that's how row numbers track position). Add stable when rows don't display their position to take the index out of the check (the cost: the index argument stays frozen at its mount-time value).
  • show / Switch are not v-if: hiding only removes the subtree from layout flow and keeps it alive — while hidden, the content still follows the signals, and it comes back exactly as it was. For "built fresh on every show" use function children {() => cond() ? <X/> : null}.
  • When a row is deleted, its subtree is destroyed (onCleanup runs); keyed "move" animations are not implemented (for virtualized long lists see <scroll vlist>).
  • The host is a transparent placeholder node: put it in a column for vertical stacking or a row for horizontal stacking; it takes up no box of its own; row wrap does not recognize it for line breaking (use a plain row for wrapping label flows).

Events & Focus ​

Stable

Every event finds the first handler along the ancestor chain and stops there — no bubbling, no capture, no stopPropagation.

EventCallback argsDescription
onClicknoneLeft button release
onMouseMove{x, y}Mouse moves within the node
onWheel{deltaY}Positive when scrolling down (DOM convention). Consumed first by containers inside scroll
onContextMenu{x, y}Right button release; commonly used to call openContextMenu
onKeyDown / onKeyUp{key, ctrl, shift, alt}Delivered to the focused node, then walks up the ancestor chain
onFocus / onBlurnoneFocus enters / leaves
onInput{value}Edit callback for input-like components (string; slider is a number)
onResize{width, height}Window-level event: delivered to the layout root when the window size changes, not fired on non-root nodes. Combined with a signal it's the useWindowSize pattern (see below)

useWindowSize: adaptive on window resize ​

js
// onResize only recognizes the layout root; breakpoints are plain memos in the script, not baked into the kernel
const [win, setWin] = createSignal({ width: 520, height: 360 });
const wide = createMemo(() => win().width >= 480);

<window title="app" width={520} height={360}>
  <column onResize={(e) => setWin({ width: e.width, height: e.height })}>
    {() => (wide() ? <Sidebar /> : <text font={12}>(narrow)</text>)}
  </column>
</window>

Focus model: clicking any node makes it the keyboard focus, and the focused node gets a 1px blue dashed outline. A disabled subtree responds to no events and is skipped in focus switching. The keyboard can move focus too (Tab / Shift+Tab traversal, arrow keys, Enter / Space activation) — see Accessibility & Keyboard Navigation below.

js
<rect
  width={380} height={110} background="#eef3f8"
  onClick={() => setLast("click")}
  onMouseMove={(e) => setPos(`${e.x}, ${e.y}`)}
  onWheel={(e) => setLast(`wheel deltaY=${e.deltaY}`)}
  onContextMenu={(e) => setLast(`context menu at ${e.x}, ${e.y}`)}
  onKeyDown={(e) => setLast(`keydown ${e.key}`)}
>
  <text>click to focus, then move / scroll / right-click / type</text>
</rect>

Accessibility & Keyboard Navigation ​

stable · pure computation

The keyboard is a path parallel to the mouse, not a replacement: clicking still changes focus, the keyboard just adds a way to get the job done without touching the mouse. All of it lives in one layer inside the kernel (gfx/a11y.go); components only declare their own semantics.

There is no screen-reader bridge (Windows UIA / macOS AX / Android AccessibilityService) — three platforms, three APIs, which conflicts with the zero-cgo promise. What it does deliver is "can a keyboard user finish the task", which is pure computation and therefore assertable in CI.

What is in the Tab order, and in what order ​

PropBehaviour
omittedper tag table: button / checkbox / radio / switch / slider / input / search / textarea / select / rating / tabs / pagination / datepicker / colorpicker / upload
focusable={true}force into the order (containers / icons)
focusable={false}force out of the order
tabIndex>0 sorts first (ascending), 0 (default) follows tree order, <0 leaves the order but can still be focused programmatically

Containers (column / row / view / rect / overlays) are not focusable by default — giving a purely visual box a tab stop forces keyboard users to press Tab several times to reach a real control.

Never in the order: disabled subtrees, hidden / aria-hidden="true", un-laid-out nodes (inside a closed overlay, inactive tabs pages), nodes outside the window rect (scrolled out of view), and overlay internals (dropdown options, calendar cells, swatches — arrow keys move inside those, exactly like listbox options in a browser).

Keys ​

KeyMeaning
Tab / Shift+Tabmove through the order, wrapping at both ends (not consumed when the order is empty)
Enter / Spaceactivate = click this control (same onClick exit as the mouse)
arrowsradio group move + select; slider one step; rating one star; tabs / pagination page; select highlight; datepicker a day / a week; colorpicker a cell / a column
PageUp / PageDown / Home / Endmonth / first & last day of the month in the calendar
Escclose the open overlay (dropdown / calendar / palette / dialog)

Two general rules: combinations with Ctrl / Alt are never touched (that's the shortcut layer's turf), and boundary behaviour depends on dimensionality — one-dimensional groups (radio / tabs / pagination / select) wrap, two-dimensional ones (calendar / palette) stop.

Whether a node can be activated is decided by its role, not by "it has a click handler": <rect onClick> does nothing on Enter unless you write role="button".

Submitting a form with the keyboard: with focus in an input / search, Enter submits the enclosing <form> (onSubmit({values})); with focus on a button, Enter presses that button.

Overlays and focus reconciliation ​

  • A modal overlay (dialog / drawer) traps Tab inside itself — no trap stack to maintain, the order is computed from "topmost visible modal overlay";
  • Opening an overlay moves focus to its first focusable control; after Esc / backdrop close, focus is reconciled to the first focusable control outside it (otherwise the ring stays on something invisible).

aria ​

ConceptRule
implicit roleone default role per built-in tag (HTML implicit roles first, otherwise the closest ARIA role: input→textbox, select→combobox, rating→slider, upload→group, …). An explicit role prop always wins; a bad value warns once and falls back
accessible namearia-label → title → text content → placeholder (fields). An empty string means "no readable name" — the kernel does not invent "button 3"
aria-*the full ARIA 1.2 set is recognised but not executed (only aria-hidden takes part in the tab order). Typos (aria-lable) warn once — the point is to catch typos, not to limit usage

gx/a11y: reading the focus chain ​

js
import { focusOrder, focusNode, focusNext, focusPrev, roles } from "gx/a11y";
APIReturnsUse
focusOrder()arraysnapshot of the current order: { role, name, tag, tabIndex, disabled, focused, description?, keyHint?, box }
focusNode(el)booleanfocus programmatically (like el.focus())
focusNext() / focusPrev()booleanthe exact same path as Tab / Shift+Tab
roles()arrayimplicit role table ({tag, role, keyHint?})

This is not for screen readers — it is a self-check: a name-less button in focusOrder() means a missing aria-label, caught long before a screen-reader user reports "it reads nothing".

Acceptance checklist ​

Fill in a form → submit → close a dialog, using the keyboard only, step by step:

#ActionExpected
1press Tabfocus enters the first focusable control, the focus ring is visible
2keep pressing Tabfocus advances in tree / tabIndex order — nothing skipped, repeated, or invisible
3press Tab again at the endwraps to the first control
4focus an input, typethe text lands in the field (onInput fires, the controlled value is written back)
5focus select / datepicker / colorpicker, press Enterthe overlay opens
6press an arrow keythe highlight / cursor moves inside the overlay
7press Enterselects and closes, onChange fires, focus returns to the field
8press Esc while opencloses, value unchanged
9focus a radio group, press →moves inside the group and selects
10focus a button, press Enter / Spacethe button fires (not "submit the form")
11focus an input, press Entersubmits the enclosing <form> (onSubmit({values}))
12trigger a dialogthe overlay opens with a visible backdrop
13press Tab repeatedly inside itfocus cycles inside the overlay, never escaping
14press Escthe overlay closes
15press Tab after closingfocus is reconciled to the first focusable control outside it

Runnable version: testdata/a11y_form_demo.js in the repository (./gox testdata/a11y_form_demo.js) — one form plus one dialog; line ② shows the submit result and line ③ projects focusOrder().

When adding a new component: is the tag in the natively-focusable table (or explicitly documented as not), does it have an implicit role, can its main action be reached with Enter (role-dependent), are overlay internals kept out of the tab order, does it carry a keyboard hint, can every mouse action be done with the keyboard, does an overlay close on Esc and restore focus to its trigger, and is the keyboard behaviour covered by an automated test rather than "I tried it once"?

Known limits ​

SymptomWhy
screen readers read nothingno screen-reader bridge; role / name are merely readable, a host can turn focusOrder() into a platform tree
focus does not travel between windowseach window has its own focus
no Home / End / selection / undo in text fieldsadvanced text editing is out of scope for v1
radio groups without name group by parenttwo groups in one container merge; give them a name
controls inside inactive tabs pages are not tabbablethat is what keep-alive means
controls scrolled out of view are not tabbableintentional: keyboard users should not land somewhere invisible

Layering & Positioning ​

Stable · Clipping escape

Controls who paints on top of whom, and lets elements escape the normal flow. Three groups of props, each doing one thing.

PropTypeDescription
zIndexnumberPaint and hit order within the same layer; higher is on top. Uses a stable sort, does not reorder children
positionstringSetting it to "absolute" takes the element out of the normal flow; left / top then take effect (relative to the parent's content area)
left / topnumberOffsets for absolute positioning
escapeClippingbooleanLets this subtree's painting and hit testing overflow its parent's box, and collects it for last-draw at the root level

absolute does not lift the parent's clipping

position="absolute" only takes the element out of the normal flow; by default it is still clipped by its parent's box. To make an overlay overflow its parent's bounds, you must explicitly add escapeClipping. Built-in overlay tags (dialog / toast / the select popup / menus) already carry this capability, so you don't need to write it by hand.

js
{/* Overlay: absolutely positioned at the top-left of the parent's content area */}
<rect position="absolute" left={12} top={8} width={100} height={20} background="#f2c94c" />

{/* Custom overlay: escapeClipping is required to overflow the parent box */}
<column position="absolute" top={30} escapeClipping={true}>
  <text>Floating on top</text>
</column>

Transition Animations ​

Stable · Not an element

Animation is not a component but a cross-cutting capability that can be attached to any node: when a value changes, it smoothly approaches the target with ease-out over the given duration.

Prop / APITypeDescription
transitionnumber or objecttransition={400} is shorthand (applies to all animatable properties); transition={{width: 400, opacity: 350}} sets a duration per property
opacitynumber0~1, grouped: a semi-transparent parent fades the whole subtree together
animate(node, prop, to, ms)imperativeAnimates a property of a node to a target value; returns a cancel function
animate(from, to, ms, onUpdate, onDone)imperativeNever touches element properties; you get the interpolated values yourself (pair with things like setStatus)

The declarative transition only recognizes these five numeric properties: width / height / left / top / opacity.

js
// Width transition: smoothly expands/contracts over 400ms on toggle
h("rect", {
  height: 18,
  background: "#2f80ed",
  transition: { width: 400 },
  width: () => (wide() ? 300 : 60)
}),

// Grouped fade: a semi-transparent parent fades the whole subtree together
h("row", {
  gap: 6, height: 28,
  transition: { opacity: 350 },
  opacity: () => (visible() ? 1 : 0.15)
},
  h("rect", { width: 24, height: 24, background: "#c0392b" }),
  h("text", { font: 12, width: 80, height: 24 }, "fading")
),

// Imperative: you get the interpolated values; onUpdate is called each frame (~60fps)
h("button", {
  onClick: () => {
    animate(0, 100, 600,
      (v) => setStatus("progress " + Math.round(v) + "%"),
      () => setStatus("done"));
  }
}, "bounce")

Three behaviors that are easy to misjudge

① No transition on first assignment (same as CSS): an element doesn't "grow from 0" when first mounted; use imperative animate() for entrance animations.
② Layout reads the interpolated values during the transition, so sibling nodes make way — it's not just a visual effect.
③ While an animation is running, the prop the script reads is already the final value: the prop is the single source of truth; the interpolation only exists in the render layer. To read "the currently displayed value" you have to maintain it in a signal yourself.

  • The easing curve is fixed to ease-out and cannot be customized; no keyframes, no transition-delay.
  • No color transitions in v1.
  • value / padding / gap / margin / flexGrow are deliberately excluded: transitioning the former would fight the script's own controlled write-back, and half-pixel intermediate values of the latter would let text bleed through.
  • Zero cost when idle (no active animation means no timer is kept alive).