Ornament
Mid-level, pareto-optimal, treeshakable and tiny (< 5k) TypeScript-positive toolkit for web component infrastructure
Install / Use
/learn @SirPepe/OrnamentREADME
Ornament - Framework for web component frameworks
📢 What's new in 3.1.1? Check out the Changelog!
Build your own component framework with Ornament, a stable, mid-level, pareto-optimal, treeshakable, tiny and TypeScript-positive toolkit for web component infrastructure! Escape from heavyweight frameworks, constant rewrites and the all-encompassing frontend FOMO with a declarative, simple, and type-safe API for almost-vanilla web components:
import {
define,
attr,
string,
number,
connected,
reactive,
} from "@sirpepe/ornament";
// Register the element with the specified tag name
@define("my-greeter")
class MyGreeter extends HTMLElement {
// No built-in rendering functionality. Shadow DOM or light DOM? Template
// strings, JSX, or something else entirely? You decide!
#shadow = this.attachShadow({ mode: "open" });
// Define content attributes alongside corresponding getter/setter pairs
// for a JS api and attribute change handling and type checking. If you use
// TypeScript, the type checks will work at compile time *and* at run time
@attr(string()) accessor name = "Anonymous";
@attr(number({ min: 0 })) accessor age = 0;
// Mark the method as reactive to have it run every time one of the attributes
// change, and also run it when the component first connects to the DOM.
@reactive()
@connected()
greet() {
this.#shadow.innerHTML = `Hello! My name is ${this.name}, my age is ${this.age}`;
}
}
Ornament makes quasi-vanilla web components fun and easy when compared to the equivalent boilerplate monstrosity that one would have to write by hand otherwise:
<details> <summary>😱 Unveil the horror 😱</summary>class MyGreeter extends HTMLElement {
#shadow = this.attachShadow({ mode: "open" });
// Internal "name" and "age" states, initialized from the element's content
// attributes, with default values in case the content attributes are not set.
// The value for "age" has to be figured out with some imperative code in the
// constructor to keep NaN off our backs.
#name = this.getAttribute("name") || "Anonymous";
#age;
constructor() {
super(); // mandatory boilerplate
let age = Number(this.getAttribute("age"));
if (Number.isNaN(age)) {
// Remember to keep NaN in check
age = 0;
}
this.#age = 0;
}
// Remember to run the reactive method when connecting to the DOM
connectedCallback() {
this.greet();
}
// Method to run each time `#name` or `#age` changes
greet() {
this.#shadow.innerHTML = `Hello! My name is ${this.#name}, my age is ${this.#age}`;
}
// DOM getter for the property, required to make JS operations like
// `console.log(el.name)` work
get name() {
return this.#name;
}
// DOM setter for the property with type checking and/or conversion *and*
// attribute updates, required to make JS operations like `el.name = "Alice"`
// work.
set name(value) {
value = String(value); // Remember to convert/check the type!
this.#name = value;
this.setAttribute("name", value); // Remember to sync the content attribute!
this.greet(); // Remember to run the method!
}
// DOM getter for the property, required to make JS operations like
// `console.log(el.age)` work
get age() {
return this.#age;
}
// DOM setter for the property with type checking and/or conversion *and*
// attribute updates, required to make JS operations like `el.age = 42` work.
set age(value) {
value = Number(value); // Remember to convert/check the type!
if (Number.isNaN(value) || value < 0) {
// Remember to keep NaN in check
value = 0;
}
this.#age = value;
this.setAttribute("age", value); // Remember to sync the content attribute!
this.greet(); // Remember to run the method!
}
// Attribute change handling, required to make JS operations like
// `el.setAttribute("name", "Bob")` update the internal element state.
attributeChangedCallback(name, oldValue, newValue) {
// Because `#name` is a string, and attribute values are always strings as
// well we don't need to convert the types at this stage, but we still need
// to manually make sure that we fall back to "Anonymous" if the new value
// is null (if the attribute got removed) or if the value is (essentially)
// an empty string
if (name === "name") {
if (newValue === null || newValue.trim() === "") {
newValue = "Anonymous";
}
this.#name = newValue;
this.greet(); // Remember to run the method!
}
// But for "#age" we do again need to convert types, check for NaN, enforce
// the min value of 0...
if (name === "age") {
const value = Number(value); // Remember to convert/check the type!
if (Number.isNaN(value) || value < 0) {
// Remember to keep NaN in check
value = 0;
}
this.#age = value;
this.greet(); // Remember to run the method!
}
}
// Required for attribute change monitoring to work
static get observedAttributes() {
return ["name", "age"]; // remember to always keep this up to date
}
}
// Finally remember to register the element
window.customElements.define("my-greeter", MyGreeter);
</details>
Ornament makes only the most tedious bits of building vanilla web components (attribute handling and lifecycle reactions) easy by adding some primitives that really should be part of the standard, but aren't. You yourself add everything else. Ornament is not a framework, but something that you want to build your own framework on top of. Combine Ornament's baseline web component features with something like uhtml or Preact for rending, add your favorite state management library (or don't), write some glue code and enjoy your very own frontend web framework.
Guide
Installation
Install @sirpepe/ornament with your favorite package manager. To get the decorator syntax working in early 2026, you will probably need some tooling support, such as:
- @babel/plugin-proposal-decorators
(with the option
versionset to"2023-11") - esbuild (with the option
targetset to something other thanesnext) - TypeScript 5.0+
(with the option
experimentalDecoratorsturned off).
Apart from that, Ornament is just a bunch of functions. No further setup required.
General philosophy
The native APIs for web components are verbose and imperative, but lend themselves to quite a bit of streamlining with the ever-upcoming syntax for ECMAScript Decorators. The native APIs are also missing a few important primitives and conveniences. Ornament's goal is to provide the missing primitives and to streamline the developer experience. Ornament is not a framework but instead aims to be:
- as stable as possible by remaining dependency-free, keeping its own code to an absolute minimum, and relying on (or re-implementing) iron-clad web standards where possible
- fast and lean by being nothing more than just a bag of relatively small and simple functions
- supportive of gradual adoption and removal by being able to co-exist with vanilla web component code
- malleable by being easy to extend, easy to customize, and easy to get rid of
- universal by adhering to (the spirit of) web standards, thereby staying compatible with vanilla web component code as well as all sorts of web frameworks
- equipped with useful type definitions (and work within the constraints of TypeScript)
Ornament is infrastructure for web components and not a framework itself. It makes dealing with the native APIs bearable and leaves building something actually sophisticated up to you. Ornament does not come with any of the following:
- State management (even though it is simple to connect components to signals or event targets)
- Rendering (but it works well with uhtml, Preact and similar libraries)
- Built-in solutions for client-side routing, data fetching, or really anything beyond the components themselves
- Any preconceived notions about what should be going on server-side
- Specialized syntax or tooling for every (or any specific) use case
You can (and probably have to) therefore pick or write your own solutions for
the above features. Check out the examples folder for inspiration! The
examples can be built using npm run build-examples.
Exit strategy
Every good library should come with an exit strategy as well as install instructions. Here is how you can get rid of Ornament if you want to migrate away:
- Components built with Ornament will generally turn out to be very close to vanilla web components, so they will most probably just keep working when used with other frameworks/libraries. You can theoretically just keep your components and replace them only when the need for change arises.
- If you want to replace Ornament with hand-written logic for web components,
you can replace all attribute and update handling piecemeal. Ornament's
decorators co-exist with native
attributeChangedCallback()and friends just fine. Ornament extends what you can do with custom elements, it does not require abstracting anything away. - Much of your migration will depend on how you build on top of Ornament. You should keep reusable components and app-specific state containers separate, just as you would do in e.g. React. This will make maintenance and eventual migration much easier, but this is really ou
