npm package: @pfern/elements
Description: A minimalist, pure functional declarative UI toolkit.
Author: Paul Fernandez
Latest version: 0.2.1

<!-- Generated by scripts/sync-readmes.js. Source: README.md -->

# @pfern/elements

A functional, stateless UI toolkit for composing reactive web pages.

Elements.js borrows the simple elegance of functional UI composition from
[React](https://react.dev/), distilled to its purest form:

- No JSX.
- No hooks or keys.
- No virtual DOM heuristics.

Components are pure functions; updates are just calling the function again with
new arguments.

While you may choose to manage application logic with tools like
[Redux](https://redux.js.org/) or [Zustand](https://github.com/pmndrs/zustand),
Elements.js keeps _UI state_ exactly where it belongs: in the [DOM][dom] itself.

## Principles

- **Pure data model:** UI elements are represented as data-in, data-out
  functions. They accept W3C standard `props` and child elements as arguments, and
  return nested arrays.
- **Dynamic updates:** When an event handler returns the output of a component
  element defined within its scope, the element is updated with its new
  arguments.
- **Imperative boundary, functional surface:** DOM mutation is abstracted away,
  keeping the authoring experience functional and composable.

### Example: Recursive counter
```js
import { button, component, div, output } from '@pfern/elements'

export const counter = component((count = 0) =>
  div(
    output(count),
    button({ onclick: () => counter(count + 1) },
           'Increment')))
```

## Quick Start

### Install as a dependency
```sh
npm install @pfern/elements
```

### Optional 3D / X3DOM helpers

```sh
npm install @pfern/elements @pfern/elements-x3dom
```

### Install as a minimal starter app
```sh
npx @pfern/create-elements my-app
cd my-app
npm install
```

Source code for the examples on this page can be found in the
[examples/](https://github.com/pfernandez/elements/tree/main/examples) directory of this repository, which are hosted as a live
demo [here](https://pfernandez.github.io/elements). The starter app also
includes examples as well as simple URL router for page navigation.

## Example: Todos App
```js
import { button, component, div, form, input, li, span, ul }
  from '@pfern/elements'

const demoItems = [{ value: 'Add my first todo', done: true },
                   { value: 'Install elements.js', done: false }]

export const todos = component(
  (items = demoItems) => {
    const add = ({ todo: { value } }) =>
      value && todos([...items, { value, done: false }])

    const remove = item =>
      todos(items.filter(i => i !== item))

    const toggle = item =>
      todos(items.map(i => i === item ? { ...i, done: !item.done } : i))

    return (
      div({ class: 'todos' },
          form({ onsubmit: add },
               input({ name: 'todo', placeholder: 'What needs doing?' }),
               button({ type: 'submit' }, 'Add')),

          ul(...items.map(item =>
            li({ style:
                { 'text-decoration': item.done ? 'line-through' : 'none' } },
               span({ onclick: () => toggle(item) }, item.value),
               button({ onclick: () => remove(item) }, '✕'))))))
  })
```

## Root Rendering Shortcut

If you use `html`, `head`, or `body` as the top-level tag, `render()` will
automatically mount into the corresponding document element—no need to pass a
container.

```js
import { body, h1, h2, head, header, html,
         link, main, meta, render, section, title } from '@pfern/elements'
import { todos } from './components/todos.js'

render(
  html(
    head(
      title('Elements.js'),
      meta({ name: 'viewport',
             content: 'width=device-width, initial-scale=1.0' }),
      link({ rel: 'stylesheet', href: 'css/style.css' })),
    body(
      header(h1('Elements.js Demo')),
      main(
        section(
          h2('Todos'),
          todos())))))
```

## How Updates Work

Elements.js is designed so you typically call `render()` once at startup (see
`examples/index.js`). After that, updates happen by returning a vnode from an
event handler.

### What is a vnode?

Elements.js represents UI as plain arrays called **vnodes** (virtual nodes):

```js
['div', { class: 'box' }, 'hello', ['span', {}, 'world']]
```

- `tag`: a string tag name (or `'fragment'` for a wrapper-less group)
- `props`: an object (attributes, events, and Elements.js hooks like `ontick`)
- `children`: strings/numbers/vnodes (and optionally `null`/`undefined` slots)


### Declarative Events

- Any event handler (e.g. `onclick`, `onsubmit`, `oninput`) may return a vnode
  array to trigger a replacement.
- If the handler returns `undefined` (or any non-vnode value), the event is
  passive and the DOM is left alone.
- Returned vnodes are applied at the closest component boundary.
- If you return a vnode from an `<a href>` `onclick` handler, Elements.js
  prevents default navigation for unmodified left-clicks.

Errors are not swallowed: thrown errors and rejected Promises propagate.

### Form Events

For `onsubmit`, `oninput`, and `onchange`, Elements.js provides a special
signature:

```js
(event.target.elements, event)
```

That is, your handler receives:

1. `elements`: the HTML form’s named inputs
2. `event`: the original DOM event object

Elements.js will automatically call `event.preventDefault()` *only if* your
handler returns a vnode.

```js
form({ onsubmit: ({ todo: { value } }, e) =>
       value && todos([...items, { value, done: false }]) })
```

### Routing (optional)

For SPAs, register a URL-change handler once:

```js
import { onNavigate } from '@pfern/elements'

onNavigate(() => App())
```

With a handler registered, `a({ href: '/path' }, ...)` intercepts unmodified
left-clicks for same-origin links and uses the History API instead of
reloading the page.

You can also call `navigate('/path')` directly.

### SSG / SSR

For build-time prerendering (static site generation) or server-side rendering,
Elements.js can serialize vnodes to HTML:

```js
import { div, html, head, body, title, toHtmlString } from '@pfern/elements'

toHtmlString(div('Hello')) // => <div>Hello</div>

const doc = html(
              head(title('My page')),
              body(div('Hello')))

const htmlText = toHtmlString(doc, { doctype: true })
```

Notes:
- Event handlers (function props like `onclick`) are dropped during
  serialization.
- `innerHTML` is treated as an explicit escape hatch and is inserted verbatim.

### Explicit Rerenders

Calling `render(vtree, container)` again is supported (diff + patch). This is
useful for explicit rerenders (e.g. dev reload, external state updates).

To force a full remount (discarding existing DOM state), pass
`{ replace: true }`.

### Why Replacement (No Keys)

Replacement updates keep the model simple:

- You never have to maintain key stability.
- Identity is the closest component boundary.
- The DOM remains the single source of truth for UI state.

## Props

Element functions accept a single props object as first argument:

```js
div({ id: 'x', class: 'box' }, 'hello')
```

In the DOM runtime:

- Most props are assigned via `setAttribute`.
- A small set of keys are treated as property exceptions when the property
  exists on the element.
- Omitting a prop in a subsequent update clears it from the element.

This keeps updates symmetric and predictable.

## `ontick` (animation hook)

`ontick` is a hook (not a DOM event) that runs once per animation frame. It can
thread context across frames:

```js
transform({
  ontick: (el, ctx = { rotation: 0 }, dt) => {
    el.setAttribute('rotation', `0 1 1 ${ctx.rotation}`)
    return { ...ctx, rotation: ctx.rotation + 0.001 * dt }
  }
})
```

`ontick` must be synchronous. If it throws (or returns a Promise), ticking
stops, and the error is not swallowed.

If the element is inside an `<x3d>` scene, Elements.js waits for the X3DOM
runtime to be ready before ticking.

## X3D / X3DOM (experimental)

The optional `@pfern/elements-x3dom` package includes elements for X3DOM’s
supported X3D node set. You can import them and create 3D scenes
declaratively:

```sh
npm i @pfern/elements @pfern/elements-x3dom
```

### Demo: Interactive 3D Cube
```js
import { appearance, box, material, scene,
         shape, transform, viewpoint, x3d } from '@pfern/elements-x3dom'

export const cubeScene = () =>
  x3d(
    scene(
      viewpoint({ position: '0 0 6', description: 'Default View' }),
      transform({ rotation: '0 1 0 0.5' },
        shape(
          appearance(
            material({ diffuseColor: '0.2 0.6 1.0' })),
          box()))))
```

### Lazy Loading

X3DOM is lazy-loaded the first time you call any helper from
`@pfern/elements-x3dom`. For correctness and stability, it always loads the
vendored `x3dom-full` bundle (plus `x3dom.css`).

## Types (the docs)

Elements.js is JS-first: TypeScript is not required at runtime. This package
ships `.d.ts` files so editors like VSCode can provide rich inline docs and
autocomplete.

The goal is for type definitions to be the canonical reference for:

* HTML/SVG/X3D element helpers
* DOM events (including the special form-event signature)
* Elements.js-specific prop conventions like `ontick`, plus supported
  prop shorthands like `style` (object) and `innerHTML` (escape hatch)

Most props are assigned as attributes. A small set of keys are treated as
property exceptions (when the property exists on the element): `value`,
`checked`, `selected`, `disabled`, `multiple`, `muted`, `volume`,
`currentTime`, `playbackRate`, `open`, `indeterminate`.

Omitting a prop in a subsequent update clears it from the element.

## API

### `component(fn)`

Wrap a recursive pure function that returns a vnode.

### `render(vnode[, container])`

Render a vnode into the DOM. If `vnode[0]` is `html`, `head`, or `body`, no
`container` is required.

Pass `{ replace: true }` to force a full remount.

### `elements`

All tag helpers are also exported in a map for dynamic use:

```js
import { elements } from '@pfern/elements'

const { div, button } = elements
```

### DOM Elements

Every HTML and SVG tag is available as a function:

```js
div({ id: 'box' }, 'hello')
svg({ width: 100 }, circle({ r: 10 }))
```

Curated MathML helpers are available as a separate entrypoint:

```js
import { apply, ci, csymbol, math } from '@pfern/elements/mathml'

math(
  apply(csymbol({ cd: 'ski' }, 'app'), ci('f'), ci('x'))
)
```

For X3D / X3DOM nodes, use `@pfern/elements-x3dom`:

```js
import { box } from '@pfern/elements-x3dom'

box({ size: '2 2 2', solid: true })
```

### `onNavigate(fn[, options])`

Register a handler to run after `popstate` (including calls to `navigate()`).
Use this to re-render your app on URL changes.

### `toHtmlString(vnode[, options])`

Serialize a vnode tree to HTML (SSG / SSR). Pass `{ doctype: true }` to emit
`<!doctype html>`.

### `navigate(path[, options])`

`navigate` updates `window.history` and dispatches a `popstate` event. It is a
tiny convenience for router-style apps.

### Testing Philosophy

Tests run in Node and use a small in-repo fake DOM for behavioral DOM checks.
See [`packages/elements/test/README.md`](https://github.com/pfernandez/elements/blob/main/packages/elements/test/README.md).

## License

MIT License
Copyright (c) 2026 Paul Fernandez

[dom]: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model