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.
- 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.
import { button, component, div, output } from '@pfern/elements'
export const counter = component((count = 0) =>
div(
output(count),
button({ onclick: () => counter(count + 1) },
'Increment')))npm install @pfern/elementsnpm install @pfern/elements @pfern/elements-x3domnpx @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.
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) }, '✕'))))))
})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())))))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.
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)
- 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.
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 }]) })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.
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.
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 }.
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.
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 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.
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-x3domimport { 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()))))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).
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.
Wrap a recursive pure function that returns a vnode.
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.
All tag helpers are also exported in a map for dynamic use:
import { elements } from '@pfern/elements'
const { div, button } = elementsEvery 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 })Register a handler to run after popstate (including calls to navigate()).
Use this to re-render your app on URL changes.
Serialize a vnode tree to HTML (SSG / SSR). Pass { doctype: true } to emit
<!doctype html>.
navigate updates window.history and dispatches a popstate event. It is a
tiny convenience for router-style apps.
Tests run in Node and use a small in-repo fake DOM for behavioral DOM checks.
See packages/elements/test/README.md.
MIT License Copyright (c) 2026 Paul Fernandez
This repository is a monorepo:
@pfern/elementslives inpackages/elements@pfern/elements-x3domlives inpackages/elements-x3dom
The root package.json provides convenience scripts that proxy into each
package workspace.
npm test
npm run -s test:coverage
npm run -s typecheck
npm run -s build:types
npm run -s x3dom:test
npm run -s x3dom:test:coverage
npm run -s x3dom:typecheckCI fails on high+critical vulnerabilities in production dependencies:
npm audit --omit=dev --audit-level=highCI also prints the full npm audit report (including dev dependencies) as a
non-blocking log to aid triage.
To refresh upstream X3DOM docs for type generation after updating vendor bundles (manual step; requires network access):
npm run -s fetch:x3dom-src