Skip to content

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 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.


See also