Extending components
Build on the components, don't fork them
Every xoji element extends one blessed base, XojiElement, and that
base is the extension contract too. So when a component is close to what you need
but not quite (you need a control that is semantically a toggle, or an element
the set doesn't ship), you build on top instead of rebuilding bespoke. The shared
token sheet and the
When to extend
The components compose for appearance through ::part()
hooks and slots: restyle internals, inject content, no fork required. This
page is the other axis: composing for behavior or semantics. Reach
for it when you need a control the set doesn't ship, or a richer state on one
it does.
The extension contract
These protected members are the stable surface a subclass builds
on. render() adopts the one shared component stylesheet for you,
then writes styles() + template() into the shadow
root, so a subclass never re-implements theming, only its own markup and the
state that drives it.
| Member | Type | What it's for |
|---|---|---|
root | ShadowRoot | the open shadow root; write your markup into it |
template() | () => string | required; the element's shadow markup |
styles() | () => string | per-element host-layout CSS (the shared token sheet is adopted for you) |
render() | () => void | paint the shadow; call it after a state change to re-render |
reflectBoolean(name, on) | (string, boolean) => void | reflect a boolean prop to a present/absent attribute |
reflectString(name, value) | (string, string?) => void | reflect a string prop, removing the attribute when empty |
A custom element
Extend XojiElement, declare the attributes you observe, override
template() and styles(), and call render()
on the state changes you care about. The markup reads straight from the token
register, so it re-themes with every other component when the theme changes.
import { XojiElement, define } from "@xoji/core/elements";
// A small custom element: a click-to-increment tally, themed entirely from
// the token register the active algorithm derives.
class TallyChip extends XojiElement {
static get observedAttributes() {
return ["count"];
}
get count(): number {
return Number(this.getAttribute("count") ?? "0");
}
set count(value: number) {
this.setAttribute("count", String(value));
}
connectedCallback(): void {
super.connectedCallback(); // paints once
this.addEventListener("click", () => {
this.count += 1;
this.render(); // re-paint on the state change you care about
});
}
protected styles(): string {
return `:host { display: inline-block; }
button {
font: inherit;
color: var(--accent-fg);
background: var(--accent);
border: var(--border-thin) solid var(--accent);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
cursor: pointer;
}`;
}
protected template(): string {
return `<button part="chip" type="button">Tally: ${this.count}</button>`;
}
}
define("xoji-tally-chip", TallyChip);
Extending a shipped element
To add behavior to a component the set already ships, subclass its element and
widen observedAttributes by spreading the base's. The inherited
render() keeps painting the component's look; your subclass adds
only the new behavior, here the controlled toggle the base deliberately left
to the consumer.
import { XojiButton } from "@xoji/core/elements";
// A controlled toggle built on the shipped button: it owns no new look, just
// the behavior the base left to the consumer — a click flips `pressed`, which
// the base already reflects to `aria-pressed` and paints as a sunken state.
class ToggleButton extends XojiButton {
static get observedAttributes() {
return [...super.observedAttributes, "data-toggle"];
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener("click", () => {
this.pressed = !this.pressed;
});
}
}
customElements.define("x-toggle-button", ToggleButton);
New tokens, same contract
A subclass is held to the same hard rule as a first-party component: every theme token its CSS reads must be one the register produces. If your extension paints with a token the base never consumed, declare it in the component's manifest so the coverage check stays honest.
// A subclass that paints with a token the base never consumed declares it,
// so the coverage check still holds: every token the CSS reads is one the
// register produces.
protected styles(): string {
return `button { box-shadow: 0 0 0 2px var(--ring); }`;
// → add "--ring" to this component's consumedTokens manifest entry
}