@pfern/elements
v0.2.1
Published
A minimalist, pure functional declarative UI toolkit.
Maintainers
Readme
@pfern/elements
A functional, stateless UI toolkit for composing reactive web pages.
Elements.js borrows the simple elegance of functional UI composition from React, 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 or Zustand, Elements.js keeps UI state exactly where it belongs: in the DOM itself.
Principles
- Pure data model: UI elements are represented as data-in, data-out
functions. They accept W3C standard
propsand 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
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
npm install @pfern/elementsOptional 3D / X3DOM helpers
npm install @pfern/elements @pfern/elements-x3domInstall as a minimal starter app
npx @pfern/create-elements my-app
cd my-app
npm installSource code for the examples on this page can be found in the examples/ directory of this repository, which are hosted as a live demo here. The starter app also includes examples as well as simple URL router for page navigation.
Example: Todos App
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.
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):
['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 likeontick)children: strings/numbers/vnodes (and optionallynull/undefinedslots)
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>onclickhandler, 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:
(event.target.elements, event)That is, your handler receives:
elements: the HTML form’s named inputsevent: the original DOM event object
Elements.js will automatically call event.preventDefault() only if your
handler returns a vnode.
form({ onsubmit: ({ todo: { value } }, e) =>
value && todos([...items, { value, done: false }]) })Routing (optional)
For SPAs, register a URL-change handler once:
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:
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. innerHTMLis 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:
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:
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:
npm i @pfern/elements @pfern/elements-x3domDemo: Interactive 3D Cube
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 likestyle(object) andinnerHTML(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:
import { elements } from '@pfern/elements'
const { div, button } = elementsDOM Elements
Every HTML and SVG tag is available as a function:
div({ id: 'box' }, 'hello')
svg({ width: 100 }, circle({ r: 10 }))Curated MathML helpers are available as a separate entrypoint:
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:
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.
License
MIT License Copyright (c) 2026 Paul Fernandez
