Dojo NG is being built in the open, one section at a time — follow along on Heptapod.

Why Dojo NG is built on web components

Dojo NG started as a replacement for the Dojo 2 widget set. I had built projects using Dojo for years. Dojo was great for large complex projects. When I worked as the software architect at Holmes, we used Dojo and then Dojo 2 over the course of seventeen years to build large certification training systems.

The widgets themselves held up. What aged was everything around them. They rendered through Dojo's own framework, so they were only useful to an app built on that framework. When attention moved elsewhere, the widgets had nowhere to go.

That's the usual fate of a component library. One written for React works in React. When a team moves to something else, or when a company ends up with React in one product and Angular in another, the components get rebuilt along with everything else. The library was never really the team's; it belonged to the framework.

So the first decision for Dojo NG was the one that mattered most: build on the part of the stack that doesn't churn.

What a custom element buys

Every dj-* tag is a standard custom element, registered through the browser's Custom Elements API. Once @dojo-ng/button is imported, the browser knows what <dj-button> is. Any framework that can render a <div> can render it, because to the framework it is just another element.

That used to come with an asterisk. React passed every prop to a custom element as a string attribute and had no way to bind its events, so arrays, objects, and callbacks needed wrapper components. React 19 fixed the property half: it now sets non-string values as properties. Vue, Angular, and Svelte had already done so. The same <dj-select> with the same options array now works in all four.

It also works with no framework at all. An import map and a script tag are the whole setup:

<script type="importmap">
{ "imports": { "@dojo-ng/button": "https://esm.sh/@dojo-ng/button" } }
</script>
<script type="module">import "@dojo-ng/button";</script>
<dj-button kind="contained">Save</dj-button>

That path matters more than it looks. A library whose components still work after the framework is gone is the most direct answer to framework churn, and plain HTML is where it is easiest to see. The other four paths need a few lines of setup each, and Getting started covers them. Plain HTML needs none.

How it's built

The components use Lit 3 on a small shared base class. Lit handles rendering and reactive properties and stays out of the way otherwise. There is no component-to-component dependency graph beyond what each element actually composes. dj-text-input, for example, uses dj-label and dj-helper-text.

Each component keeps its markup and styles in a shadow root, so page CSS can't break it by accident. Styling comes in through --dj-* custom properties, layered as primitives, semantic roles, and component tokens. Override a primitive and everything built on it follows. Dark and high-contrast themes are the same tokens with different values.

Form controls are form-associated through ElementInternals. A dj-text-input inside a native <form> shows up in FormData, takes part in constraint validation, and resets with the form, the same as an <input>. Dojo NG is aimed at line-of-business apps, and those are mostly forms, so this was not optional.

Every component is its own npm package, shipped as plain ES modules compiled from TypeScript. Install only what you use. A bundler reads the packages from node_modules as usual, and an import map loads them with no build step at all.

The heaviest components don't reinvent the hard parts. dj-data-grid sits on TanStack Table and TanStack Virtual, dj-rich-text on Lexical, and the chart set uses D3 for the math while rendering its own SVG. The engine handles state and computation, and the element owns rendering, theming, and accessibility.

Those heavy components grow through plugins. Row grouping, tree rows, and selection are separate packages that extend dj-data-grid through public extension points. The grid core never reads a plugin's configuration; the plugin adapts to the core, not the other way around. Tables, mentions, the slash menu, and embeds work the same way for dj-rich-text. If you do not need a feature, you do not need to load it. We've done our best to ban fat overloaded widgets wherever possible.

What it costs

Web components have real costs, and anyone who has tried them already knows the list.

  • The shadow boundary cuts both ways. Page CSS can't reach inside, which is the point, but it also means you style through tokens and the parts each component exposes with ::part(), not arbitrary selectors. Label association doesn't cross the boundary either, so the input components set their inner control's accessible name from the label property instead of relying on for and id.
  • React needs version 19. Earlier versions still turn every prop into a string. Even in 19, custom events such as dj-close have no JSX prop, so you listen through a ref.
  • There is no server-side rendering yet. The components render in the browser. Declarative Shadow DOM is the route to a no-JavaScript first paint, and it isn't done.
  • Safari before 16.4 needs a polyfill for the form controls, since that is when Safari added ElementInternals.
  • Everything is pre-1.0. Every package is at 0.1 or 0.2, and APIs can still move. That is also why the per-library migration guides aren't written yet: a prop-by-prop mapping to an API that may change would mislead you.

Where it stands

There are 112 packages on npm under @dojo-ng: 82 custom elements, 25 plugins, and 6 utility packages. Every element package that renders anything, 80 of 81, runs axe accessibility checks in a browser test suite on Chromium, Firefox, and WebKit; the 81st, dj-global-event, renders nothing to check. All of it is BSD-3-Clause. The source is on Heptapod, with a read-only mirror on GitHub.

What's published stays free. A paid layer is planned for work that needs a server or has no free upstream, and none of it is built yet.

The quickest way to see the point of all this is to open the playground or the component catalog, then pick your framework in Getting started. If something doesn't work the way this post says it should, tell us on Discord or open an issue on Heptapod.