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.

counter.tsxmain.tsxcounter.styles.tscounter.css
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.


See also