npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pfern/elements

v0.2.1

Published

A minimalist, pure functional declarative UI toolkit.

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 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

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/elements

Optional 3D / X3DOM helpers

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

Install as a minimal starter app

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/ 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 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:

(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.

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.
  • 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:

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-x3dom

Demo: 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 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:

import { elements } from '@pfern/elements'

const { div, button } = elements

DOM 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