소개개요
NUCLO / 12

Language

Documentation

Nuclo documentation

A practical reference for installing Nuclo, building with explicit updates, styling with typed CSS, and rendering server-side HTML.

Quick startnpm create nuclo@latest
Quick Start
v0.3.29
Current release
175
Tag builders
~18.4 KB
Core, gzipped
Explicit
Runtime model

Overview

#

Nuclo is a small DOM library for TypeScript. You build the page with plain functions such as div() and button(), keep state in ordinary variables, and call update() when the page should change.

Nuclo has no virtual DOM, no compiler, no proxies and no signals.

main.ts
import 'nuclo'

let count = 0

const App = () =>
  div(
    h1(() => `Count: ${count}`),
    button('Add one', { onClick: () => { count++; update() } }),
  )

render(App, document.getElementById('app')!)

The mental model

  1. Build the UI with tag builders.
  2. Pass a function for every value that depends on state.
  3. Change state with plain JavaScript.
  4. Call update(). Nuclo runs the functions again and patches the DOM.

Changing a variable does not touch the DOM. The page changes only when you call update().

Quick Start

#

create-nuclo scaffolds a Vite and TypeScript project that is ready for Nuclo:

$npm create nuclo@latest
$bun create nuclo
$pnpm create nuclo

It asks for a project name. To skip the prompt, pass the name and flags. With npm, put -- before the flags:

terminal
$ npm create nuclo@latest my-app -- --template basic --yes

Then install and start the dev server:

terminal
$ cd my-app
$ npm install
$ npm run dev

Options

  • [project-name]: the target folder. The default is nuclo-app.
  • -t, --template <name>: the template to copy. Only basic exists today.
  • -y, --yes: never prompt, use the defaults.
  • -f, --force: write into a folder that is not empty.
  • -h, --help: print the help.

Installation & Imports

#

To add Nuclo to an existing project:

terminal
$ npm install nuclo

Or use pnpm add nuclo, yarn add nuclo or bun add nuclo. Nuclo has no dependencies.

Routing is not part of the core package. It ships separately as nuclo-router, which you install next to nuclo when you need it.

Globals

import 'nuclo' puts these on globalThis:

  • 175 tag builders: 112 for HTML (div, span, button, …) and 63 for SVG, with an Svg suffix (svgSvg, pathSvg, …). See Tag Builders.
  • 16 helpers: update, scope, when, list, region, into, on, render, hydrate, forceUpdate, css, cx, createCss, variants, keyframes and globalStyle.

The import must run before any code that calls a builder. ES modules run in import order, so this fails:

main.ts
import { App } from './app' // app.ts calls div() → ReferenceError: div is not defined
import 'nuclo'

Put import 'nuclo' first in your entry file, or import it in every file that uses the globals.

Named imports

The helpers are also named exports. They are the same functions as the globals. getCssText() and resetStyles() are named exports only. Tag builders are globals only, so import { div } from 'nuclo' does not work.

main.ts
import { render, update, getCssText } from 'nuclo'

Entry points

  • nuclo: registers the globals and exports the 16 helpers plus getCssText and resetStyles.
  • nuclo/ssr: renderToString, renderManyToString, renderToStringWithContainer and getCssText. See Server Rendering.
  • nuclo/polyfill: a small DOM for Node and Bun. See nuclo/polyfill.
  • nuclo/types: types only. See TypeScript.

TypeScript

#

Nuclo ships its own types. You need two things:

  • "moduleResolution": "bundler". node16 and nodenext are not supported yet.
  • The DOM lib. TypeScript includes it by default when you do not set lib.

If any file in the project imports 'nuclo', every file sees the global types. You need nothing else. If no file imports it, for example in a separate tsconfig for tests, add the types yourself:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["nuclo/types"]
  }
}

Or add /// <reference types="nuclo/types" /> to a .d.ts file.

Useful types

These types are global, so you use them without an import:

  • NodeModFn<Tag>: what a tag builder returns. Use it as a component's return type.
  • NodeModLike<Tag>: anything a tag builder accepts as an argument.
  • ExpandedElement<Tag>: what render() and hydrate() return.
  • ElementTagName: any HTML tag name.
  • ExpandedElementAttributes<Tag>: the attribute object a tag builder accepts, including on* handlers, onMount and onDestroy. Use it for components that pass attributes through.
  • MountCallback and DestroyCallback: onMount and onDestroy handlers.
  • WhenBuilder, ListModifier: what when() and list() return.
  • RegionOptions, RegionType: the options region() takes.
  • StyleResult, Style, ThemeConfig, CssInstance and the other styling types. You can also import these: import type { StyleResult } from 'nuclo'.
badge.ts
function Badge(text: string): NodeModFn<'span'> {
  return span(text)
}

ExpandedElement<Tag> marks every DOM member as optional. Cast it when you need the full element type: render(App, root) as HTMLDivElement.

Every HTML tag is a global function with the same name: div, span, input, … The one exception is <var>, which is var_ because var is a reserved word. Every SVG tag has an Svg suffix: svgSvg, pathSvg, circleSvg, …

Calling a tag builder does not create an element yet. div('Hi') returns a builder: a function that creates the element later. Nuclo calls it when you mount it with render() or place it inside another builder, when() or list(). Each use creates a new element.

card.ts
let name = 'Ada'

const Card = () =>
  div(
    { id: 'card', className: 'card' },
    h2('Profile'),
    p(() => `Hello, ${name}`),
    button('Rename', { onClick: () => { name = 'Grace'; update() } }),
  )

render(Card, document.body)
LiveA builder takes attributes, static children and dynamic text. Rename changes the state, then calls update().Open ↗

Profile

Hello, Ada

Arguments

A builder takes any number of arguments and applies them from left to right:

  • string, number, bigint: a text node.
  • () => value: text that changes on update(). See Dynamic Values.
  • null, undefined: skipped.
  • true, false: rendered as the text "true" or "false". Use when() for conditions.
  • A plain object: attributes. See Attributes.
  • A css(), cx() or variants() result: adds its classes.
  • on(...): an event listener or lifecycle hook.
  • Another builder, when() or list(): a child.
  • A DOM Node: appended as it is.
  • (el, index) => …: a custom modifier. See below.

Gotchas

  • div(isAdmin && span('Admin')) renders the text "false". Write div(when(() => isAdmin, span('Admin'))).
  • Arrays are not children. Spread them: ul(...names.map((n) => li(n))). For lists that change, use list().
  • A local variable with a tag name hides the builder. Inside function Row(label: string), label(...) is not callable. Rename locals such as label, title, data, p, a and i.
  • Browsers expose every element that has an id as a global variable. If the page contains <main id="main"> before Nuclo loads, Nuclo leaves that global in place, so main(...) throws a TypeError. Use other ids.

Custom modifiers

A function that declares at least one parameter runs once, in argument order, while the element is being built. Its first argument is the element. The second is an internal index, so do not rely on it. If it returns a string or number, that becomes text. A Node is appended. An object is applied as attributes.

example.ts
const field = input((el) => {
  el.autocomplete = 'off'
})

Getting the element

render() returns the root element. Inside a tree, use on("mount", (el) => …) or a custom modifier. You can also call the builder function yourself to create a detached element:

example.ts
const el = div('Hello')() as HTMLDivElement
document.body.append(el)

Attach it before the next update(). A node that is not in the document when update() runs stops updating for good.

SVG

SVG builders take the same arguments. Nuclo sets every SVG attribute with setAttribute, so names such as "stroke-width" work as written.

icon.ts
const Icon = () =>
  svgSvg(
    { viewBox: '0 0 24 24', width: 24, height: 24, className: 'icon' },
    circleSvg({ cx: 12, cy: 12, r: 10, fill: 'currentColor' }),
  )

All builders

HTML: a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var_ video wbr

SVG: aSvg animateSvg animateMotionSvg animateTransformSvg circleSvg clipPathSvg defsSvg descSvg ellipseSvg feBlendSvg feColorMatrixSvg feComponentTransferSvg feCompositeSvg feConvolveMatrixSvg feDiffuseLightingSvg feDisplacementMapSvg feDistantLightSvg feDropShadowSvg feFloodSvg feFuncASvg feFuncBSvg feFuncGSvg feFuncRSvg feGaussianBlurSvg feImageSvg feMergeSvg feMergeNodeSvg feMorphologySvg feOffsetSvg fePointLightSvg feSpecularLightingSvg feSpotLightSvg feTileSvg feTurbulenceSvg filterSvg foreignObjectSvg gSvg imageSvg lineSvg linearGradientSvg markerSvg maskSvg metadataSvg mpathSvg pathSvg patternSvg polygonSvg polylineSvg radialGradientSvg rectSvg scriptSvg setSvg stopSvg styleSvg svgSvg switchSvg symbolSvg textSvg textPathSvg titleSvg tspanSvg useSvg viewSvg

A component is a plain function that returns a builder, such as button(...). There is no component API. Nothing is created until you render it or place it in a tree.

counter.ts
function Counter(label: string) {
  let count = 0 // one per call
  return button(() => `${label}: ${count}`, {
    onClick: () => { count++; update() },
  })
}

render(() => div(Counter('A'), Counter('B')))
LiveA component is a plain function. Each Counter() call keeps its own count.Open ↗

State declared inside the function belongs to that instance. State declared outside is shared by every instance. forceUpdate() calls components again, which resets the state declared inside them.

Children are just arguments:

card.ts
function Card(heading: string, ...children: NodeModLike<'div'>[]) {
  return div({ className: 'card' }, h2(heading), ...children)
}

render(() => Card('Hi', p('one'), p('two')))

Attributes

#

Pass a plain object to set attributes. You can pass several objects. For the same key, the later value wins, except className and style, which merge. A value can be static, or a function with no parameters that runs again on every update().

example.ts
let busy = false

const save = button(
  { type: 'submit', disabled: () => busy, 'aria-busy': () => String(busy), 'data-id': 42 },
  'Save',
)
LiveAttribute values can be functions. The second button flips busy and calls update(); Save's disabled and aria-busy follow.Open ↗

How a value is set

  • If the HTML element has a property with that name (value, checked, disabled, htmlFor, tabIndex, …), Nuclo sets the property. Otherwise it calls setAttribute(key, String(value)), for example for "aria-*" and "data-*".
  • On SVG elements, Nuclo always calls setAttribute.
  • null and undefined are skipped. A dynamic value that returns null or undefined keeps the old value. To clear a value, return "", or false for a boolean property.
  • Booleans work for DOM boolean properties, such as disabled on a button, checked, hidden and required. Anywhere else, true and false are written as the strings "true" and "false".
  • Use "data-*" keys. dataset is not supported.
  • innerHTML is not escaped, and server rendering does not output it. Avoid it.

Classes

Use className. Static class strings, css() results and a dynamic className all merge. A dynamic className keeps the static classes, and when it returns "" only the static classes remain.

example.ts
let active = false
const base = css({ p: 8 })

const item = li(base, { className: 'item' }, { className: () => (active ? 'active' : '') }, 'Home')
// class="<base> item", then "<base> item active" after active = true; update()

Do not use the class key on HTML elements. It sets the raw attribute and replaces every class applied before it, including css() classes.

Inline styles

style takes an object with camelCase keys, or a function that returns one.

example.ts
let progress = 30

const bar = div({ style: () => ({ width: `${progress}%`, backgroundColor: 'teal' }) })
  • Nuclo adds no units. Write "12px", not 12. Unitless properties such as opacity and zIndex accept numbers.
  • "", null and undefined clear a property.
  • A dynamic style sets only the keys it returns. A key it stops returning keeps its old value, so return "" to clear it.
  • Style strings ("color: red") and custom properties ("--gap") are not supported. Use css() instead. It takes custom properties through raw.
fnPublic API
// DOM events such as "click". El and Ev are inferred from the tag and the event name. function on(type: DOMEventName, listener: (this: El, e: Ev) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Custom events function on<K extends string, E extends Event = Event>(type: K, listener: (this: El, e: E) => unknown, options?: boolean | AddEventListenerOptions): NodeModFn // Lifecycle, see Lifecycle function on(type: "mount", listener: MountCallback): NodeModFn function on(type: "destroy", listener: DestroyCallback): NodeModFn

There are two ways to listen to events: on* attributes and the on() modifier.

on* attributes

In an attribute object, a function under a key such as onClick (on followed by a capital letter) is an event handler, not a dynamic value.

search.ts
let query = ''

const search = input({
  placeholder: 'Search',
  value: () => query,
  onInput: (e) => { query = e.currentTarget.value; update() },
  onKeyDown: (e) => { if (e.key === 'Escape') { query = ''; update() } },
})
  • TypeScript infers the event type from the key and the element. e.currentTarget and this are the element.
  • Nuclo sets the native property, so onClick becomes el.onclick. There is one handler per event: a later onClick replaces an earlier one.
  • onDoubleClick and onDblClick both mean dblclick. Lowercase keys such as onclick are type errors.
  • A name that is not a known event listens to its lowercase form: onMyEvent listens to "myevent".
  • Errors thrown by the handler are not caught.
LiveonInput and onKeyDown are plain attributes. Type, then press Escape to clear.Open ↗

Type something, then press Escape.

on()

on() returns a modifier that calls addEventListener on the element.

panel.ts
const panel = div(
  on('scroll', onScroll, { passive: true }),
  on('click', logClick),
  on('click', closeMenu),
)
  • Every on() adds a listener. All of them run, in order.
  • options goes straight to addEventListener: capture, once, passive, signal.
  • Pass on() straight to a builder and the element type is inferred: input(on('input', (e) => e.currentTarget.value)).
  • If a listener throws, Nuclo logs the error with console.error, and the other listeners still run.
  • Nuclo removes the listener when it removes the element. On the server, on() does nothing.
  • You can reuse one on() value on many elements.

For a custom event, pass the event type as a generic:

picker.ts
type PickEvent = CustomEvent<{ id: number }>

const picker = ul(on<'pick', PickEvent>('pick', (e) => choose(e.detail.id)))

Which to use

Use on* attributes for the usual case. Use on() when you need more than one listener for an event, listener options, or a custom event name such as "my-event".

fnPublic API
// As attributes { onMount?: (el: El) => void | (() => void), onDestroy?: (el: El) => void } // Or with on() function on(type: "mount", listener: (el: El) => void | (() => void)): NodeModFn function on(type: "destroy", listener: (el: El) => void): NodeModFn

onMount runs once, after the element is inserted. onDestroy runs once, when Nuclo removes the element. Write them as attributes or with on(). Both forms behave the same, and the element is typed from its tag.

timer.ts
let seconds = 0

const Timer = () =>
  span(
    {
      onMount: () => {
        const id = setInterval(() => { seconds++; update() }, 1000)
        return () => clearInterval(id) // runs when the span is removed
      },
    },
    () => `${seconds}s`,
  )

const search = input(on('mount', (el) => el.focus()))
LiveonMount starts an interval and returns its cleanup. Unmount runs the cleanup, so the count pauses until the timer mounts again.Open ↗

When they run

  • onMount runs at the end of the render(), hydrate(), update() or forceUpdate() call that inserted the element. It never runs while the tree is being built.
  • If onMount returns a function, that function runs on destroy, after the element's onDestroy callbacks.
  • onDestroy runs when Nuclo removes the element: a list() row is removed, a when() branch switches, an into() leaves its region, or forceUpdate() drops it. It runs just before removal, so the element is still in the document.

Raw DOM removal

If you remove an element yourself, with node.remove() or innerHTML = '', its onDestroy and cleanups never run, and timers keep running. Nuclo has no unmount(). To tear down an app with cleanup, wrap it in when():

main.ts
let mounted = true
render(() => div(when(() => mounted, App())), root)

mounted = false
update() // App's onDestroy hooks and cleanups run

Order and errors

  • You can register as many hooks as you like, in either form. They run in registration order.
  • onDestroy always runs children first, then the parent.
  • Elements mount in the order of their first onMount hook, which follows argument order, and each element runs all its hooks together. Pass a parent's hook before its children if they depend on it.
  • A hook that throws is logged with console.error. The other hooks still run.
  • The callback must return nothing or a cleanup function, so do not make it async. A returned Promise is never used as a cleanup. Start async work inside it: { onMount: (el) => { void load(el) } }.
  • Hooks never run on the server. On the client, they run after hydrate().
Note: Hooks are not effects. They never run again when state changes. Each one runs at most once per element.

Nuclo never updates the page on its own. Change your state, then call update().

counter.ts
let count = 0

const Counter = () =>
  button(() => `Clicked ${count} times`, {
    onClick: () => { count++; update() },
  })

render(Counter)
Livecount++ only changes the variable. The text catches up on the next update(), from the button or anything else on this page.Open ↗

Clicked 0 times

Change as much state as you like, then call update() once:

example.ts
let first = 'Ada'
let last = 'Lovelace'

render(() => p(() => `${first} ${last}`))

first = 'Grace'
last = 'Hopper'
update() // one pass, both changes

Call update() from event handlers, timers, fetch callbacks or onMount. Do not call it from inside a dynamic value.

Dynamic Values

#

A function with no parameters is a dynamic value. Nuclo runs it once when it builds the element, and again on every update(). Use dynamic values for text children, attribute values, style and className. For children that appear, disappear or repeat, use when() and list().

example.ts
let name = 'Alice'
let busy = false

const Profile = () =>
  div(
    h1('Welcome'),              // static
    p(() => `Hello, ${name}`),  // dynamic text
    button({ disabled: () => busy }, 'Save'),
  )
LiveEdit the name. The static text was fixed when the element was built; the dynamic one runs again on every update(). forceUpdate() rebuilds the tree, so the static text catches up too.Open ↗

static: Hello, Alice

dynamic: Hello, Alice

Nuclo checks the parameter count (fn.length). A function that declares a parameter is a custom modifier and runs only once. (x = 0) => … and (...args) => … have a length of 0, so they are dynamic.

Dynamic text

  • A string, number, bigint or boolean renders as String(value). () => ok && 'Saved' shows "false", so write () => (ok ? 'Saved' : '').
  • null and undefined render as empty text.
  • A css() or cx() result on the first run makes the function a dynamic class, not text. See cx().
  • A node, builder, array or plain object renders nothing, and never updates.

Dynamic attributes

  • Nuclo writes a value only when it differs from the last value Nuclo wrote. It does not compare against the live DOM, so if a user types into an input and your function returns the same value as before, the text they typed stays.
  • A result of null or undefined writes nothing, so the old value stays. className is the exception: any falsy result removes the dynamic classes and keeps the static ones.
  • A style function applies the returned object on every update.

Errors

If a dynamic value throws, Nuclo catches the error and the rest of the update still runs. Text becomes empty or keeps its old value, and an attribute keeps its old value. Both recover on the next update() that succeeds. The exception: a text function that throws on its first run stays empty for good.

fnPublic API
function update(...scopeIds: string[]): void

Runs one synchronous pass over every dynamic value on the page. With ids, it only updates what is inside the elements marked with those scope() ids. Each pass runs these steps in order:

  1. list(): reads the items again and adds, moves or removes rows.
  2. when(): checks the conditions again. A branch is rebuilt only when a different branch becomes active.
  3. Dynamic attributes, including style and className.
  4. Dynamic text.
  5. onMount for the elements that steps 1 and 2 inserted.
  • It runs everything. Every dynamic value runs on every call, even when its data did not change. Only the DOM write is skipped. Keep dynamic values fast and free of side effects.
  • Only nodes in the document. If a node is not in the document when update() runs, Nuclo stops tracking it. It never updates again, even after you attach it. This applies to list() and when() too. Render into a container that is in the document.
  • Static values never change. Plain strings and attribute values are fixed when the element is built. To rebuild them, use forceUpdate().
  • Errors. Text and attribute errors are caught, and the pass continues. If a when() condition throws, the error is logged and that when() stops updating. If a list() items or renderItem function throws, the error escapes update() and the remaining steps do not run.
  • Server. update() does nothing on the server.
LiveEvery update() runs every dynamic value, even when its data did not change. Other demos on this page push the count up too.Open ↗

This dynamic value has run 1 times.

fnPublic API
function scope(...ids: string[]): NodeModFn

Gives the element it is passed to one or more scope ids. update('id') then updates only what is inside that element, including the element's own attributes.

example.ts
let cartItems: string[] = []
let user = 'Ada'

const App = () =>
  div(
    header(() => `Hi, ${user}`),
    aside(scope('cart'), span(() => `Items: ${cartItems.length}`)),
  )

render(App)

cartItems.push('apple')
user = 'Grace'
update('cart') // "Items: 1", the header still says "Hi, Ada"
update()       // everything, the header now says "Hi, Grace"
LiveBoth buttons add an item and rename the user. update('cart') refreshes only the scoped panel.Open ↗
Hi, Ada
Items: 0
  • An element can have several ids: scope('cart', 'sidebar').
  • update('a', 'b') updates everything inside a or b. If several elements use the same id, all of them update.
  • With nested scopes, update('outer') includes the inner scope. update('inner') does not touch the outer one.
  • An id that no element in the document has updates nothing and raises no error, so check the spelling.
  • scope() does nothing on the server.
fnPublic API
function when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder interface WhenBuilder { when(condition: boolean | (() => boolean), ...content: NodeModLike[]): WhenBuilder else(...content: NodeModLike[]): WhenBuilder }

Renders content based on a condition. Nuclo checks the conditions again on every update(). The first true condition wins. If none is true, the .else() content renders. Without .else(), nothing renders.

status.ts
let status: 'loading' | 'error' | 'ready' = 'loading'

const Status = () =>
  div(
    when(() => status === 'loading', p('Loading…'))
      .when(() => status === 'error', p('Something went wrong'))
      .else(p('Ready')),
  )
LiveThe first true condition wins; otherwise .else() renders. A new branch is built from scratch.Open ↗

Loading…

Conditions

  • For state, pass a function. A plain boolean is read once: when(open, …) never changes.
  • In TypeScript the function must return a boolean. Write () => items.length > 0 or () => user != null.

What updates

  • While the same branch stays active, its elements are kept. Dynamic values, lists and when() blocks inside it keep updating.
  • When a different branch becomes active, the old content is removed and its onDestroy hooks run. The new branch is built from scratch and its onMount hooks run. State in the old elements, such as input text or focus, is lost.

Branch content

Put children in a branch: text, dynamic text, builders, list() and nested when(). Pass builders such as p('Hi'), not DOM nodes: a DOM node stops updating after its branch hides once.

Attribute objects and on() in a branch apply to the parent element, and they stay after the branch hides. For a conditional attribute, use a dynamic attribute: { className: () => (active ? 'active' : '') }.

Chaining

.when() and .else() return a new builder and do not change the original. If you call .else() twice, the last call wins.

fnPublic API
function list<T>( items: () => readonly T[] | Iterable<T>, renderItem: (item: T, index: number) => ListRenderResult, ): ListModifier

Renders one row per item. On every update(), Nuclo calls items() again and matches the new items to the existing rows by identity (===).

fruits.ts
let fruits = ['Apple', 'Banana']

const Fruits = () => ul(list(() => fruits, (fruit) => li(fruit)))

render(Fruits)

fruits.push('Cherry')
update()

How rows are matched

  • An item that is still there keeps its row. Nuclo moves the row if needed, and does not call renderItem again.
  • A new item gets a new row. A removed item loses its row, and its onDestroy hooks run.
  • Duplicate items each get their own row.
  • You can change the array in place (push, splice, sort) or return a new array.

A kept row is never rendered again, so use dynamic values for the fields that change:

todos.ts
const todos = [{ text: 'Write docs', done: false }]

const Todos = () =>
  ul(
    list(() => todos, (todo) =>
      li({ className: () => (todo.done ? 'done' : '') }, () => todo.text),
    ),
  )

render(Todos)

todos[0].done = true
update() // same <li>, its class is now "done"

Copies are new items, so replacing the array with todos.map((t) => ({ ...t })) rebuilds every row.

LiveRows are matched by identity. Check a row, then Reverse: the same <li> moves, checkbox and all.Open ↗

What renderItem returns

  • Return one element, such as li(...), tr(...) or an SVG builder. Return null or undefined to skip an item. Strings, text nodes and fragments render nothing.
  • Do not return when() or list() directly. Put them inside the row element: li(todo.text, when(() => todo.done, ' ✓')).
  • To hide items, filter in items(), not in renderItem.
  • Keep renderItem free of side effects. Nuclo may call it more than once for the same item.

The index

index is the item's position when its row was created. It is not updated when rows move. For the current position, use a dynamic value: li(() => `${names.indexOf(name) + 1}. ${name}`).

Iterables

Any iterable works: arrays, Set, generators and map.values(). Returning the same array or Set each time is fine. An iterator, such as a generator or map.values(), is used up after one pass, so create it inside items() on every call. Iterating a Map directly creates new [key, value] arrays each time, so every row is rebuilt. Use map.values() instead.

Placement

Rows appear where list() sits among its siblings, between two comment markers. Other children of the parent are not touched. list() also works inside SVG builders.

fnPublic API
function region(options: RegionOptions): NodeModFn function into(id: string, ...content: NodeModLike[]): NodeModFn function into(regions: Record<string, NodeModLike | NodeModLike[]>): NodeModFn interface RegionOptions { id: string type?: 'latest' | 'stack' // default: 'latest' empty?: NodeModLike }

region() marks a named place in the tree. into() fills it from anywhere else, at any depth. The layout does not receive the content as a prop, and the component that builds the content does not know where it lands.

app.ts
const Layout = () =>
  div(
    aside(nav(a({ href: '/' }, 'Home'))),
    main(region({ id: 'main', empty: p('Nothing open.') })),
  )

// Anywhere in the tree, before or after the region
const Article = () => into('main', article(h1('Hello')))

render(() => div(Layout(), Article()))

How it works

  • into() leaves only a comment where it is written. Its content renders between the region's markers.
  • Order does not matter. A into() built before its region waits and shows the moment the region is built. Views show in the order they were written.
  • The content lives as long as the view. When the when() that holds an into() closes, or its list() row is removed, the content leaves the region. Opening it again adds one copy, not two.
  • When a region goes away, for example its when() closes, its views wait again. A region built later with the same id shows them.
  • A into() whose id matches no region renders nothing and raises no error, so check the spelling.
  • Ids are unique in the tree. A second region built under a live id logs a warning.
  • Regions work with server rendering. On hydrate(), each view claims the HTML the server rendered instead of building it again.

Options

  • id: the name into() calls use to find the region.
  • type: 'latest' (the default) shows only the newest view. 'stack' shows every view, in arrival order.
  • empty: rendered while the region holds no views.

Stacked regions

With type: 'stack', a view that opens over another does not rebuild the one underneath. Its DOM, focus and form state stay. Use this for a layer such as a dialog over a page.

layers.ts
let open = false

const App = () =>
  div(
    region({ id: 'main', type: 'stack' }),
    into('main', form(input({ placeholder: 'Keeps its text' }))),
    when(() => open, into('main', div('On top'))),
  )

Several regions at once

Pass an object to fill more than one region from a single into(). A value can be one child or an array of children. Either form nests: an into() inside another view's content targets its own region.

page.ts
const Page = () =>
  into({
    main: article(h1('Eager route')),
    sidebar: [h2('Related'), ul(li('One'), li('Two'))],
  })

A dialog opened from deep in the tree

The overlay region sits at the root. Any component, at any depth, opens a dialog by wrapping an into() in a when(). Closing the when() removes the dialog from the region.

dialog.ts
let confirming = false

const App = () =>
  div(
    Page(),
    div({ className: 'overlay' }, region({ id: 'overlay' })),
  )

// Deep inside Page()
const DeleteButton = () =>
  div(
    button('Delete', { onClick: () => { confirming = true; update() } }),
    when(() => confirming,
      into('overlay',
        dialog({ open: true },
          p('Delete this item?'),
          button('Cancel', { onClick: () => { confirming = false; update() } }),
        ),
      ),
    ),
  )

Toasts from list rows

Each row of a list() is a view. A stacked region shows them in order, and removing an item from the array removes its toast.

toasts.ts
const toasts: { text: string }[] = []

const notify = (text: string) => {
  toasts.push({ text })
  update()
}

const dismiss = (toast: { text: string }) => {
  toasts.splice(toasts.indexOf(toast), 1)
  update()
}

const App = () =>
  div(
    button('Save', { onClick: () => notify('Saved.') }),
    div({ className: 'toasts' }, region({ id: 'toasts', type: 'stack' })),
    list(() => toasts, (toast) =>
      into('toasts', div(toast.text, button('×', { onClick: () => dismiss(toast) }))),
    ),
  )

A page title set by the page

With the default type: 'latest', the newest view wins. The layout owns the heading, and whichever page is open names it. A page without an into('title', …) leaves the empty text.

title.ts
let page: 'home' | 'settings' = 'home'

const Layout = () =>
  div(
    h1(region({ id: 'title', empty: 'My app' })),
    when(() => page === 'home', Home()).else(Settings()),
  )

const Home = () => p('Welcome.')

const Settings = () =>
  div(
    into('title', 'Settings'),
    form(/* … */),
  )

Dynamic values inside a view

The content of an into() is ordinary Nuclo content. Functions in it are state-dependent values and refresh on update(), wherever the region is.

status.ts
const selected = new Set<string>()

const Layout = () =>
  div(
    header(region({ id: 'status' })),
    Table(),
  )

const Table = () =>
  div(
    into('status', () => `${selected.size} selected`),
    list(() => rows, (row) =>
      label(
        input({ type: 'checkbox', onChange: (e) => {
          e.currentTarget.checked ? selected.add(row.id) : selected.delete(row.id)
          update()
        } }),
        row.name,
      ),
    ),
  )

A region that comes and goes

The dashboard's into() is written once. While the user is signed out there is no main region, so the view waits. Signing in builds the region and the view shows. Signing out removes the region and the view waits again.

auth.ts
let user: { name: string } | null = null

const App = () =>
  div(
    when(() => user !== null,
      main(region({ id: 'main' })),
    ).else(
      LoginForm(),
    ),
    into('main', Dashboard()),
  )

A view with its own region

A view can contain a region(), and other views can target it. Here the editor page brings a tabs region with it, and each tab fills it. When the editor closes, its region goes with it.

editor.ts
let tab: 'code' | 'preview' = 'code'

const Editor = () =>
  into('main',
    div(
      nav(
        button('Code', { onClick: () => { tab = 'code'; update() } }),
        button('Preview', { onClick: () => { tab = 'preview'; update() } }),
      ),
      section(region({ id: 'tabs' })),
    ),
    when(() => tab === 'code', into('tabs', textarea()))
      .else(into('tabs', iframe({ src: '/preview' }))),
  )

Live example

region-app.ts
import 'nuclo'

let page: 'home' | 'article' | null = 'home'
let dialog = false

const go = (next: typeof page) => () => {
  page = next
  dialog = false
  update()
}

const Layout = () =>
  div(
    nav(
      button('Home', { onClick: go('home') }),
      button('Article', { onClick: go('article') }),
      button('Close page', { onClick: go(null) }),
    ),
    main(
      region({ id: 'main', type: 'stack', empty: p('Nothing open.') }),
    ),
    aside(region({ id: 'side', empty: p('No related links.') })),
  )

// Pages fill the layout's regions from outside it.
const Home = () =>
  into('main',
    input({ placeholder: 'Type here, then open the dialog' }),
    button('Open dialog', {
      onClick: () => { dialog = true; update() },
    }),
  )

const Article = () =>
  into({
    main: article(h2('Article'), p('One into() fills both regions.')),
    side: [h2('Related'), ul(li('One'), li('Two'))],
  })

// Stacks over Home, so Home keeps its DOM and the text you typed.
const Dialog = () =>
  into('main',
    div(
      p('On top of Home.'),
      button('Close', { onClick: () => { dialog = false; update() } }),
    ),
  )

export const RegionApp = () =>
  div(
    Layout(),
    when(() => page === 'home', Home()),
    when(() => page === 'article', Article()),
    when(() => dialog, Dialog()),
  )
LiveA layout with a stacked main region and a side region, filled from outside it. Type into Home, open the dialog and close it: the text stays. Article fills both regions with one into().Open ↗

No related links.

fnPublic API
function forceUpdate(): void function forceUpdate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

update() only runs dynamic values. Static values, such as the text in h1(labels[lang].title), are fixed when the tree is built. forceUpdate() calls your components again and patches the live DOM to match, static values included. Use it for rare, app-wide changes, such as switching the language. Use update() for everything else.

i18n.ts
const labels = { en: { title: 'Hello' }, pt: { title: 'Olá' } }
let lang: keyof typeof labels = 'en'

const App = () => div(h1(labels[lang].title))
render(App, root)

lang = 'pt'
forceUpdate() // same <h1> element, now "Olá"
Liveupdate() refreshes only the dynamic lang line. forceUpdate() calls App again and patches the same <h1>. The explicit form rebuilds only this tree, not the whole page.Open ↗

lang = 'en'

Hello

Static text, fixed on build.

With no arguments, forceUpdate() rebuilds every root rendered with render(App) or hydrate(App). Roots rendered with render(App()) are skipped. If a component throws, the error is logged and that root keeps its old DOM. An error thrown later, while the new build is applied, is logged too, but the parts already patched stay patched.

What stays and what changes

  • Children are matched in order, by tag. A matching element is reused, so focus and typed input values stay, and its onMount hooks do not run again. Once one child's tag differs, that child and every later sibling are built fresh.
  • Text, attributes, classes, styles, listeners, when() branches and list() rows follow the new build.
  • New elements are created, and their onMount hooks run. Elements the new build no longer makes are removed, and their onDestroy hooks run.
  • list() rows are matched by position too, not by item, so a reused row element can end up showing a different item.
  • Attributes the new build no longer sets are not removed.
Watch out: state declared inside a component function is reset, because the function runs again. Keep state that must survive outside the component.

Explicit form. forceUpdate(App(), parent) rebuilds one tree in parent and returns its root. It does not register the root for later calls. Like hydrate(), it expects the root to be the first node in parent. If the first node has a different tag, it is replaced.

On the server, forceUpdate() does nothing.

render()

#
fnPublic API
function render(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element, index?: number): ExpandedElement

Builds your tree and appends its root element to parent. parent defaults to document.body. It returns the root element.

main.ts
import 'nuclo'
import { App } from './app.ts'

render(App, document.getElementById('app')!)
  • You can pass the component (render(App)) or the builder it returns (render(App())). The DOM is the same. Only render(App) lets forceUpdate() rebuild the root later, so prefer it.
  • It appends. It does not clear parent, so calling it twice adds two copies.
  • Every onMount in the tree has run when render() returns.
  • Render into an element that is in the document. See update().
  • The root must be an element, such as div(...), not when() or list().
  • index is internal. It does not choose where the element is inserted. Leave it out.

A component passed as render(App) must take no parameters. A required parameter is a type error. An optional one type-checks, but render() throws at runtime. Wrap a component that takes props:

main.ts
const Page = (props: { name: string }) => div(h1('Hi ', props.name))

render(() => Page({ name: 'Ana' }), root)

hydrate()

#
fnPublic API
function hydrate(nodeModFn: NodeModFn | (() => NodeModFn), parent?: Element): ExpandedElement

Takes over HTML made by renderToString(). It reuses the existing elements, attaches event listeners, and connects dynamic values, when() and list(), so update() works afterward. Then it runs the onMount hooks. parent defaults to document.body. It returns the root element.

client.ts
import 'nuclo'
import { App } from './app.ts'

hydrate(App, document.getElementById('root')!)
  • Use the same component, and the same data, as the server.
  • Hydrate into a dedicated container. The app's root must be its first node, although whitespace is fine. If another node comes first, Nuclo replaces that node and leaves the server copy behind.
  • As with render(), pass App, not App(), so forceUpdate() can rebuild the root later. App must take no parameters. With an optional one, hydrate() silently attaches nothing. Wrap a component that takes props: hydrate(() => Page(props), root).
  • If there is no server HTML, use render().

When the server and the client differ

The client wins:

  • A different root tag: that node is replaced with fresh DOM. An empty container: fresh DOM is appended.
  • Different text is patched. Different list() items and when() branches follow the client.
  • Extra server children are removed, and missing ones are created.
  • Attributes that the server wrote but the client does not set are left as they are.

renderToString() writes HTML comments as markers, such as <!-- text-0 --> before each text node. hydrate() needs them, so do not strip comments from the server HTML. Without the markers, hydration deletes the list() rows, and list() and when() stop updating.

Server Rendering

#
fnPublic API
// import { ... } from 'nuclo/ssr' function renderToString(input: RenderableInput): string function renderManyToString(inputs: RenderableInput[]): string[] function renderToStringWithContainer(input: RenderableInput, containerTag?: string, containerAttrs?: Record<string, string>): string function getCssText(): string type RenderableInput = NodeModFn | (() => NodeModFn) | Element | Node | null | undefined

Render a component to an HTML string on the server. In the browser, call hydrate() with the same component to make that HTML interactive. Server runtimes have no document, so import nuclo/polyfill first.

app.ts
let count = 0
const heading = css({ color: 'red' })

export const App = () =>
  main(
    h1(heading, () => `Count: ${count}`),
    button({ onClick: () => { count++; update() } }, '+1'),
  )
server.ts
import 'nuclo/polyfill'
import 'nuclo'
import { renderToString, getCssText } from 'nuclo/ssr'
import { App } from './app.ts'

const html = renderToString(App)
const page = `<!doctype html>
<html>
  <head><style id="nuclo-styles">${getCssText()}</style></head>
  <body>
    <div id="root">${html}</div>
    <script type="module" src="/client.js"></script>
  </body>
</html>`
client.ts
import 'nuclo'
import { App } from './app.ts'

hydrate(App, document.getElementById('root')!)

Keep id="nuclo-styles" on the style tag. The client reuses that element and adds only new rules. See getCssText().

What runs on the server

  • Each renderToString() call builds the tree and runs each dynamic value once.
  • Event handlers, onMount and onDestroy never run. update(), scope() and forceUpdate() do nothing.
  • Rendering is synchronous. Load your data first, then render.
  • Nothing stays registered after a render, so one process can serve many requests. Pass request data as arguments, such as renderToString(Page(user)). Module-level variables are shared by all requests.
  • Do not call render() on the server. It appends to the shared document.body.

renderToString()

Takes a builder (App()), a component (App) or a DOM node. null and undefined return "". The root must be an element. A component passed as renderToString(Page) must take no parameters, so pass Page(user) for one that takes data.

example.ts
renderToString(p('Hello'))
// '<p><!-- text-0 -->Hello</p>'
  • Text and attribute values are HTML-escaped. Text inside script() and style() is written as is, with only closing tags broken up, so never put user input there.
  • It does not throw. If the component, a list() function or a when() condition throws, or the polyfill is missing, it logs the error and returns "". Check for an empty result if the request should fail. A text or attribute function that throws only renders empty.

renderManyToString()

The same as inputs.map(renderToString).

renderToStringWithContainer()

Wraps the output in one element. containerTag defaults to "div", and containerAttrs to {}. Attribute values are escaped. Attribute names are written as given, so use HTML names such as class. Never pass user input as the tag.

example.ts
renderToStringWithContainer(p('Hi'), 'section', { id: 'main', class: 'page' })
// '<section id="main" class="page"><p><!-- text-0 -->Hi</p></section>'

nuclo/polyfill

#
import 'nuclo/polyfill' // Named exports: // document, Event, CustomEvent, Node, Element, HTMLElement, // NucloDocument, NucloElement, NucloText, NucloNode

A small DOM so Nuclo can build trees in Node and Bun. Import it once, before your first render. Most apps only need the plain import.

  • When there is no window, it sets globalThis.document, Node, Element and HTMLElement, but only the ones that are missing. It does not create window.
  • In a browser, or under jsdom, it does nothing.
  • Event and CustomEvent are the runtime's own constructors. Node, Element and HTMLElement are aliases of the polyfill classes.

It is a DOM for building trees, not a browser. It has no innerHTML and no getElementById, querySelector() returns null, and event listeners are ignored. Use renderToString() to get the HTML.

css()

#
fnPublic API
function css(style: Style): StyleResult function css(name: string, style: Style): StyleResult type StyleResult = { className: string }

Turns a typed style object into generated CSS classes. Pass the result to any tag builder. In the browser, the rules go into one <style id="nuclo-styles"> element. On the server, getCssText() returns them.

card.ts
const card = css({
  p: 16,
  rounded: 8,
  bg: '#fff7ed',
  hover: { bg: '#ffedd5' },
})

const Card = () => div(card, 'Simple card')
  • css() makes one class for the base declarations, plus one class for each pseudo key, selector and media block. The card above gets two classes.
  • Class names are hashes of the content. Identical styles share a class, and the server and the client produce the same names.
  • Define styles once, at module level. css() caches by object, so a new object on every call is processed again each time.
  • Changing a style object after passing it to css() does nothing.
  • Every style stays registered for the life of the page or server process. For values that change per item, such as a width from data, use the style attribute.

Named styles

Pass a name first to get a readable class name:

title.ts
const pageTitle = css('page-title', { text: 24, hover: { color: 'red' } })

pageTitle.className // "page-title page-title-<hash>"
  • The base class is exactly the name. Other blocks get name-<hash>.
  • The name must be a valid class name. Otherwise Nuclo warns and generates one.
  • Names are global. If two different styles use the same name, Nuclo warns.

Using the result

example.ts
const card = css({ p: 8 })

div(card)                           // best
div(card, { className: 'featured' }) // className merges
div(card, { class: 'featured' })     // replaces: only "featured" is left

String(card) and card.className both return the class names.

Style Objects

#

The object you pass to css() uses common CSS properties in camelCase, a set of shorthands, and nested keys for pseudo states, selectors and media queries. TypeScript autocompletes all of them. Properties outside the typed set, such as borderTopLeftRadius, fill or paddingInline, go in raw (see below).

Values

  • Numbers become px, except 0 and unitless properties: zIndex, opacity, fontWeight, lineHeight, flex, flexGrow, flexShrink, order, aspectRatio, gridColumn and gridRow. In raw, zoom, scale, column-count, orphans, widows, tab-size and animation-iteration-count are unitless too.
  • Strings are used as written. null, undefined and false are skipped.
  • lineHeight (and leading) is unitless, so leading: 24 means 24 times the font size. Write "24px" for pixels.
example.ts
css({ p: 16, m: 0, opacity: 0.5, leading: 1.5, z: 10, w: '50%' })
// padding:16px; margin:0; opacity:0.5; line-height:1.5; z-index:10; width:50%

Shorthands

KeyCSS
p, pt, pr, pb, plpadding, and each side
px / pypadding left and right / top and bottom
m, mt, mr, mb, ml, mx, mythe same for margin
w, h, minW, maxW, minH, maxHwidth, height, and their min and max
sizewidth and height
bgbackground
text / font / weightfont-size / font-family / font-weight
leading / trackingline-height / letter-spacing
align / items / justifytext-align / align-items / justify-content
z / rounded / shadow / selectz-index / border-radius / box-shadow / user-select

These keys take true:

KeyCSS
rowdisplay: flex; flex-direction: row
coldisplay: flex; flex-direction: column
centeralign-items: center; justify-content: center (does not set display)
truncateoverflow: hidden; text-overflow: ellipsis; white-space: nowrap

Pseudo states

hover, focus, focusVisible, focusWithin, active, visited, disabled, enabled, checked, required, invalid, valid, readOnly, first (:first-child), last (:last-child), only (:only-child), odd, even, empty, placeholderShown, placeholder, before, after, selection, marker, firstLine and firstLetter. They nest: hover: { focus: {…} } gives :hover:focus.

Selectors and at-rules

A key that starts with & is a selector, and each & becomes the class. A key that starts with @ is an at-rule: @media, @container or @supports.

menu.ts
const menu = css({
  p: 8,
  '& > li': { py: 4 },
  '& > li:hover': { color: 'red' },
  '&:is(.dark *)': { color: 'white' },
  '@media (min-width: 768px)': { p: 16 },
})
  • The key must start with &. A key such as ".dark &" is ignored, so write "&:is(.dark *)".
  • Two nested @media blocks combine with and. A query led by a media type (print, only screen) is placed first, because the grammar allows a type only at the start. A pair that cannot be flattened — two media types, or a leading not — warns and uses the inner query alone. Any other nested at-rule replaces the outer one.

raw

Use raw for any property, with the name written exactly as in CSS. Numbers still get px, so pass strings:

example.ts
css({ raw: { '--gap': '8px', '-webkit-line-clamp': '2' } })

Any other flat key is converted to kebab-case and written out as is, so a typo such as colr: 'red' becomes a declaration the browser drops. A nested object under a key that is not a pseudo state, a screen, or an & or @ key is ignored. TypeScript reports both.

fnPublic API
function cx(...inputs: ClassInput[]): StyleResult type ClassInput = StyleResult | string | false | null | undefined | ClassInput[]

Combines styles. When two inputs set the same property, the later input wins.

example.ts
const base = css({ p: 8, color: 'red' })
const blue = css({ color: 'blue' })

cx(base, blue)                  // padding: 8px; color: blue
cx(base, false, null, 'extra')  // base's classes plus "extra"
div(cx(base, blue), 'Blue text')
  • Falsy inputs are skipped, arrays are flattened, and strings are split and de-duplicated.
  • Generated classes for the same block (base, the same pseudo, the same media query) merge into one new class. Other class names pass through unchanged.
  • Only cx() knows argument order. With div(base, blue), both classes are added, and the rule that was created last wins. Put styles that override each other in one cx() call.
  • When a named style's block merges with another input's block, the merged class is name-<hash>, not the bare name. Blocks that do not merge keep their class, so cx(pageTitle, 'extra') still has page-title.

Dynamic classes

Pass a function that always returns cx(...):

toggle.ts
const baseButton = css({ px: 12, py: 8, border: '1px solid #ccc' })
const activeButton = css({ bg: '#ff3f00', color: 'white' })

let active = false

const Toggle = () =>
  button(
    () => cx(baseButton, active && activeButton),
    () => (active ? 'Active' : 'Inactive'),
    { onClick: () => { active = !active; update() } },
  )

The function must return a style result on its first run. () => active && activeButton renders the text "false" instead of a class.

createCss()

#
fnPublic API
function createCss<const T extends ThemeConfig>(theme?: T): CssInstance<T> // CssInstance<T> = { css, cx, variants, keyframes, globalStyle, theme }

Returns its own css, cx, variants, keyframes and globalStyle, bound to a theme. A theme gives names to values: with the theme below, borderColor: 'border' writes #e5e7eb, and TypeScript autocompletes the names. Each entry in screens becomes a style key for a media query, such as md: {…}.

theme.ts
export const { css, cx, variants } = createCss({
  colors: { primary: '#ff3f00', border: '#e5e7eb' },
  radii: { card: '12px' },
  screens: { md: '(min-width: 768px)' },
})

const panel = css({
  p: 12,
  rounded: 'card',
  border: '1px solid',
  borderColor: 'border',
  md: { p: 24 },
})
Theme keyUsed by
colorsbg, backgroundColor, color, borderColor and each side, outlineColor, caretColor, textDecorationColor
fontsfont, fontFamily
shadowsshadow, boxShadow
radiirounded, borderRadius
screensnew style keys, such as md: {…}
  • A theme name resolves only when it is the whole value. border: "1px solid primary" is not resolved.
  • A screen value without @ becomes @media <value>. A value that starts with @ is used as written, for example "@container (min-width: 400px)".
  • Screens exist only on instances made with createCss(). The global css() has none, but it accepts @media keys.
  • All instances and the global helpers share one stylesheet. Screen media rules are output in the order the screens are declared, not the order you use them, so list min-width screens from small to large. Use one createCss() instance per app, so that order is the one in your theme.
  • theme on the result is the theme you passed.

variants()

#
fnPublic API
function variants(config: { base?: Style variants?: { [group: string]: { [value: string]: Style } } defaultVariants?: { [group: string]: value } compoundVariants?: ({ [group: string]: value } & { css: Style })[] }): (props?: { [group: string]: value }) => StyleResult

Builds a function that returns a style for a set of options, such as a button's intent and size.

button.ts
const buttonClass = variants({
  base: { px: 12, py: 8, rounded: 6 },
  variants: {
    intent: { primary: { bg: '#2563eb' }, danger: { bg: '#dc2626' } },
    size: { sm: { text: 12 }, lg: { text: 18 } },
    block: { true: { w: '100%' } },
  },
  defaultVariants: { intent: 'primary', size: 'sm' },
  compoundVariants: [{ intent: 'danger', size: 'lg', css: { weight: 700 } }],
})

button(buttonClass(), 'Save')                                                // primary, sm
button(buttonClass({ intent: 'danger', size: 'lg', block: true }), 'Delete') // bold, full width
  • Styles merge in this order: base, the chosen value of each group, then each matching compound. Later styles win per property.
  • A missing, undefined or null option uses the default. A group with no default is skipped.
  • A group with only true and false keys takes a boolean.
  • A compound applies when every group it lists matches, defaults included.
  • Each selection is built once and cached.

keyframes() & globalStyle()

#
fnPublic API
function keyframes(frames: KeyframeFrames): string function globalStyle(selector: string, style: FlatStyle): void

keyframes() registers an animation and returns its generated name. Frame keys are from, to and percentages, such as "50%" or "0%, 100%". Frames take flat properties only. Identical frames return the same name.

fade.ts
const fadeIn = keyframes({
  from: { opacity: 0 },
  to: { opacity: 1 },
})

const panel = css({ animation: `${fadeIn} 200ms ease-out` })

globalStyle() adds a rule for any selector:

reset.ts
globalStyle('*, *::before, *::after', { boxSizing: 'border-box' })
globalStyle('body', { m: 0, font: 'system-ui, sans-serif' })
  • The selector is used exactly as written.
  • Only flat properties work. Pseudo keys, screens and @ keys are ignored, so globalStyle() cannot write media rules.
  • The same rule is added once. A different style for the same selector adds a second rule, and the later one wins.

getCssText() & resetStyles()

#
fnPublic API
function getCssText(): string // from 'nuclo/ssr' or 'nuclo' function resetStyles(): void // from 'nuclo'

getCssText() returns every rule that css(), cx(), variants(), keyframes() and globalStyle() have created in this process: base rules first, then the @media, @container and @supports blocks. Use it for server rendering.

  • Call it after renderToString(). Rules created during the render are included.
  • It covers the whole process, not one page or request, and it only grows. That is safe, because class names are content hashes.
  • Put it in <style id="nuclo-styles">. The client reuses that element and skips the rules already in it. With another id, or none, the client adds a second stylesheet with every rule again.
  • The text is not escaped, so never put untrusted input into style values.

resetStyles() is a test helper. It removes every rule and the #nuclo-styles element. Styles you created earlier keep their class names but lose their CSS. Do not call it in an app or on a server.

setup.test.ts
import { resetStyles } from 'nuclo'

beforeEach(() => resetStyles())

nuclo-router

#
Separate package: nuclo-router is not part of the Nuclo core package. Install it next to nuclo. It is a preview release, so its API may still change.

Routing for Nuclo: server rendering, hydration, code-split routes and idle preloading, built on list() and update(). About 4.9 KB gzipped, with no dependencies beyond Nuclo itself and no side effects, so it tree-shakes away when unused.

terminal
$ npm install nuclo nuclo-router

Or use pnpm add, yarn add or bun add with the same two names. It needs nuclo 0.3 or later as a peer dependency.

The router decides what to load. The app says where with router.outlet(): one list() over the layer stack, one row per layer, so an ordinary page is a single row. The server and the client build the same tree, so hydrate() claims the server's nodes instead of replacing them. Everything else is history, one delegated click listener and requestIdleCallback.

One import

main.ts
export const router = createRouter(routes)   // define once, import anywhere
await router.start()                          // resolve the current URL
render(App, document.getElementById('app')!) // App places router.outlet() and reads router.path, router.go(), …

start() resolves the matching route's modules before it returns, so the first tree is complete on both the server and the client. After that the router reads through to the route it started: router.path, router.pending, router.go().

A whole app

routes.ts
// isomorphic: imported by the browser and the server
import { createRouter } from 'nuclo-router'

export const router = createRouter({
  '/':           () => import('./pages/Home.ts'),
  '/docs':       () => import('./pages/Docs.ts'),
  '/blog/:slug': () => import('./pages/Post.ts'),
  '*':           () => import('./pages/NotFound.ts'),
})

export const App = () =>
  div(
    header(a({ href: router.href('/') }, 'Home'), a({ href: router.href('/docs') }, 'Docs')),
    main(
      when(() => router.pending, Spinner()),
      when(() => router.error !== null, ErrorView()),
      router.outlet(),   // where the pages land
    ),
    footer('© nuclo'),
  )
pages/Post.ts
import type { RouteContext } from 'nuclo-router'
import { router } from '../routes.ts'

// A page is plain content
export default function Post(ctx: RouteContext) {
  // A query-only change keeps the page, so the query is read live.
  return article(h1(ctx.params.slug), p(() => (router.search.has('preview') ? 'draft' : '')))
}
main.ts
import 'nuclo'
import { router, App } from './routes.ts'

await router.start()
hydrate(App, document.getElementById('app')!)   // or render() without SSR
server.ts
import 'nuclo/polyfill'
import 'nuclo'
import { renderToString } from 'nuclo/ssr'
import { router, App } from './routes.ts'

// one Route per request
const route = await router.start(request.url)
const html = route.run(() => renderToString(App))

That is the whole integration. Links work because an <a href> is just an <a href>. One delegated listener on document intercepts the links the router owns and leaves every other link to the browser.

On the server, start() returns a Route per request and leaves the router alone, so concurrent requests cannot see each other's route. route.run() makes that Route the one the router, and so the outlet, reads for the length of the render. In the browser there is one Route per page load, and start() makes it the router's own. No run() is needed.

A page receives its RouteContext and its PageProps, as (ctx, { layer, data, outlet, beforeLeave }), and may take fewer arguments. It returns anything list() accepts. To fill another part of the layout as well, return an into() from inside the page, such as div(article(…), into('sidebar', nav(…))). That content leaves with the page. See Pages & Regions.

Patterns & Nested Routes

#
PatternMatchesparams
/, /docs/introexactly that path{}
/blog/:slug/blog/hello{ slug: 'hello' }
/u/:id/edit/u/7/edit{ id: '7' }
/files/*rest/files/a/b.txt{ rest: 'a/b.txt' }
*anything unmatched, your 404{ '*': '…' }

Precedence is static, then dynamic in declaration order, then catch-all with the longest prefix first. So '*' only matches what nothing else does and can sit anywhere in the table. Trailing slashes, duplicate slashes and percent-encoding are normalized, and param values arrive decoded. Matching is a segment comparison per pattern. No regexes are built or run.

Relative routes

A pattern written with ./ is relative to the parent that declares it, and an href written with ./ is relative to the active route. Together they make a reusable section that means the same thing under every parent. A table's value is either a loader or another table, whose keys are relative to it:

records/routes.ts
import type { RouteTable } from 'nuclo-router'

// a section, with no idea where it lives
export const recordSection: RouteTable = {
  '/':             () => import('./Record.ts'),   // the parent path itself
  './preview':     () => import('./Preview.ts'),
  './preview/raw': () => import('./Raw.ts'),
}
routes.ts
// declare it wherever it belongs
createRouter({
  '/': () => import('./pages/Home.ts'),
  '/invoices/:id':  recordSection,
  '/customers/:id': recordSection,
  '*': () => import('./pages/NotFound.ts'),
})

Those two lines produce all six patterns with no path written twice. Both parents get the same loader objects, so they share one module cache and one import().

  • Inside a child table, './x', '/x' and 'x' all mean the same thing, '/' means the parent path itself, and '*rest' gives the section its own catch-all. Nesting goes as deep as you like.
  • Top-level keys are used as written, so '*' stays '*'.
  • A child under a *catch-all parent is a createRouter() error, since a catch-all has to end its pattern.

Parent pages and outlet()

A parent and its children are a real hierarchy. The parent renders, and its child renders inside it, wherever it calls outlet():

records/Record.ts
import type { PageProps, RouteContext } from 'nuclo-router'

// a parent page takes its outlet from (ctx, { outlet })
export default function Record(ctx: RouteContext, { outlet }: PageProps) {
  return div(Header(ctx), input({ id: 'notes' }), main(outlet()))
}

Navigating between children does not rebuild the parent. Its DOM node is the same node, so its focus, its scroll position, its half-typed input and any state in its closure all survive.

  • A page with no child route renders nothing from its outlet, so a layout can call it unconditionally. A parent that has a child and never calls outlet() swallows it.
  • There are no index routes. '/' is the layout itself, so the parent path renders the layout with an empty outlet rather than a separate index page inside it. Put the landing content in the layout, or give it a real child path.

The same call then works from either parent, and from a page that does not know which one it is in:

example.ts
router.href('./preview')      // '/invoices/42/preview' or '/customers/7/preview'
await router.go('./preview')
await layer.push('./preview') // the usual shape: a child layer

./x, ../x, . and .. resolve against the current route's path, not the URL's "directory". From /invoices/42, ./preview is /invoices/42/preview, where a browser would have said /invoices/preview, and a trailing slash on the URL changes nothing. ../ walks up a segment and stops at the root. The query and hash are carried through, base is re-added, and the resolved absolute path is what goes into history. This is the same deviation from URL resolution that React Router makes for <Link to="./x">.

  • Only ./ and ../ are route-relative. A bare preview in an href is left to ordinary browser rules.
  • Build relative links with router.href('./preview') rather than writing href="./preview" by hand. href() resolves at render time, so the markup that ships is absolute and still correct for a crawler, for a middle-click and with JavaScript disabled.

What is kept, and what is rebuilt

NodeOn navigation
a parent whose pattern and path are unchangedkept: same node, and its load() is not re-run
the matched pagekept when its pattern and path are unchanged and it has no load(), so a query or hash change keeps it. A route with a loader refreshes on every visit.
a parent whose path changedrebuilt, along with everything under it
a pushed layerrenders the matched page alone. Its parents are already mounted in the row beneath it.

A kept page keeps the ctx it was built with. That is the meaning of ctx everywhere: read router.* for live values. Query-driven state, such as filters, pagination and tabs, reads router.search inside a dynamic value and writes with router.go('?page=2', { replace: true }). The page, its focus and its half-typed input stay put.

Pages & Regions

#

router.outlet() puts the page in one place. A page that also needs a sidebar, a toolbar or a heading somewhere else in the layout fills it with into(). The layout marks the place with region(), and the page returns an into() next to its own content. The layout never receives page content as a prop, and navigation never rebuilds it.

app.ts
export const App = () =>
  div(
    aside(
      input({ placeholder: 'Filter' }),   // keeps its text across navigations
      region({ id: 'sidebar', empty: p('Nothing to show.') }),
    ),
    main(router.outlet()),
  )
pages/Post.ts
import type { PageProps, RouteContext } from 'nuclo-router'

export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) {
  return div(
    article(h1(post.title), p(post.body)),
    into('sidebar', nav(h2('In this post'), ul(list(() => post.headings, (h) => li(h))))),
  )
}

The page's content is a row of the outlet's list(), and an into() lives as long as the row that holds it. So the sidebar content arrives with the page and leaves with it: navigating to a page without an into('sidebar', …) shows the region's empty content again. The page can use its ctx and data for both parts, since both are built by the same function.

What happens on navigation

  • A kept page keeps its regions. A query or hash change that keeps the page does not touch the region content either. Read router.search inside a dynamic value if the sidebar depends on it.
  • A rebuilt page refills them. When the route changes, the old row is removed and the new page's into() views take over. Nothing is left behind.
  • A pushed layer adds a view. The layer is a later row of the outlet, so its into() is a newer view of the same region. With the default type: 'latest' it covers the page's view until the layer closes, and then the page's view shows again. With type: 'stack' both show.
  • A parent page can fill regions too. Its views stay while its children change, because the parent is not rebuilt.
  • Server rendering works as is. The server builds the page inside route.run(), so its into() views are in the HTML, and hydrate() claims them.

Several regions from one page

Pass an object to fill more than one region at once. The page's main content still goes through the outlet, so return it beside the into():

pages/Report.ts
export default function Report(ctx: RouteContext) {
  return div(
    table(…),
    into({
      toolbar: [button('Export'), button('Print')],
      sidebar: nav(a({ href: router.href('./summary') }, 'Summary')),
    }),
  )
}

Which tool for which job

  • outlet() is for a child route: content the URL decides, rendered inside its parent page. See Nested Routes.
  • into() is for the matched page spreading itself across the layout. The content belongs to the page and uses its data.
  • matches() is for the layout deciding from the URL alone, with its own small table and no page involved. See matches().

All of these belong to the one matched route. The router does not drive two independent areas from one URL, so a sidebar with its own routes is a region a page fills, not a second outlet. A sidebar that really wants its own router, loading and fallback keeps one unstarted and resolves with match() — see Multiple Routers & Combined Loading.

Route Loaders & Code Splitting

#

A page module may export a load function. It runs before the page is built, on every navigation to that route, and whatever it returns is handed to the page as data:

pages/Post.ts
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router'

export const load: DataLoader<Post> = (ctx) => fetchPost(ctx.params.slug)

export default function Post(_ctx: RouteContext, { data: post }: PageProps<Post>) {
  return article(h1(post.title), p(post.body))
}

The route table is unchanged. The loader travels with the page. It gets the matched RouteContext, so params, search and path are all available. It may be sync or async, and it runs on the server too, so a server-rendered page ships with its data already in the HTML instead of fetching after hydration.

The module is cached. The data is not. A route's chunk is fetched once and reused, and its loader runs again on every navigation. That is why /blog/a and /blog/b cannot be confused, and why a failed loader can simply be retried.

SituationBehavior
while it runsrouter.pending is true and the page you were on stays up
it throws or rejectslands on router.error. go() still never rejects, and the navigation can be retried.
during start()the rejection comes out of start(). There is no Route yet to put it on.
a pushed layerruns its own loader, with its own data
the idle preloaderwarms modules only. It has no context to pass, and would be fetching data for a route you may never open.
re-navigating to the same URLthe loader runs again and the row rebuilds. A route without a loader keeps its row.

To revalidate, navigate to where you already are without adding a history entry:

example.ts
await router.go(router.url, { replace: true })   // runs load() again

Starting the data with the chunk

A load exported by the page module can only run once that module has arrived, so a cold navigation fetches the chunk, then the data. To start both at once, declare the loader in the table with withLoad():

routes.ts
import { createRouter, withLoad } from 'nuclo-router'
import { loadPost } from './data.ts'   // small, in the main bundle

createRouter({
  '/blog/:slug': withLoad(() => import('./pages/Post.ts'), loadPost),
})

It replaces the module's own load and runs on every visit like it. A rejection while the chunk is still loading lands on router.error like any other.

There is no serialization of server data into the HTML, by design. The client's load runs again on hydration. If that matters, have the server emit route.data, for example as window.__DATA__, and the loader take it first. route.data is the matched page's data only. A parent's load() result is not on it.

Code splitting and preloading

A route's value is always a function that returns the page component, so the component is reached lazily:

routes.ts
'/a': () => import('./A.ts'),                      // default export (+ optional load)
'/b': () => import('./B.ts').then(m => m.BPage),   // named export
'/c': () => CPage,                                 // eager, not split
'/d': () => ({ default: DPage, load: loadD }),     // eager, module-shaped

Only the module-shaped forms can carry a load export. Picking a named component with .then(m => m.BPage) hands the router the function alone.

Each module is imported once and cached. Concurrent navigations to the same route share one import(). Once the user has left the page alone for idleTimeout (a second by default; no taps, keys, wheel or scroll), the remaining routes are loaded in the background, one per idle slice (requestIdleCallback). Any input, or a navigation the user is waiting for, pauses it until the page is idle again.

A preloaded or eager route never flips router.pending and lands before the next paint: no spinner frame, no layout shift. router.pending is only true for a module or loader that has to be waited for, and the outgoing page stays on screen while it is. A chunk that fails to load surfaces as router.error, never as an unhandled rejection, and can simply be retried.

Router API

#
fnPublic API
// import { ... } from 'nuclo-router' function createRouter(table: RouteTable, options?: RouterOptions): Router function withLoad(loader: RouteLoader, load: DataLoader): RouteLoader interface RouterOptions { base?: string; preload?: boolean; idleTimeout?: number; onNavigate?: (ctx: RouteContext, kind: NavigationKind) => void }

createRouter(table, options?)

table maps a pattern to a loader, or to a nested table. Options:

OptionDefault
base'/'URL prefix the app is served under, such as '/docs'. It only matches at a segment boundary: /docs/intro is inside '/docs', /docsearch is not. URLs outside it match nothing, so start() rejects (your server-side 404) and links to them are left to the browser.
preloadtrueLoad the remaining routes' modules in the background once the page is idle. false turns it off.
idleTimeout1000Milliseconds without taps, keys, wheel or scroll before preloading starts, or resumes after the user was busy.
onNavigate(ctx, kind) => void, called after every resolved navigation in the browser, including the initial one, once the DOM has caught up. kind is 'start', 'push', 'replace', 'pop' (Back, Forward, close()) or 'layer'. The hook for document.title, meta tags, focus, scrolling and analytics. Never called during SSR.

Returns the router: start(), match(), outlet(), matches(), and every Route member except run(), read through to the active Route. That is the one the last start() returned in the browser, or the one whose run() is executing on the server. Reading a member with neither throws. The route table, the compiled matcher and the module cache live on the router, so they are shared across requests on a server.

router.start(url?)

Resolves url (default: location.href in the browser) to its page modules and returns a Promise<Route>. Rejects when nothing matches and the table has no '*' route. On a server, that is your 404. In the browser it also makes the Route the router's active one, attaches the popstate and delegated click listeners and schedules preloading. A second start() retires the previous Route for you. On the server it touches nothing shared, so render inside route.run().

router.match(url?)

Resolves a URL against the table without loading anything: no import, no history, no listeners, no Route. Returns a RouteContext, or null when the URL is outside base, matches no pattern or, in the browser, is on another origin. Absolute URLs only, since ./ resolution needs an active route.

example.ts
// 404 before importing a single chunk
if (!router.match(request.url)) return new Response(null, { status: 404 })

// "is this link ours?" for a custom link component or a prefetch rule
const owned = router.match(href) !== null

router.outlet()

The active route's pages as a list(). Place it once, wherever the pages go. A pushed layer is a later row of it. It is empty before start() resolves and after stop(). On the server it renders the Route whose run() is executing.

router.matches(table)

Renders the view of a second table that the active route's path matches, as a list() you place anywhere. See matches().

Route

Every member but run() is also on the router, which is where an app reads it.

Member
path, pattern, params, search, hash, urlthe active match. search is a URLSearchParams. search, hash and url are live.
pendinga navigation is waiting for a module or a loader
errorthe last failed load, cleared by the next navigation
datawhat the matched page's load() returned, for a server to serialize. A parent's is not on it.
depthhow many layers are on the stack (1 for an ordinary page)
go(href, { replace })navigate, loading the module if needed. Never rejects: a module that fails, by rejecting or by throwing, lands on error.
push(href)open a route as a new layer on top, resolving with what it closes with. Accepts ./. Rejects on no match or a failed module.
href(path)prefix a path with base, for a({ href }). Resolves ./ and ../ against the active route.
stop()detach every listener, dismiss open layers, empty the outlet. Idempotent. A stopped Route still answers with its last match, and go() does nothing.
run(fn)call fn with this Route as the router's active one and return its result: route.run(() => renderToString(App)) on the server. Synchronous only. The Route stops being active when fn returns.

RouteContext

A page's first argument: path, pattern, params, search, hash, url. It is the context the page was built with. Read router.* for live values. They differ only when the query or hash changed without a rebuild.

params is { [name]: string } by default. A page can type it from its own pattern with Params, which turns '/blog/:slug' into { slug: string } and '/files/*rest' into { rest: string }:

pages/Post.ts
import type { Params, RouteContext } from 'nuclo-router'

export default function Post(ctx: RouteContext<Params<'/blog/:slug'>>) {
  return article(h1(ctx.params.slug))   // slug: string, nothing else exists
}

PageProps

A page's second argument. Destructure what the page needs.

Prop
layerwhere the page sits in the stack. See Layer below.
datawhat the route's load() returned, or undefined without one. Typed by PageProps<TData>.
outletwhere a parent page renders its child route. See Nested Routes.
beforeLeaveregisters a guard asked before this page is discarded. See Leave guards.

Layer

Where a page sits in the stack, and its way out.

Member
depth0 for the base page, 1 and up for pushed layers
push(href)open another layer from inside a page, same as Route.push()
close(result?)close this layer and everything above it, resolving the push() that opened it. No argument means dismissed. A no-op at depth 0.

DataLoader and withLoad()

DataLoader is (ctx: RouteContext) => TData | Promise<TData>, a page module's optional load export. withLoad(loader, load) makes a route whose load starts beside its chunk's import instead of after it. See Route Loaders.

Types

Every type is a named export of nuclo-router, so a page, a loader or a guard can be typed without importing from a path.

TypeWhat it is
Params<'/blog/:slug'>the params object for a pattern. RouteParams is the untyped shape, and Match is the matcher's result.
PageComponenta page: (ctx, { layer, data, outlet, beforeLeave }) => …
RouteModulea page module: the component itself, or { default, load? } as import() yields
RouteLoader() => RouteModule | Promise<RouteModule>, optionally carrying a load from withLoad()
RouteTablethe table createRouter() takes: pattern to loader, or to a nested table
RouterOptions{ base?, preload?, idleTimeout?, onNavigate? }
NavigateOptions{ replace? }, the second argument of go()
NavigationKind'start' | 'push' | 'replace' | 'pop' | 'layer', the second argument of onNavigate
Outletthe type of outlet() from PageProps
LeaveGuard(to) => boolean | void | Promise<…>, what beforeLeave() takes
MatchViewwhat a matches() view is: params in, content or import() of a page out

Layers & Leave Guards

#

push() opens a route on top of the current one instead of replacing it, and resolves with whatever that layer closes with. Because the pages are a single list() over the stack, opening a layer is an append. The page underneath is not re-rendered, so its DOM, its focus and its half-filled form are all still there when the layer closes.

That makes the "I need a record that doesn't exist yet" flow work without throwing away the form the user was filling in:

pages/Edit.ts
// the page underneath
const created = await layer.push<Option>('/options/new')
if (created) { options.push(created); selected = created.id; update() }
pages/NewOption.ts
// the pushed page: (ctx, { layer })
layer.close(created)   // resolves the push() above with the new option
layer.close()          // dismissed: resolves with undefined

Layers nest. A dialog that needs its own dialog calls layer.push() and awaits it, with no reference to the Route.

  • The URL is the top layer. push() adds a history entry, so a layer is linkable and Back closes it, resolving its push() as dismissed, exactly like Cancel. Back and close() are one code path: close() records its result and walks history back, and the depth in history.state is the authority. Browsers keep about 50 entries a tab, so after a long walk through layers the entry beneath may be gone: then close() closes the layers in place and rewrites the current entry to the page left showing.
  • An ordinary navigation clears the stack. A link click or go() is not a layer. It replaces everything and dismisses every open layer, so no caller is left awaiting.
  • A cold load is a standalone page. The URL names the top layer but not the stack beneath it, so opening a layer's URL directly renders it at depth 1 with layer.depth === 0 and an inert close(). A pushed route has to make sense on its own. Branch on layer.depth to offer a link out instead of a dead Cancel button.
  • The promise cannot survive a reload, because it lives in a closure. Treat the result as a shortcut through a flow that also works the long way round.
  • Nothing is left awaiting. stop() dismisses every open layer, and a layer abandoned by a navigation resolves with undefined.

Leave guards

A page that holds something worth keeping, such as a half-filled form, registers a guard. It is asked before the page is discarded, and returning false (or a promise of it) keeps the page:

pages/Edit.ts
import type { PageProps, RouteContext } from 'nuclo-router'

export default function Edit(_ctx: RouteContext, { beforeLeave }: PageProps) {
  let dirty = false
  beforeLeave(() => !dirty || confirm('Discard your changes?'))
  return form(input({ onInput: () => (dirty = true) }), …)
}
  • Only when the page goes. A child navigation under a layout, or a query or hash change that keeps the page, does not ask. A guard lives and dies with its page, so there is nothing to unregister.
  • Every way out. A link, go(), Back and Forward, a layer's close(), and go() to a URL the router does not own (leaving the app, where to is null). A refused Back or Forward is walked back, so the address bar stays on the page. Closing the tab or reloading is beforeunload's job.
  • to is the RouteContext the navigation is heading to. Guards run innermost first and stop at the first refusal. One that throws keeps the page. close(result) goes through the guard too, so clear your flag before closing with a result.
  • The router can only walk back history entries it wrote. A Back or Forward onto one it did not, such as a native #fragment link or another script's pushState, is not asked.

matches() & Sub-routers

#

router.outlet() renders the page. router.matches(table) renders something else that depends on where you are, such as a sidebar, breadcrumbs or a toolbar, from its own small table, without loading a route or adding a page:

app.ts
div(
  aside(
    router.matches({
      '/':           () => nav('Home links'),
      '/blog/new':   () => nav('Drafting tips'),
      '/blog/:slug': ({ slug }) => nav(`More like ${slug}`),
      '*':           () => import('./Sidebar.ts'),   // code-split, rendered once loaded
    }),
  ),
  main(router.outlet()),
)
  • Same matching as the route table. The patterns, precedence, raw-path matching and base stripping are the router's own, so a view and a page always agree about the URL. It reads the top layer's path, like router.path.
  • A view is (params, ctx) => content, where params is typed from its pattern and ctx is the RouteContext with the view's pattern and params. It may instead return import() of a page module, which is called with ctx once it lands.
  • Nothing matches, nothing renders. Add a '*' view for a fallback. It is empty before start() and after stop(), like the outlet.
  • It re-renders only when the route changes, so a view keeps its DOM across anything that keeps the page.
  • A lazy view renders nothing until its module arrives. A failed import() is logged with console.error, does not set router.error, and is retried on the next navigation. A plain update() does not retry.
  • On the server a lazy view ships nothing, since its module lands on the client. Use a synchronous view for anything the first HTML must contain.

Sub-routers

Routes nest, so a sub-router is usually just a nested table. One router: the section's own '/' page is its chrome, and its children render in that page's outlet(), so the chrome survives navigation within the section:

routes.ts
createRouter({ '/': …, '/docs': docsRoutes, '*': … })

Or give the feature a router of its own, created but never started. The parent owns a single catch-all entry and the child resolves the tail against its own patterns, params and fallback:

routes.ts
// parent table
'/docs/*rest': () => import('./docs/Shell.ts'),
docs/Shell.ts
const docs = createRouter(docsRoutes, { base: '/docs' })
const hit = docs.match()                       // { pattern: '/:topic', … }
const page = await docsRoutes[hit.pattern]()   // the app's own lookup

Never starting it is the point. start() is what attaches the listeners, and two started routers conflict. The trade is that the child owns resolution, so route loaders, which a started router runs, are yours to call. This shape also rebuilds the shell when the tail changes, because the router sees one route and the shell resolves the rest itself. A nested table is what gets you a parent the router keeps mounted, so prefer it unless the feature really needs its own router, base and fallback.

Multiple Routers & Combined Loading

#

An app can have as many routers as it has areas. The rule that keeps this simple: start exactly one per document, and let every other router stay unstarted, resolving URLs with match(). Only the started router owns the URL, the history and the click listener. The rest are tables you ask questions of — and each area can load on its own clock, with its own spinner.

Combined loading: a chain of loaders

Every page on a matched chain may export load(). On a cold navigation to a child route, the layout's loader and the page's loader run in parallel, and one router.pending covers the whole chain: the previous page stays on screen until the pair resolves, so a half-loaded tree never paints. Each component receives its own data:

routes.ts
'/shop': {
  '/':          () => import('./pages/Shop.ts'),    // layout, has a load()
  './:section': () => import('./pages/Section.ts'), // page, has a load()
},
pages/Shop.ts
import type { DataLoader, PageProps, RouteContext } from 'nuclo-router'

export const load: DataLoader<ShopData> = async () => {
  await wait(500)                              // a slow API
  return { sections: ['toys', 'books', 'music'] }
}

// the layout gets its own data, and its outlet for the child page
export default function Shop(_ctx: RouteContext, { data, outlet }: PageProps<ShopData>) {
  return div(
    h1('Shop'),
    nav(...data.sections.map((s) => a({ href: router.href(`/shop/${s}`) }, s))),
    main(outlet()),
  )
}
pages/Section.ts
export const load: DataLoader<SectionData> = async (ctx) => {
  await wait(900)                              // slower than the layout's
  return { section: ctx.params.section, items: catalog[ctx.params.section] }
}

// the page gets its own data: the two loaders ran beside each other
export default function Section(_ctx: RouteContext, { data }: PageProps<SectionData>) {
  return article(h2(data.section), ul(...data.items.map((i) => li(i))))
}

Both loaders start together, not one after the other. router.pending is true while either one is awaited, and false the rest of the time — a preloaded or eager route never flips it. Moving between sections keeps the layout, so only the section's loader runs again; the layout's data, its DOM and a half-typed input all survive. Re-navigating to the same URL re-runs the leaf's loader and rebuilds its row, exactly as with a single page.

Multiple loading: a second router with its own spinner

An independent area — a sidebar, a tray, a live region — gets a router of its own, created but never started, and its own loading flag. router.pending belongs to the started router and says nothing about this area, so the two can load side by side and either can finish first:

sidebar.ts
import { createRouter } from 'nuclo-router'

// Created but never started: start() is what attaches the history and click
// listeners, and only the main router is started. match() attaches nothing.
const standIn = () => ({ default: () => div() })   // never rendered

const sidebarRouter = createRouter({
  '/shop':          standIn,
  '/shop/:section': standIn,
  '*':              standIn,
}, { base: '/docs' })              // the same base as the main router

export const sidebar = { loading: false, info: null as SidebarInfo | null }

export async function refreshSidebar(at: string) {
  const hit = sidebarRouter.match(at)          // the second router resolves
  if (!hit || (hit.pattern !== '/shop' && hit.pattern !== '/shop/:section')) {
    sidebar.info = null; update(); return
  }
  sidebar.loading = true                       // its own flag, its own spinner
  sidebar.info = null; update()
  const data = await fetchSidebar(hit.params.section)
  sidebar.loading = false; sidebar.info = data
  update()
}
routes.ts
createRouter(routes, {
  onNavigate: (ctx, kind) => {
    announce(kind)
    refreshSidebar(ctx.url)   // every navigation re-resolves the sidebar
  },
})

The second router is asked, never told: match() reads the URL and returns a context, and nothing about the sidebar ever enters the main route table. onNavigate runs after every resolved navigation, the initial one included, so it is the one place that keeps an independent area in step with the URL.

  • pending belongs to the started router. A never-started router has no Route, no pending and no error. The area tracks its own flags, and a second spinner can sit next to the first. In the demo the sidebar loads for 800ms while the route's loaders run for 900ms, so the page can finish before the sidebar or the other way round.
  • match() attaches nothing. Any number of unstarted routers may share the document; each has its own table, base and module cache, and none of them touch history. They cost a table and a matcher.
  • Its loaders never run. The started router runs the load() of the pages it matches. A never-started router only resolves, so if its views need data, fetch it yourself — the demo's refreshSidebar(). If you want loaders, a nested table or a region a page fills keeps that job with the started router.
  • Guard stale replies. A slow sidebar fetch must not land on the section you navigated away from. Bump a generation counter per refresh and drop replies that no longer match, the way the router itself retires overtaken navigations.
  • A lazy matches() view has no pending either. It renders nothing until its module lands. Three loading systems, three independent clocks: the route's pending, your own flags, and a lazy view that simply is not there yet.

Computed Values

#

Nuclo has no computed API. Use a plain function, and call it from dynamic values:

example.ts
let items = ['apple', 'banana', 'cherry']
let filter = ''

const visible = () => items.filter((item) => item.includes(filter))

const Fruits = () =>
  div(
    p(() => `Showing ${visible().length} of ${items.length}`),
    ul(list(visible, (item) => li(item))),
  )

filter = 'an'
update()

Async & Loading

#

Use plain async/await, and call update() each time the state changes. For a fetch, that means once before the await and once after:

example.ts
let status: 'idle' | 'loading' | 'done' | 'error' = 'idle'
let result: unknown = null

async function loadData() {
  status = 'loading'
  update()
  try {
    result = await fetch('/api/data').then((r) => r.json())
    status = 'done'
  } catch {
    status = 'error'
  }
  update()
}

Best Practices

#
  • Batch changes. Change all the state you need, then call update() once.
  • Keep dynamic values fast and pure. All of them run on every update().
  • Use scope() for small, frequent updates, such as typing or timers.
  • Render into the document. Nodes that are not in the document when update() runs stop updating.
  • Use when() and list() for children that change, not a function that returns nodes.
  • Use className, not class, and use () => cx(...) or { className: () => … } for dynamic classes.
  • Define styles once, at module level.
  • Server rendering: render first, then put getCssText() in <style id="nuclo-styles">. Pass request data as arguments, and keep HTML comments in the output.

API Index

#

Every public function, and where it comes from.

nuclo (global and named export)

nuclo (named export only)

nuclo/ssr

  • renderToString(input), renderManyToString(inputs), renderToStringWithContainer(input, tag?, attrs?) and getCssText().

nuclo/polyfill

  • A side-effect import, plus document, Event, CustomEvent, Node, Element, HTMLElement and the Nuclo* classes.

nuclo-router (separate package)

Globals only

  • 175 tag builders: div, span, …, var_, and svgSvg, pathSvg, ….
  • Types: NodeModFn, NodeModLike, ExpandedElement and more. Styling types such as StyleResult can also be imported from 'nuclo'.

Special keys

Frequently Asked Questions

#

Does changing state update the UI?

No. The DOM stays as it is until you call update().

Does Nuclo use signals, proxies or a virtual DOM?

No. State stays plain JavaScript. When a tree is rendered, Nuclo creates real elements and text nodes. update() runs every registered dynamic value, or those inside the given scopes, and patches those same nodes where a value changed. There is no dependency graph and no virtual tree.

Why does my page show "false"?

Booleans render as text. div(ok && span('Done')) shows "false". Use when(() => ok, span('Done')) for children, and ok ? 'Done' : '' for text.

Why doesn't my list row change when I edit the item?

Rows for items that are still in the list are kept, not rendered again. Use dynamic values such as () => todo.text for fields that change. See list().

Why does part of my UI stop updating?

Usually its nodes were not in the document when update() ran. Nuclo stops tracking those nodes, even after you attach them, so render into a container that is in the document. Two other causes: a when() whose condition throws stops updating, and a text function that throws on its first run stays empty. See update().

When should I use forceUpdate()?

Only for rare changes to static values, such as switching the language. For everything else, use update(). See forceUpdate().

How do I put a page into a layout without passing it down as a prop?

Mark the place with region({ id: 'main' }) and have the page return into('main', …). See region() & into().

How do I unmount an app?

Wrap it in when() and switch the condition off. Removing the DOM yourself does not run onDestroy. See Lifecycle.

Does Nuclo include a router?

Not in the core package. Routing ships separately as nuclo-router, with server rendering, hydration, code-split routes and idle preloading. Install it with npm install nuclo nuclo-router. See nuclo-router.

Does Nuclo have effects?

No. onMount and onDestroy run once per element, tied to insertion and removal. Nothing runs again because some state changed.