# 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:

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

```ts
// 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:

```tsx
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:

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

```ts
// 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);
```

```css
/* 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.

```ts
// counter.styles.ts
import css from "./counter.css?raw";

export const counterSheet = new CSSStyleSheet();
counterSheet.replaceSync(css);
```

```tsx
// 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.

```ts
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>`

```ts
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:

```tsx
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

* [Custom elements](/custom-elements)
* [Elements](/elements)
