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

domeleon

v0.6.6

Published

Build web UIs with classes encapsulating your domain model and a pluggable Preact/React/Vue VDOM.

Readme

Domeleon

Build web UIs with classes encapsulating your domain model and a pluggable Preact/React/Vue VDOM.

Installation

npm

npm install domeleon

Topics

Features

  • Pluggable VDOM rendering engines:
    • Preact (default)
    • React
    • Vue
  • Modular add-ons:
    • unocss for succinct css w/ a type safe theme manager
    • zod for validation
    • maskito for masked inputs
    • inspector to monitor your component's state & updates
  • Core:
    • Serializer - serialize component state to the server or local storage
    • Router - composable router integrated with component model
    • Forms - ARIA, validation, structure uniform & friendly across varied CSS

Architecture

The heart of your Domeleon app is your object model: classes encapsulating the state of your domain model instead of wrapping DOM elements. So long as your classes imply a tree from their public writable properties, Domeleon can reason about that tree. Most notably, transforming that tree into a view or serializing it to the server or local storage.

Simplicity guides the architecture:

  • Views use functional style:
    • because views are simpler when stateless
  • Components use OO style:
    • because encapsulation simplifies reasoning about state
  • HTML/CSS use TypeScript (also works with Javascript):
    • because it's simpler to code in one high level language

First Code Sample

Here's a Counter component in Domeleon. It assumes an HTML page with <div id="app">.

  import { App, Component, div, button } from 'domeleon'

  class Counter extends Component {
    count = 0

    view() {
      return div(
        button({ onClick: () => this.add()}, '+'),
        this.count
      )
    }

    add() {
      this.count++
      this.update()
    }
  }

new App({ root: new Counter(), id: 'app' })

Application state lives in your components — in this case count.

Components optionally implement a view method, but since they represent your domain model, rather than a wrapper around a DOM element, you can have multiple views, e.g. summaryView or detailView. A view is a pure non-side-effecting function of the Component's state returning an immutable VElement.

Components update their state via their update method, which triggers a re-render of the root component's view. Features like databinding, routing, and serialization, all automatically call update for you, which you can monitor with domeleon's inspector module.

Examples / Real World Usage

Numerous examples here: https://typebulb.com

Typebulb runs apps in markdown files called bulbs, perfect for tools, visualizations & experiments. Many of the bulbs are written in domeleon, and typebulb.com itself is itself written in Domeleon too.

Components

Derive from the Component class to encapsulate part of your domain model. Then build out a tree of components to structure your application. Here, we build upon our introductory example, by composing two smaller components into a new component:

import { Component, div } from 'domeleon'
import { Counter } from './counter'

export class TwinCounters extends Component {
  counter1 = new Counter()
  counter2 = new Counter()

  view() {
    return div(
      this.counter1.view(), // Call child's view method
      this.counter2.view()
    )
  }
}

💡Component != DOM Element
A component encapsulates a piece of application state, not a DOM element.

You can also specify arrays of components. Here's the structure of a recursive tree component:

import { Component } from 'domeleon'

export class Tree extends Component
{    
  trees: Tree[] = []
  ...
}

Let's flesh this out:

  • add and remove components
  • enable serialization so it roundtrips & our component state hot-reloads
  • add a visualisation of each node.
import { Component, button, div, VElement, SerializerMap } from 'domeleon'

export class Tree extends SampleComponent {
  serializerMap: SerializerMap<SampleComponent> = { trees: [Tree] } // optional, for derialization
  trees: Tree[] = []

  add() {
    this.trees.push(new Tree())
    this.update()
  }

  remove() {
    this.trees.pop()
    this.update()
  }

  view(): VElement {
    return div({ style: { paddingLeft: `${this.ctx.rootToHere.length * 10}px` } },
      this.ctx.componentId,
      button({ onClick: () => this.add() }, "add"),
      this.trees.length ?
        button({ onClick: () => this.remove() }, "remove") :
        undefined,
      this.trees.map(c => c.view())
    )
  }
}

The serializerMap field enables our tree's children to be successfully deserialized. It's a simple map to deserialize the raw incoming json objects into classes.

💡Deep Dive: Every component is assigned an auto-incrementing unique componentId at construction, accessible via its ctx property. It's used by formField to automatically assign labels to inputs with unique ids, so you never need to. You rarely actually use it explicitly like we did in this example, but it's informative in understanding how domeleon works.

Component Initialization

Construction

Your component is simply a class instance, so its life begins with a constructor call.

onAttached

The onAttached method is called after your component has its ctx.app and ctx.parent property set.

   override onAttached () {
       // Perform setup, e.g.: // start subscriptions, fetch data
   }

The onAttached method is guaranteed to be called:

  • once per component attached to the component tree
  • synchronously, after an update (or app start) made it accessible from the root component
  • before the next render
  • after every component in the component tree for that update (or app start) has had its parent and child properties set
  • before onAttached is called on its children

Tip: The onNavigate method is often used in tandem with onAttached to build out your component model. It's particulary useful for asynchronously fetching data from the server upon navigation.

Updates

The primary function of update is to trigger a re-render of the app. As we saw earlier:

  add (x: number) {
    this.count += x
    this.update()
  }

Specifically, update triggers:

  • onUpdated on the component and each ancestor up to the root
  • a save, if autoPersist is enabled
  • a re-render of the app

By far the most expensive part of on update cycle is changes to the DOM. Since Domeleon runs atop state-of-the-art virtual DOMs, these operations are extremely fast and reliable.

Tip: Use the domeleon inspector to live view all updates to your component tree. It's helps understand the model.

Tip: In certain exceptional circumstances, such as a complex custom animations, postpone calling update. This is exactly how the Mandelbrot Explorer written with Domeleon works while zooming, as each calculation needs to run within 16ms! Domeleon lets you be pragmatic.

Views

Views are pure functions of component state: each component's view returns an extremely light-weight immutable VElement tree.

The app's chosen renderer takes this tree and translates it into the specific VDOM nodes (e.g., Preact, Vue, or React nodes) used by the underlying renderer, which then patches the VDOM.

view methods tend to be parameterless, as components are natural units of encapsulation: they simply render their own state. However, the world isn't perfect! In domeleon, you can add optional parameters to your child components' view methods. This can be useful when a child's view needs to render state that makes more sense to live on the parent

Critically, domeleon is model-centric, not view-centric. A domeleon component represents a piece of state, not a piece of HTML. A component might be viewless or have multiple methods returning different VElements. For example:

  summaryView () {
    return div (...)
  }

  detailView () {
   return div (...)
  }

HTML Helpers

Domeleon provides HTML helper functions (div, span, button, etc.) that create VElement objects. These helpers accept attribute objects followed by a spread of child elements or primitive values.

Examples:

import { div, button } from 'domeleon'

div()                                      // empty <div></div>
div("hello")                               // <div>hello</div>
div({ id: 'my-div' })                      // <div id="my-div"></div>
div({ id: 1, class: "foo" })               // <div id="1" class="foo"></div>
div({ id: 'main' }, "hello")               // <div id="main">hello</div>
div(div({ class: 'inner' }))               // <div><div class="inner"></div></div>
div({ id: 'combo' }, "hello", div("bye"))  // <div id="combo">hello<div>bye</div></div>

button({ onClick: () => console.log('clicked') }, "Click Me") // <button>Click Me</button>

Internally, div, button etc. just call the h function, passing in the element name.

class and style

Domeleon treats these properties specially:

  • class may be a string or string[]
  • class preprocesses utility classes (e.g. converts " " to "_")
  • style only takes an object, not a string
  • class and style properties are merged correctly when passing multiple attribute objects to an h function.

For example, the following 3 statements are all equivalent:

div({class: "x y" })
div({class: ["x", "y"] })
div({class: "x"}, { class: "y" }

Import mergeAttrs should you need to merge attribute objects outside the context of an h function.

Event Handlers

Event handlers are specified directly in attribute objects:

button({ onClick: () => this.add(1) }, "+")

JavaScript's this binding requires care. Follow these patterns within Domeleon components:

  1. Wrap callbacks in closures: To preserve the correct this context.
    // CORRECT - this will guarantee the correct `this` pointer reference
    button({ onClick: e => this.handleClick(e) }, "Click")
    
    // WRONG - this can lose the *this* pointer reference
    button({ onClick: this.handleClick }, "Click")

Domeleon always expects camelCase, from events through to aria attributes. Each vdom specific renderer automatically transforms the casing appropriately (e.g. to kebab-casing etc.).

onMounted callback

The vast majority of your code won't need to touch the DOM; you simply output vdom nodes, and let the renderer patch your changes automatically.

However, sometimes you need direct access to DOM elements. It might be useful for focus management, integrating 3rd-party libraries that operate on a DOM element, or drawing on a canvas.

We can use the onMounted event on an attribute, to obtain a reference to a DOM element:

import { Component, VElement, canvas } from 'domeleon'

export class Paint extends Component {
  view() {
    return canvas({
      width: 150,
      height: 150,
      onMounted: elm => {    
        const canvas = element as HTMLCanvasElement
      }
    })
  }
}

You can optional return a callback that is called when the element is unmounted.

DOM Keys

After each update, the virtual DOM is patched. The patcher compares the current virtual DOM tree to the previous one, and modifies the real DOM accordingly. However, the patching algorithm can't know your intent, and so occassionally does the wrong thing. It may try to reuse an element that you definitely want to replace, or it may try to replace a list of child elements that you merely wanted to reorder. To better declare your intent, provide keys for your virtual DOM nodes. For example:

div ({key: wizardPage})

If the key changes, the patcher now knows to definitely recreate that DOM element. This means even if your next wizard page happened to have an input that could have been updated, that instead it will be replaced, predictably resetting DOM state like focus and selections, and invoking any animations that should occur on element creation.

Forms

Domeleon lets you choose 4 increasing layers of abstraction when building forms, to suit your needs:

| Layer | Description | API | |-------|-------------|----------| | Raw Input | Thin wrappers around the h function returning a VElement | input, select | inputXXX | Functions that databind to your components | inputText, inputTextArea, inputSelect, inputRange, inputNumber (via maskito), inputMask (via maskito), inputCheckbox, inputRadioGroup, inputCheckboxGroup | | formField | Takes an inputXXX function and automatically outputs standardized vdom elements with: labelling/description, validation, aria attributes, id generation, structural compatibility with major css frameworks | formField | | formField wrapper | Your own helpers that wrap formField for consistent styling throughout your application. | myFormField

💡Note: By design, Domeleon never outputs css classes; this is entirely handled in your application layer. For example, you set the class on the validationAttrs property of formField; domeleon is 100% not prescriptive here.

Raw Input Layer (lowest h layer)

This is simply directly using the standard auto-generated h wrappers based on the HTML spec:

input ({id: "foo", value: "name": onInput: () => { ... } })

InputXXX Layer (databinding layer)

The following functions are abstraction of the raw input layer, to handle databinding for you:

  • inputText
  • inputTextArea
  • inputSelect
  • inputRange
  • inputNumber // maskito
  • inputMask // maskito
  • inputCheckbox
  • inputRadioGroup
  • inputCheckboxGroup

You can use these inputXxx functions directly, or you can merely pass the function into formField as the inputFn. Here's using one directly:

export class Form extends Component {
  country = `UA`

  view () {
    return inputSelect({
      target: this,
      prop: () => this.country,
      options: [
        {value: "UA", label: "Ukraine"},
        {value: "GE", label: "Georgia"}
      ]  
    })
  }
}

Every inputXxx function takes a target and prop value, to perform the databinding. Some inputXxx functions take additional arguments, such as inputSelect that takes an array of options.

formField

Here's how we could use an input function with formField. Note that in a typical app we'd probably write a reusable formField, to minimise boilerplate, as we'll see later.

import { Component, div } from 'domeleon';

export class Form extends Component {
  country = `UA`
  view () {
    return formField({
      target: this
      prop: () => this.country,
      inputFn: inputSelect,
      inputProps: {
        options: [
          {value: "UA", label: "Ukraine"},
          {value: "GE", label: "Georgia"}
        ] 
      }
    })
  }
}

The inputProps simply passes through the properties to inputFn.

So why use formField rather than just directly use an inputXxx function?

formField will automatically:

  • include a human readable label and/or description, either driven by the component's field name by default, or by the getLabel method if implemented on the component
  • asssign unique ids for the label and descriptions to work properly (using component.ctx.qualify(prop)).
  • output aria attributes
  • if validation is enabled, output validation errors
  • include a ubiqituous element structure compatible with all major css frameworks

Swapping from bootstrap to unocss? Your form fields stay the same: just update the css classes you feed them. formField helps you deeply separate the concerns of your form's data vs. presentation.

Writing a formField wrapper

It's common to write a wrapper for formField, for consistent styling across your app. Your usage can look like this:

  appFormField ({
    target: this,
    prop: () => this.country
  )

Where you've defined a reusuable appFormField. Here is where you can set every facet of the formField, to precisely control the styling.

type SupportedInputFn = typeof inputText | typeof inputNumber | typeof inputMask

const appFormField = (props: FormFieldInputProps<SupportedInputFn>) => 
  return formField({
    ...props,
    // common customization
    fieldAttrs: { class: styles.field }, // div wrapping entire field
    descriptionAttrs: { class: styles.description },
    validationAttrs: { class: styles.error } // e.g. red    
  })

formField always provides a unified structure, regardless of whether we're using text inputs, select inputs, or radio buttons:

  • target // the component to databind to
  • prop // the property on the component to databind to, expressed in a type safe manner
  • inputFn // the name of the input function, e.g. inputText or inputSelect
  • inputProps // the props the inputFn expects.

Labels and Descriptions

Default Labels

By default, formField automatically generates a label name by converting yourProperty to Your Property using the humanizeIdentifer function. It translates like this:

  • yourPropertyName -> Your Property Name

getLabels

If you need more control, or a more metadata driven approach, you can implement the getLabels function. This will automatically be picked up by formField when setting the label and description properties. Use as follows:

export class MySample extends Component {
  username: string
  bonus: number

  getLabels() {
    return {
      username: { label: "What's your name?", description: "Nick names are ok!" },
      bonus: "Annual Bonus"  
    }
  }

You can specify either just a string for label, or an object, if you wish to provide both a label and a description.

The description text by default appears below your input, and is swapped out by a validation message, if a validation error occurs.

Explicitly Setting label and description

For maximum control, explicitly set the label and description values directly when calling formField. Here you can set arbtirary HTML rather than just strings. For example:

    return formField({ 
      target: this,
      prop: () => this.subscribeToNewsletter, 
      inputFn: inputCheckbox,
      label: "Subscribe To Newsletter"
      description: span({/* styles here */}, "Exclusive content!")
    })

Validation

Domeleon can integrate with validation libraries like zod or class-validator. The dedicated domeleon/zod package provides out of the box support for zod. zod has a very nice chained model for incrementally refining permissable values.

Suppose we define this zod schema:

const schema = z.object({
  username : z.string().min(3).endsWith("x").max(10).regex(/^[a-z]+$/),
  rating   : z.number().min(0).max(10).optional()
})

We can use it as follows:

export class MySample extends SampleComponent implements IValidated, z.infer<typeof schema>
{
  validator: Validator = new ZodValidator(this, schema)

  username!: string
  rating?: number

  submit () {
    this.validator.validate()
    ...
  }

  override onUpdated(event: UpdateEvent) {   
    this.validator.revalidate(event)
  }

 view () {
   return div (
     formField({
      target: this
      prop: () => this.usernmae,
      inputFn: inputText
    }),
    button({ onClick: () => this.submit()})
    ... // define other formField etc.
  }
}

Let's break this down:

  • Validator is a domeleon base class that orchtestrates validating your components
    • Recursively validates your components by calling validateSync on each validator
    • Triggers onValidate on your component, to handle custom asynchronous validations
    • Collates all the validations per property, and maintains an overall validation state
  • ZodValidator implements validateSync to validate your component's properties with your Zod schema

The onUpdated override gives incremental feedback to the user, by revalidating the component, as they adjust their form.

Async Validation

We can also provide custom async validation via overriding the onValidate method. This is useful for asynchronous valdation, or validation that applies to the form as a whole, rather than a particular property.

For example:


  override async onValidate() {
    const result = await ...    // call server here
    if (this.username == ...) { // check if username is unique
      return []                 // no validation errors
    }
    return [{
      property: 'username',
      messages: ['Username is not unique'],
      value: this.username
    }]
  }

Your component will receive two validation update events, first a synchronous one, to immediately give feedback to your users, followed by the asynchronous one.

validator.state

A validator's state is of type ValidationState:

"unvalidated" | // yet to be valiated
"validating" |  // in the process of validation, no validation failed yet
"invalid" |     // at least one synchronous or asynchronous validation failed
"valid"         // determine valid, all async validatio complete

validator.state is especially highlighted in the inspector.

Unocss & Theming

You can use whatever CSS framework you want with Domeleon. However, Domeleon has a specific add-on for unocss that provides a theme manager that allows you to express type-safe, terse, css code.

Canonical Usage

For example, here's styling for a tab component:

const tabStyles = themeMgr.styles("tab", theme => {
  const { textPrimary, bgSecondary, bgAccent } = theme.colors

  return {
    active: `text-${textPrimary} border-b-${bgAccent} font-bold`,
    inactive: `text-${bgSecondary} border-b-transparent font-normal`
  }
})

Then use styles with guaranteed type safety as follows:

  view() {
    div ({class: tabStyles.active }, ...)
  }

Benefits

For most apps, using domeleon's unocss theme manager will save you time:

  • Effortlessly find or rename all usages of any specifc css variable, e.g. bgSecondary, throughout your entire app
    • Confidently delete unused styles
  • Class names are semantically named and scoped, such as app-tab-active:
    • Zero collisions, short, & human radable
    • The prefix, such as tab above, is optional. So themeMgr.styles(theme => { ...}) is also ok
  • Frictionless switching between custom themes, e.g. light vs dark
  • Choose whatever organisation keeps your css code maintainable:
    1. Colocate styles with components
    2. Keep styles in their own file

Setting Up a Theme

Here is how you set up a theme:

const lightTheme = {
  colors: {
    textPrimary: 'rgb(0, 100, 0)',
    bgPrimary: 'rgb(220, 220, 220)',
    pianoRoll: {
      barLine: '#808080',  // both rgb & hex OK
      barLabel: '#ffffff'
    }
  }
}

export const themeMgr = new UnoThemeManager({
  id: 'app',  
  themes: { light: new LightTheme() },
  unoCssConfig: { presets: [presetWind3()] }
})

Multiple Themes

It's easy to add multiple themes. Here's adding a dark theme:

const darkTheme = {
  colors = {
    textPrimary: 'rgb(0, 200, 0)',
    bgPrimary: 'rgb(50, 50, 50)',
    pianoRoll: {
      barLine: '#808080',
      barLabel: '#000000'
    }
  }
}

Where we now add both the light and dark themes to our theme manager:

  themes: {
    light: new LightTheme(),
    dark: new DarkTheme()
  }

Dynamically Changing Themes

You simply set the themeName of your theme manager whenever you want to change themes, at runtime. Or you can configure it based on your users preference as follows:

const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches  

export const themeMgr = new UnoThemeManager({
  ...  
  initialTheme: isDark ? 'dark' : 'light',
})

Imports and App Setup

You'll need to pick an unocss preset, then use the UnoThemeManager and ThemeProxy for domeleon, as follows:

import presetWind3 from '@unocss/preset-wind3'
import { UnoThemeManager, type ThemeProxy } from 'domeleon/unocss'

Make sure to specify your theme manager's cssAdapter in your app:

new App({
  ...
  cssAdapter: themeMgr.unoCssAdapter  
})

This guarantees any fresh class properties on your VElement (div, span etc.) are sent through to the adapter just before each VDOM patch, with no FOUC (Flash of Unstyled Content).

Bypassing UnoCss Shortcuts

themeMgr.styles internally leverages unocss shortcuts, which means you get clean, short, semantically named class properties in your css. You can however, directly use utility styles on class properties if you wish. You can even reference your theme manager's styles and it will work fine. For example:

  div({ class: `text-${bgSecondary} border-b-transparent font-normal`})

Global Styles

globalUnoCss

Sometimes you need to specify styles globally, such as wanting your links to look a particular way across your entire application. You define them with a globalUnoCss definition:

const globalUnoCss = (theme: ThemeProxy<MotifnTheme>) => {
  const { textPrimary, primaryHover, backgroundPrimary, textSecondary, } = theme.colors
  return {
    'a': `text-${textPrimary} no-underline`,
    'a:hover': `text-${primaryHover}`,
    'h1': `m-0 text-${textPrimary}`,
    'body': `m-0 bg-${backgroundPrimary} text-${textSecondary} font-sans antialiased`
  }
}

export const themeMgr = new UnoThemeManager({
  ...
  globalUnoCss
})

Be judicious with global styles; in most cases you don't need them.

globalRawCss

Sometimes you'll want to define raw css, not unocss utility strings. This is useful in certain cases, such as for keyframe animations. You define them with a globalRawCss definition:

const globalRawCss = (theme: ThemeProxy<MotifnTheme>) => {
  const { highlightAnimateStart, highlight } = theme.colors
  return `
    @keyframes nowStroke {
      0%   { stroke: ${highlightAnimateStart.css}; }
      100% { stroke: ${highlight.css}; }
    }
    @keyframes nowFill {
      0%   { fill: ${highlightAnimateStart.css}; }
      100% { fill: ${highlight.css}; }
    }
    .now { animation: nowStroke 0.5s linear; }
    .now-note-label { animation: nowFill 0.5s linear; }
  `
}

export const themeMgr = new UnoThemeManager({
  ...
  globalRawCss
})

Once these global styles are defined, you can refer to them into your ordinary unocss styles.

CssVar—Advanced Usage

You almost always use theme properties such as textPrimary within a string literal. This means its toString method is called, that simply returns its name, for unocss usage.

Its type is CssVar which has other properties that are occasionally useful if you need to work at lower layer, as we did when using globalRawCss. For example:

name:        textPrimary
cssVarName:  --app-colors-text-primary
css:         rgb(var(--app-colors-text-primary))
rawValue:    rgb(0, 200, 0)

Deep Dive: The proxy generated by the theme manager takes your raw string values and returns CssVar values.

Transparency / Alpha / Opacity

Always separate the alpha values from your colors. For example:

const lightTheme = {
  colors: {
    textPrimary: 'rgb(0, 100, 0)'    
  },
  // optional: “alphas”, “transparency”, etc.
  alphas: {
    panel: '0.5' // decimal-string between 0–1 (here, 50% opacity)
  }
}

Now, to make textPrimary 50% transparent, you have two options:

  • Option 1: Simply inline 50 with unocss; this is more common.
  • Option 2: Pass a theme variable to the alpha method of a color theme variable.

Both are shown below, yielding the same result:

const styles = themeMgr.styles("test", theme => {
  const { textPrimary } = theme.colors
  const { panel } = theme.alphas

  return {
    option1: `text-${textPrimary}}/50`
    option2: `text-${textPrimary.alpha(panel)}`...
  }
})

Using an alpha theme variable is useful if your transparency value diverges per theme.

Dynamic Styling

Domeleon's unocss add-on computes all styles ar runtime, not build time. Why?

  • The underlying unocss library is incredibly fast, even for the first render.
  • Even the presets are fairly small in kb.
  • No build step = ultra simple deployment.
  • Domeleon has deep integration, bypassing any FOUC issues.

Sticky Situation—Advanced Usage

One issue with dynamically generated styles, is occassionally you can build up too many styles in the head of your document. However, there's an escape hatch.

The UnoThemeManager class exposes its UnoCssAdapter:

  • By default its stickyMode is always, so classes always persist across renders.
  • If you set stickyMode to explicit then only classes passed through the stickyClass function perist across renders. Import and use stickyClass for special cases like animation, where you need to guarantee classes "stick" around.

You can also call the UnoCssAdapter's clearStickyClasses to force clear the sticky styles.

Routing

Domeleon provides a composable router, where each Component that's routed has its own router property, and routeSegment:

export class Help extends Component
{
  router: Router = new Router(this)  
  routeSegment = "help"
}

The Help component will now match the help route.

💡Route Tree Structure
The route structure mirrors your component tree structure (but skips transparent routes).

💡Inspect Routes
Domeleon's inspector especially highlights your route structure, and informs you of router update events.

Navigation

Use the router's navigate method with relative paths. The below assumes this is the Help component:

| Code | From | To | |------|-------------|------------| | this.router.navigate('') | /help | /help | | this.router.navigate('banana') | /help | /help/banana | | this.router.root.navigate('help/banana') | / | /help/banana | | this.router.parent.navigate('examples') | /help | /examples |

navigate takes an optional second argument action ('PUSH' | 'REPLACE', default 'PUSH'). Use 'REPLACE' to change the URL without adding a history entry — right for modal-like routes (e.g. a fullscreen toggle) where Back should leave the page, not replay the toggle.

Routes are path + query only: location.hash is never carried across navigations. This matches React Router and Vue Router; treat fragments as page-scoped state you set yourself after navigating.

Root router

Each router on your component tree also forms its own router tree. The root of that tree must define a transparent route (explained further below), meaning it has an empty routeSegment:

class Index {
  router: Router = new Router(this)
  routeSegment = ""
}

Note that you can define basePath, explained further below.

Intercepting Navigation

Implement your Component's' onNavigate for:

  • Validation/Prevention: (return false to prevent navigation)
  • Data Fetching: (await retrieving child components)
  • Redirection: (cancel, then navigate elsewhere)

Implement as follows:

async onNavigate (relativeRoute: Route) {
  if (child.firstSegment == ...) {
    // return false to cancel navigation
  }
  const child = await ... // fetch child
  this.serializer.deserialize ({ child: child})
}

You may also implement the synchronous onNavigated to react after a component becomes active in the route.

Route Class

The Route object provides a safe, structured view of a route. Access its segments or convenient firstSegment, that's simply segments[0].

Never engage in error-prone string manipulation of forward slashes to deal with paths! Use the Route class instead.

firstSegment is the most common property to use in onNavigate as it represents the the immediate relative path, that is intended to match a child component's routeSegment.

Cancelling/Guarding Navigation

Return false to cancel/gaurd a navigation; otherwise you don't need to return anything.

If the cancelled navigation came from the browser's Back/Forward button, domeleon restores the address bar to the current route (the browser has already moved it by the time your guard runs).

Preparing Child Routes

A common pattern is to fetch data on onNavigate, so that a child can be navigated to. Domeleon strictly requires an exact match based on the routeSegment, so dynamic routes require this pattern. Think of it as Just-In-Time routing.

There's no wildcard matching in Domeleon routes! Why? There must always be a component that represents the exact state of a given route. You can't navigate to "canteloupe" unless a component exists with the routeSegment with the value "canteloupe".

Navigation Links

The router has a link method that's very useful for creating <a href="..."> virtual DOM nodes. You simply provide a relative route (as a string|string[]|Route), and HValues. For example:

// Example: Creating a link to the 'banana' tab
this.router.link('banana', 'Go to Banana Tab')
// Renders an <a> tag with appropriate href and an `onClick` handler.

Transparent Routes

Transparent routes are incredibly useful as they relax the strict 1-1 relationship between the component tree and route tree. They allow a component purely to pass through the route. For example, Sidebar has a transparent route:

class Sidebar extends Component {  
  router: Router = new Router(this)
  routeSegment = ""

  help = new Help()
}

Transparent routers still receive onNavigate and onNavigated events. They just don't contribute to the path; think of it like parents who leave their kids with their grandparents.

RouteService

You can explicitly initialize the RouteService in the App construtor to set of a basePath. This will ensure your entire router setup works relatively to that path.

new App ({
   ...
   routeService: new RouteService { basePath: "admin"}
})

By default, a RouteService is created for you with an empty basePath.

Serialization

Domeleon's component tree is fully serializable, facilliating hot-reload via persisting to local storage, and transfering objects to and from the server.

ctx.keys and serializer.keys properties

Domeleon only cares about your Component's ctx.keys properties, which are:

  • public
    • not properties starting with _
  • read and write
    • not properties with only a getter
  • type is primitive, object, or class
    • not functions

You can get the exact set of properties that your component will serialize by calling your component's serializer.keys property:

const keys = this.serializer.keys

These are the same as ctx.keys, but further filtered by any key set to null in your component's serializerMap if you choose to set it.

Serialization API

Every Component has a serializer:

class Component {
  serializer: Serializer
  ...
}

The Serializer exposes the twin methods serialize & deserialize, used as follows:

// turn any component into a JSON object
const snapshot = this.serializer.serialize()

// …later – deserialize the JSON object into our component
this.serializer.deserialize(snapshot)

Both work recursively.

App.autoPersist

When an App is initialized with autoPersist: true, domeleon automatically saves after each update, and on loading, restores its state via local storage. It's trivial to set up:

import { App } from 'domeleon'

new App({
  root : new Master(),
  id : 'app',
  autoPersist : true // save & restore automatically
})

Internally, this calls serialize and deserialize on your root component.

Deserializing classes (and dates)

Declare a serializerMap to map plain json objects to classes:

export class Tree extends Component {
  serializerMap: SerializerMap<Tree> = {
    setting: Setting   // component
    created: Date      // `Date` special case
    trees  : [Tree],   // array of components
    picker : null      // transient component
  }

  setting: Setting  
  created: Date
  trees: Tree[]
  picker: new Picker()
}

The types must be classes (Date is also handled). This is necessary because Javascript doesn't actually store runtime type information per property; that's a TypeScript only concept that is destroyed in transpilation.

Specifying a null value for a field in serializerMap prevents serialization. It's typically a useful alternative to prefixing a Component property with _ or # for sub-components that you don't want to serialize, but which you still otherwise want to treat as a Component (e.g. partaking in updates etc.)

Note: Domeleon's serializer does not use decorators for this purpose, as the latest spec is still not natively implemented by browsers as of 2025.

onDeserialized event

You can handle onDeserialized on your Component as follows:

class MyComponent extends Component {
  onDeserialized() {    
    // e.g. perform validation
    this.throwIfInvalid()
  }
}

After the component and its children have been deserialized, onDeserialized is called, children first, recursively.

Note: onDeserialized has early access to ctx.parent and ctx.app (when available) enabling contextual validation, even if onAttached has yet to be called. onUpdated will always be called afterwards.

Handling Sets and Maps

For simplicity, Domeleon only allows nested components to be single instance values or arrays. This makes Domeleon easy to reason about, as well as providing a direct correspondence with our component tree and the JSON format.

We can however, still use Maps. We just need to make sure our map is:

  1. Private
  2. If public, only exposed via a read-only property, or simply a method.
  3. If serialized, expose publcally as an array.

Here's how we could serialize a collapseMap:

export class InspectorComponentTree extends Component {
  _collapseMap = new Map<string, boolean>()

  get collapseState () {    
    return [...this._collapse]
      .map(
        ([id, collapsed]) =>
        ({id, collapsed})
      )
  }

  set collapseState (state: { id: string; collapsed: boolean }[]) {
    this._collapseMap = new Map(
      state.map(
        ({id, collapsed }) =>
         [id, collapsed]
      )
    )
  }
}

This means we get the performance benefits of a Map, but can fully partake in the domeleon object model.

Design Note: Domeleon may provide a way to reduce this boilerplate in the future; several designs are possible; still figuring out which one is best. For now this is acceptable.

App

Your Domeleon application always starts by constructing an App instnace.

Here's a minimal setup:

import { App } from 'domeleon'

new App({ root: new Root(), id: "app"}) // root component + id of element to mount

Here's a more advanced setup:

import { App } from 'domeleon'
import { inspector } from 'domeleon/inspector'
import { themeMgr } from './styles/theme'
import { Master } from './master'
import { VueRenderer } from 'domeleon/vue'

new App({
  root: new Root(), 
  id: "app",
  cssAdapter: themeMgr.unoCssAdapter, // pass .class values through per render
  renderer: new VueRenderer(), // altenatively a ReactRenderer
  autoPersist: true, // auto serialize component tree to local storage
  routeService: new RouteService { basePath: "admin"} // only needed for a custom base path
  plugins: [inspector] // inspect component tree and events
})

autoPersist

If true, then your component tree will automatically be serialized to local storage. This can be really nice when developing, as you don't lose your state as you update your source code. However, you may need to add serialization annotations (see Serialization section).

Global App Handle

Every App self-registers on window.domeleon.apps, keyed by mount id, and attaches itself to its mount element as __domeleon_app__. This is a devtools-style hook (like Vue's __vue_app__): window.domeleon.apps.get('app').root gives debuggers and agents direct access to the live component tree. Re-mounting on the same id replaces the registry entry.

Pluggable VDOM - React/Vue/Preact

Domeleon's VDOM is pluggable. The specific VDOM you choose should mostly be an implementation detail; you can write large applications with Domeleon, swap out the VDOM, and not even notice. All 3 VDOMs perform well and are very robust.

By default Domeleon uses Preact, as it's tiny, making it a perfect default. However, you can also use Domeleon with React or Vue, which is useful if you really need to leverage a particular component only available in that framework.

with* Attributes

We can integrate with renderer-specific hooks and components from Preact, React, and Vue, with with* attributes.

Here we animate the shuffle using useAutoAnimate with preact:

import { div, button } from 'domeleon'
import { useAutoAnimate } from '@formkit/auto-animate/preact'

export class Shuffler extends SampleComponent {
  items = [1,2,3,4,5]

  view() {    
    return div(
      div({ withPreact: { hook: useAutoAnimate } }, 
        this.items.map(i =>
          div({ key: i }, i)
        )
      ),
      button({ onClick: () => this.shuffle() }, "Shuffle")
    )
  }

  shuffle() {
    this.items.sort(() => Math.random() - 0.5)
    this.update()
  }
}

useAutoAnimate is a wonderful component, and provides hooks for all 3 frameworks. However, let's swap out our renderer with Vue and use Vue's TransitionGroup component as follows:

      div({
        withVue: {
          component: TransitionGroup,
          componentProps: { tag: 'div', name: transitionName }
        }
      },
        this.items.map(i =>
          div({ key: i }, i)
        )
      ),

You can even include both withPreact and withVue. If the renderer doesn't match, it's simply ignored.

Choosing a Renderer

You choose a renderer when you launch your app.

Preact / Default

Here's the default, that implicitly uses Preact:

import { App } from 'domeleon'
import { Root } from './root'
...

new App({root: new Root(), id: "app"})

Vue

Here's using Vue:

import { VueRenderer } from 'domeleon/vue'
import { App } from 'domeleon'
import { Root } from './root'

new App({
  root: new Root(),
  id: "app",
  renderer: new VueRenderer()
})

React

Here's using React:

import { ReactRenderer } from 'domeleon/react'
import * as React from 'react'
import * as ReactDOMClient from 'react-dom/client'
import { App } from 'domeleon'
import { Root } from './root'
...

new App({
  root: new Root(),
  id: "app", // or "root" if you prefer that for name of the id of the element to mount on
  renderer: new ReactRenderer({ ReactLib: React as any, ReactDOMClientLib: ReactDOMClient })
})

React requires separated React and Client libaries that you import yourself, for configurability and versioning robustness.

Be Agnostic When you Can

Many of the best 3rd party libraries are vendor neutral, and you can use them simply via the onMounted hook, that works across all renderers.

Inspector

The inspector is a tool that lets you see your component tree and monitor update events.

Set up the inspector as follows:

import { App } from 'domeleon'
import { inspector } from 'domeleon/inspector'

new App({
  ...
  plugins: [inspector]
})

Components with a router property have their routes displayed, making it a great way to get an overview of your routing setup.

The types of update events are:

  • router
  • validator
  • input
  • serializer
  • custom

inspector is itself an application written with Domeleon, and uses the @unocss/preset-wind3 preset as a peer dependency.

Maskito

Domeleon has wrappers for maskito's mask editors. The built in number input in HTML is terrible; use maskito instead. It's far cleaner, more powerful, and works uniformly across all browsers.

import { inputMask, inputNumber } from 'domeleon/maskito'

You can use it exactly like you would inputText or inputRange; the inputProps exposes the maskito object model to configure it. Here's an example of using inputNumber with a formField:

  formField({   
    prop: () => this.rating, 
    inputFn: inputNumber, 
    inputProps: {
      numberParams: {
        maximumFractionDigits: 1,
        decimalSeparator: '.'
      }
    }
  })

Component Communication

Child-To-Parent Communication

Generally, a child doesn't need to talk to its parents; it looks after itself, and delegates to its own children. However, sometimes the best way to manage complexity is to break this generalisation. There are 4 main patterns for doing so:

Callbacks

In this child-to-parent communication pattern, the parent passes functions down to child views/components:

  // Parent
  view() { return this.child.view({ onSave: () => this.handleSave() }) }
  handleSave() { this.update(...) }

  // Child
  view(props: { onSave?: () => void }) { ... }

Parent Interface

In this child-to-parent communication pattern, the child accesses its parent via this.ctx.parent. The child then casts the parent to an interface that limits scope and prevents circular type references. Now the child can read state or call methods.

In this example, we want the Animal component to get its family's name, so we allow this via an IFamily interface:

  interface IFamily { name: string }

  class Family extends Component implements IFamily {
    name: string
    animals: Animal[]
  }
    
  class Animal extends Component {
    get family() { return this.ctx.parent as IFamily }  
    
    foo() {
      const name = this.family.name
    }
  }

Root Interface

Building on the previous idea, sometimes it's useful for the root component to expose an interface, that any deeply nested child component can reference.

Here our root component implements IZoo:

  interface IZoo {
     families: IFamily[]
  }

  class Zoo extends Component implements IZoo {
     families: Family[]
  }

  class Family {...}
    
  class Animal extends Component {
    get zoo () { return this.root as IZoo }
    goo() {
      const pride = this.zoo.families.find(f => f.name == "Lion")      
    }
  }

Key things to remember:

  • Keep your interfaces limited. Don't expose more than you need to; the less you expose the less you need to reason about.
  • Avoid children accessing parents, but only to a point: if you're tying yourself in knots trying to be "pure", you're merely trading one type of complexity for another.

Update Path

In this child-to-parent communication pattern, the parent overrides onUpdated (event: UpdateEvent) to react to any update originating from its children (or itself).

// Parent
onUpdated(event: UpdateEvent) {
   ...
} 

This will also catch any input, validator, router & serializer events.

Tip: To know what update events pass through onUpdated, use the Inspector.

Component to Non-Component Communication

Within the component layer, maintain a single source of truth, i.e. avoid duplicating state across components. But when crossing boundaries, consider synchronizing between your component and the other object's state.

Synchronization Examples

Component + DOM Element: Use inputXxx databinding functions to automatically synchronize between your component state and DOM state.

Component + router: Use the onNavigate/onNavigated events on Component to synchronize between your component and the browser's location captured in your component router's selectedSegment.

💡Guideline: if the state in question should be serialized with your component, it belongs on your component, if it merely emphemerally exists elsewhere at the same time.