Styling
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.
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 loadsconst 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 stringimport styles from "./counter.css?raw";
const sheet = new CSSStyleSheet();sheet.replaceSync(styles);: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.
import css from "./counter.css?raw";
export const counterSheet = new CSSStyleSheet();counterSheet.replaceSync(css);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.