Styling
Style custom elements efficiently with Constructable Stylesheets and raw CSS imports.
Custom elements can be styled two ways: with global CSS (light DOM) or scoped CSS inside a Shadow DOM. In both cases, using a CSSStyleSheet object avoids parsing the same CSS for every element instance.
import { reactive, computed } from "elements-kit/signals";
import { render } from "elements-kit/render";
import { counterSheet } from "./counter.styles";
export class CounterElement extends HTMLElement {
@reactive() count = 0;
// Private: excluded from ElementProps so JSX doesn't type `doubled` as a
// settable prop. Public readers should expose it via a getter if needed.
readonly #doubled = computed(() => this.count * 2);
#unmount?: () => void;
#template = () => (
<div>
<p>
<strong>{() => this.count}</strong>
{" × 2 = "}
<strong>{this.#doubled}</strong>
</p>
<div class="controls">
<button on:click={() => this.count++}>+1</button>
<button on:click={() => this.count--}>−1</button>
<button on:click={() => (this.count = 0)}>Reset</button>
</div>
</div>
);
connectedCallback() {
// Shadow DOM — styles are scoped, sheet is shared
const shadow = this.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [counterSheet];
this.#unmount = render(shadow, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}
customElements.define("x-counter2", CounterElement);
declare global {
namespace ElementsKit {
interface CustomElementRegistry {
"x-counter2": typeof CounterElement;
}
}
}
The problem with per-instance <style>
A naive approach injects a <style> tag into every shadow root:
class MyElement extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
// ❌ Each instance parses this string independently
shadow.innerHTML = `
<style>:host { display: block } button { padding: 4px }</style>
<button>Click</button>
`;
}
}100 instances → 100 parse operations, 100 CSSStyleDeclaration objects in memory. With large stylesheets this adds up.
Constructable Stylesheets
CSSStyleSheet is a first-class object. Create it once at module level, share it across every instance by reference via adoptedStyleSheets. The browser parses the CSS once.
// Parsed once when the module loads
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host {
display: block;
font-family: sans-serif;
}
button {
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
}
`);Adopt it in Shadow DOM:
import { render } from "elements-kit/render";
class CounterElement extends HTMLElement {
#unmount?: () => void;
#template = () => (
<section>
<p>Count: <strong>{this.#count}</strong></p>
<button on:click={() => this.#count(this.#count() + 1)}>+1</button>
</section>
);
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
// All instances share the same parsed sheet — zero extra parsing
shadow.adoptedStyleSheets = [sheet];
this.#unmount = render(shadow, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}adoptedStyleSheets is an array — you can compose multiple sheets:
shadow.adoptedStyleSheets = [baseSheet, tokenSheet, componentSheet];?raw CSS import
When using Vite, esbuild, or most modern bundlers, append ?raw to a CSS import to receive the file contents as a plain string. This keeps CSS in proper .css files (IDE support, linting, source maps) while inlining it at build time.
// counter.css stays a real file — bundler inlines it as a string
import styles from "./counter.css?raw";
const sheet = new CSSStyleSheet();
sheet.replaceSync(styles);/* counter.css */
:host {
display: block;
}
button {
padding: 4px 12px;
border-radius: 4px;
}Singleton sheet module
The standard pattern: one module exports the shared sheet, the element imports it.
// counter.styles.ts
import css from "./counter.css?raw";
export const counterSheet = new CSSStyleSheet();
counterSheet.replaceSync(css);// counter.ts
import { reactive, computed } from "elements-kit/signals";
import { render } from "elements-kit/render";
import { counterSheet } from "./counter.styles";
class CounterElement extends HTMLElement {
@reactive() count = 0;
#unmount?: () => void;
#template = () => (
<section>
<p>Count: <strong>{() => this.count}</strong></p>
<button on:click={() => this.count++}>+1</button>
</section>
);
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [counterSheet];
this.#unmount = render(shadow, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}Light DOM (no Shadow DOM)
Without a shadow root, there is no style encapsulation — your element's styles are global. Two clean options:
Option 1 — document.adoptedStyleSheets
Adopt the sheet on the document once. All instances benefit, and it is still parsed only once.
import css from "./counter.css?raw";
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
let adopted = false;
class CounterElement extends HTMLElement {
#unmount?: () => void;
#template = () => /* JSX tree */;
connectedCallback() {
if (!adopted) {
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
adopted = true;
}
this.#unmount = render(this, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}Option 2 — inject <style> once into <head>
const STYLE_ID = "x-counter-styles";
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
x-counter { display: block; font-family: sans-serif; }
x-counter button { padding: 4px 8px; }
`;
document.head.appendChild(style);
}
class CounterElement extends HTMLElement {
#unmount?: () => void;
#template = () => /* JSX tree */;
connectedCallback() {
injectStyles();
this.#unmount = render(this, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}Shadow DOM with JSX
JSX renders into a shadow root the same way it renders into a regular element — pass the shadow root as the append target:
import { reactive } from "elements-kit/signals";
import { render } from "elements-kit/render";
import { counterSheet } from "./counter.styles";
class CounterElement extends HTMLElement {
@reactive() count = 0;
#unmount?: () => void;
#template = () => (
<div>
<p>Count: <strong>{() => this.count}</strong></p>
<button on:click={() => this.count++}>+1</button>
</div>
);
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [counterSheet];
// render into the shadow root — same target type as a light-DOM element
this.#unmount = render(shadow, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}Note: with Shadow DOM, styles scoped to :host and descendant selectors are fully encapsulated — consumer CSS cannot accidentally override them.