# Symbiote.js
A lightweight, standards-first UI library built on Web Components. No virtual DOM, no compiler, no black boxes, no excess repaints. No build step required - works directly in the browser. A bundler is recommended for production performance, but entirely optional.
Here are the three most important differences between Symbiote.js and other frameworks:
1. **Natural DOM Extension Philosophy** - designed to extend platform APIs, not to replace them
2. **Runtime-Agnostic HTML Templates** - outstanding flexibility for rendering strategies and further customization
3. **Powerful App-wide State Management** - combine data contexts without bloated boilerplate or external tools
## What's new in v3.x?
- **WebMCP support** - expose live Symbiote UI actions as browser-native tools for agents. See the [WebMCP docs](https://github.com/symbiotejs/symbiote.js/blob/webmcp/docs/webmcp.md).
- **Server-Side Rendering** - render components to HTML with `SSR.processHtml()` or stream chunks with `SSR.renderToStream()`. Client-side hydration via `ssrMode` attaches bindings to existing DOM without re-rendering.
- **Isomorphic components** - `isoMode` flag makes components work in both SSR and client-only scenarios automatically. If server-rendered content exists, it hydrates; otherwise it renders the template from scratch. One component, zero conditional logic.
- **Computed properties refined** - reactive derived state with microtask batching.
- **Path-based router** - optional `AppRouter` module with `:param` extraction, route guards, and lazy loading.
- **Exit animations** - `animateOut(el)` for CSS-driven exit transitions, integrated into itemize API.
- **Dev mode** - `Symbiote.devMode` enables verbose warnings; import `devMessages.js` for full human-readable messages.
- **DSD hydration** - `ssrMode` supports both light DOM and Declarative Shadow DOM.
- **Class property fallback** - binding keys not in `init$` fall back to own class properties/methods.
- **Lazy mode** - `lazyMode` flag defers component initialization and rendering based on viewport visibility. Can also be enabled via the `lazy` attribute on `itemize` containers to efficiently handle massive data sets.
## Quick start
No install needed - run this directly in a browser:
```html
```
Or install via npm:
```bash
npm i @symbiotejs/symbiote
```
```js
import Symbiote, { html, css } from '@symbiotejs/symbiote';
```
## Core concepts
### Reactive state
```js
class TodoItem extends Symbiote {
text = '';
done = false;
toggle() {
this.$.done = !this.$.done;
}
}
TodoItem.template = html`
{{text}}
`;
```
State changes update the DOM synchronously. No virtual DOM, no scheduling, no surprises. And since components are real DOM elements, state is accessible from the outside via standard APIs:
```js
document.querySelector('my-counter').$.count = 42;
```
This makes it easy to control Symbiote-based widgets and microfrontends from any host application - no framework adapters, just DOM.
### Templates
Templates are plain HTML strings - runtime-agnostic, easy to test, easy to move between files:
```js
// Separate file: my-component.template.js
import { html } from '@symbiotejs/symbiote';
export default html`
{{title}}
`;
```
The `html` function supports two interpolation modes:
- **Object** → reactive binding: `${{onclick: 'handler'}}`
- **String/number** → native concatenation: `${pageTitle}`
### Itemize (dynamic reactive lists)
Render lists from data arrays or objects with efficient updates:
```js
class TaskList extends Symbiote {
tasks = [
{ name: 'Buy groceries' },
{ name: 'Write docs' },
];
}
TaskList.template = html`
{{name}}
`;
```
### Pop-up binding (`^`)
The `^` prefix works in any nested component template - it walks up the DOM tree to find the nearest ancestor that has the property registered in its data context (`init$` or `add$()`):
```html
{{^parentTitle}}
```
### Named data contexts
Share state across components without prop drilling:
```js
import { PubSub, html } from '@symbiotejs/symbiote';
PubSub.registerCtx({
user: 'Alex',
theme: 'dark',
}, 'APP');
// Any component can read/write:
this.$['APP/user'] = 'New name';
// Any template can use property directly:
let template = html`
`;
```
Then use it as an component's attribute in markup:
```html
```
## Attribute change reaction
Using a property accessor:
```js
class MyComponent extends Symbiote {
set 'my-attribute'(val) {
console.log(val);
}
}
MyComponent.observedAttributes = [
'my-attribute',
];
```
## `bindAttributes()` static method
Reflect attribute values to property values:
```js
class MyComponent extends Symbiote {
init$ = {
myProp: '',
}
}
// Map the attribute name to corresponding property key:
MyComponent.bindAttributes({
'my-attribute': 'myProp',
});
// observedAttributes is auto-populated
MyComponent.template = html`
{{myProp}}
`;
```
## Reserved attribute names
These attribute names are used internally by Symbiote.js:
- `bind`
- `ctx`
- `ref`
- `itemize`
- `item-tag`
- `use-template`
- `skip-text-nodes`
---
# Common Mistakes
Patterns that frequently trip up new users and AI code generators.
---
## 1. Using `this` inside template strings
Templates are context-free strings — they DO NOT execute inside a component instance and do not close over `this`. Binding values by name; the component resolves them at render time.
```js
// WRONG
MyComponent.template = html`
${this.title}
`;
// CORRECT — use binding syntax
MyComponent.template = html`
{{title}}
`;
// OR
MyComponent.template = html``;
// OR
MyComponent.template = ``;
```
---
## 2. Defining `template`, `rootStyles`, or `shadowStyles` inside the class body
`template`, `rootStyles`, and `shadowStyles` are **static property setters** on the `Symbiote` base class — not regular static fields. Assigning them inside the class body with `static template = ...` bypasses the setter and does nothing.
```js
// WRONG
class MyComponent extends Symbiote {
static template = html`
{{text}}
`; // setter never called
}
// CORRECT — assign outside the class body
class MyComponent extends Symbiote {}
MyComponent.template = html`
{{text}}
`;
MyComponent.rootStyles = css`my-component { display: block; }`;
```
---
## 3. Missing `^` prefix when referencing a parent-defined handler from an item template
Itemize items are real Symbiote components with their own reactive state (the data properties mapped from the source array or object). They can have their own handlers too — either passed as functions in the source data objects, or defined on a separate component class registered for the tag name set by `item-tag` attribute.
The common mistake is defining a shared handler on the **parent** (e.g. to handle all item clicks in one place) and then binding to it from the item template *without* `^`. Without `^`, the binding looks for the handler on the item itself — where it doesn't exist — and fails.
```js
class MyList extends Symbiote {
init$ = {
items: [{ name: 'Alice' }, { name: 'Bob' }],
onItemClick: (e) => { console.log('clicked'); },
}
}
// WRONG — looks for onItemClick on the item, not the parent
MyList.template = html`
`;
// CORRECT — ^ walks up the DOM to find onItemClick in the parent's init$
MyList.template = html`
`;
```
> `^`-targeted properties must be defined in the ancestors's `init$` — the walk does not check plain class properties.
When each item needs its own independent handler, you can pass it directly in the data:
```js
this.$.items = [
{ name: 'Alice', onItemClick: () => console.log('Alice clicked') },
{ name: 'Bob', onItemClick: () => console.log('Bob clicked') },
];
```
```html
```
Or, as it recommended for the most cases, register a dedicated component class for the tag name set by `item-tag`:
```js
class MyItem extends Symbiote {
onItemClick() { console.log(this.$.name); }
}
MyItem.template = html``;
MyItem.reg('my-item');
```
```html
```
---
## 4. Using `@` attribute prefix directly in HTML
`@` is a binding syntax prefix only — it means "bind to HTML attribute" inside `${{}}` expressions. It is not a valid HTML attribute prefix.
```html
...
...
...
```
---
## 5. Expecting dotted `init$` keys to create nested state
State is flat by design. A key like `'obj.prop'` in `init$` is a literal flat key named `obj.prop` — it does not create a nested object `{ obj: { prop: ... } }`. `this.$['obj.prop']` reads and writes that same flat key and works fine.
```js
// MISLEADING — this does NOT create { obj: { prop: 'value' } }
init$ = { 'obj.prop': 'value' };
// It creates a flat key: store['obj.prop'] = 'value'
// Reading/writing it works: this.$['obj.prop'] = 'new' ✓
// If you need a nested object in state, store it as a flat key with an object value:
init$ = { obj: { prop: 'value' } };
this.$.obj = { ...this.$.obj, prop: 'new' }; // replace the whole object to trigger reactivity
```
Dot notation in binding **targets** is fully supported — for both standard DOM properties and child component state:
```js
// DOM property path:
html`
Text
`
// Child component's $ state proxy:
html``
```
---
## 6. Using `*prop` without a `ctx` attribute or `--ctx` CSS variable
Shared context properties (`*`-prefix) require a context name to be set on the component — either via the `ctx` HTML attribute or the `--ctx` CSS custom property. Without one, `*` props silently have no effect (dev mode warns).
```html
```
---
## 7. Expecting Shadow DOM by default
Shadow DOM is opt-in. By default, templates render into the component's light DOM. Use `renderShadow = true` or assign `shadowStyles` to opt in.
```js
// Light DOM (default)
class MyComponent extends Symbiote {}
// Shadow DOM — either flag:
class MyComponent extends Symbiote {
renderShadow = true;
}
// or via shadowStyles (auto-creates shadow root):
MyComponent.shadowStyles = css`:host { display: block; }`;
```
---
## 8. Adding a wrapper div inside the template
The custom element itself is the container. Adding a wrapping `
` as the single root inside the template is unnecessary — it creates extra DOM nesting and duplicates the element's own box. Style the component tag directly instead.
```js
// WRONG — the
is redundant, my-widget is already the root
MyWidget.template = html`
{{title}}
`;
// CORRECT — template content renders directly inside the custom element
MyWidget.template = html`
{{title}}
`;
```
```css
/* Style the element tag directly */
my-widget {
display: block;
padding: 20px;
}
```
---
## 9. Relying on class property fallbacks for prefixed bindings
Class property fallbacks (resolving unregistered keys from own instance properties or prototype methods) only apply to **local, unprefixed** bindings. Any prefixed binding — `^prop`, `*prop` — resolves exclusively through the data context (`init$` or `add$()`). Plain class properties are never checked for these.
```js
// WRONG — onItemClick is a class property, not in init$
// ^ will not find it
class TaskList extends Symbiote {
onItemClick() { console.log('clicked'); }
}
TaskList.template = html`
{{name}}
`;
// CORRECT — register in init$ so any prefixed binding can resolve it
class TaskList extends Symbiote {
init$ = {
onItemClick: () => { console.log('clicked'); },
}
}
```
The same applies to shared (`*`) bindings — the property must exist in the shared context's registered data, not merely as a class property.
Named external contexts (`CTX/prop`) are different: they are registered globally via `PubSub.registerCtx()` and are fully independent of any component's `init$` — components reference them freely without declaring anything locally.
---
## 10. Using `{{prop}}` binding syntax inside tag definitions
`{{prop}}` is a **text node** binding — it only works inside element content. Any binding that targets a tag itself — attributes, DOM properties, event handlers, or CSS custom properties — must use `${{}}` or `bind=""` syntax inside the opening tag.
```html
...
...
...
...
Hello, {{userName}}!
Theme: {{APP/theme}}
```
---
## 11. Expecting two-way data binding
Symbiote.js bindings are **one-way by design** — from state to DOM. There is no `v-model`, `[(ngModel)]`, or `bind:value` equivalent. To react to user input, wire the event handler explicitly and write back to state yourself.
```js
// WRONG — expecting the binding to also update state on user input
MyComponent.template = html`
`;
// CORRECT — handle the input event and write back to state
class MyComponent extends Symbiote {
init$ = { query: '' }
}
MyComponent.template = html`
`;
```
```js
class MyComponent extends Symbiote {
init$ = {
query: '',
onInput: (e) => { this.$.query = e.target.value; },
}
}
```
This is intentional — explicit event handling keeps data flow predictable and avoids the hidden side effects that two-way binding can introduce.
---
## 12. Treating `init$` as a plain object
`init$` is processed once at connection time to populate the component's reactive context. Mutating it after the fact has no effect. Use `this.$` or `add$()` for runtime changes.
```js
// WRONG — modifying init$ after construction does nothing
class MyComponent extends Symbiote {
init$ = { count: 0 };
initCallback() {
this.init$.count = 10; // too late, already processed
}
}
// CORRECT
class MyComponent extends Symbiote {
init$ = { count: 0 };
initCallback() {
this.$.count = 10; // write through the $ proxy
}
}
```
---
# Context
Context is the central concept in Symbiote.js. Rather than passing data through prop chains or maintaining a separate global store, Symbiote uses the DOM structure itself as the data flow graph. Every component can read from and write to multiple data sources - its **context** is the union of all the sources currently accessible to it.
There are seven context types, each addressed by a token prefix in property keys:
| Token | Context Type | Scope |
|-------|-------------|-------|
| _(none)_ | [Local](#local-context) | Component instance |
| `^` | [Pop-up](#pop-up-context) | Nearest ancestor that owns the property |
| `*` | [Shared](#shared-context) | All components sharing a `ctx` attribute |
| `name/` | [Named](#named-context) | Any component, anywhere |
| `--` | [CSS Data](#css-data-context) | Inherited from the CSS cascade |
| `@` | [Attribute](./attributes.md) | HTML attribute on the element |
| `+` | [Computed](./properties.md#computed-properties) | Derived, auto-recalculated value |
---
## Local context
Local context is the component's own reactive state, scoped to the instance. It works the same way as component state in other frameworks and is invisible to other components.
Define properties in `init$`:
```js
class MyComponent extends Symbiote {
init$ = {
myProperty: 'some value',
}
}
```
Use the `$` proxy to read and write values at runtime:
```js
class MyComponent extends Symbiote {
init$ = {
myProperty: 'some value',
}
renderCallback() {
console.log(this.$.myProperty); // > 'some value'
this.$.myProperty = 'new value';
}
}
MyComponent.template = html`
{{myProperty}}
`;
```
For simple components without shared or computed props, you can also declare properties as plain class fields - Symbiote picks them up automatically via class-field fallback:
```js
class MyComponent extends Symbiote {
count = 0;
label = 'Hello';
}
```
> Use `init$` when you need shared (`*`), computed (`+`), or attribute (`@`) props in the same declaration - those tokens are only recognized inside `init$`.
See [Properties →](./properties.md) for the full property API: `add$()`, `sub()`, `set$()`, computed props, and more.
---
## Named context
Named context is a global, named data store created independently of any component. Any component can read from or write to it using the `CONTEXT_NAME/property` syntax - regardless of DOM position.
Use named context when data must be shared across unrelated parts of the application.
**Creating a named context:**
```js
import { PubSub } from '@symbiotejs/symbiote';
let appCtx = PubSub.registerCtx({
theme: 'light',
user: null,
}, 'APP');
```
**Accessing it from a component:**
```js
class MyComponent extends Symbiote {
init$ = {
'APP/theme': 'light', // optional local fallback - used if the named context hasn't published yet
}
renderCallback() {
console.log(this.$['APP/theme']); // read
this.$['APP/theme'] = 'dark'; // write - updates the named context for all subscribers
}
}
MyComponent.template = html`
`;
// Switch language at any time - all subscribed components update instantly:
l10nCtx.multiPub({
users: 'Usuarios',
comments: 'Comentarios',
likes: 'Gustos',
});
```
You can also read and modify named context directly from any component using the `$` proxy:
```js
class MyComponent extends Symbiote {
renderCallback() {
console.log(this.$['L10N/users']);
this.$['L10N/users'] = 'ユーザー';
}
}
```
More information about `PubSub` in the [PubSub →](./pubsub.md) section.
---
## Pop-up context
Pop-up context lets a child component reach a property defined by an ancestor, without the ancestor needing to pass it down explicitly. Symbiote walks up the DOM tree until it finds a component that has the requested property in its data context.
Use the `^` token to reference a pop-up property - in both text bindings and event handlers:
```html
{{^parentTitle}}
```
```js
class MyButton extends Symbiote {}
MyButton.template = html`
{{^parentTitle}}
`;
```
Symbiote walks up the DOM tree until it finds a component with the requested property registered in its context:
```js
class MyEditor extends Symbiote {
init$ = {
onButtonClicked: () => console.log('clicked'),
editorTitle: 'My Editor',
}
}
```
> [!IMPORTANT]
> Pop-up lookup only searches the **data context** (properties registered via `init$` or `add$()`). Plain class properties are not resolved this way. Always declare `^`-targeted properties in the parent's `init$`:
> ```js
> class ParentComponent extends Symbiote {
> init$ = {
> onButtonClicked: () => console.log('clicked'),
> parentTitle: 'Hello',
> }
> }
> ```
Pop-up context is useful for composition - the same child component adapts to whichever ancestor provides the expected behavior:
```js
html`
`;
// or:
html`
`;
```
> Like the CSS cascade, pop-up context has no collision guard. Use additional prefixes (e.g. `myApp_onSave`) in environments you don't fully control.
---
## Shared context
Shared context is inspired by native HTML `name` attributes - the same way `` connects radio buttons into one workflow, the `ctx` attribute connects Symbiote components into a shared data context. Components with the same `ctx` name access a common reactive store with no intermediary component required.
**Assign a context name via the `ctx` HTML attribute:**
```html
```
**Or via the `--ctx` CSS custom property**, which cascades like any CSS variable:
```css
.gallery-section {
--ctx: gallery;
}
```
```html
```
The CSS approach is useful when:
- You want **layout-driven grouping** - components inherit context from their visual container rather than repeating the attribute on each one
- You need to **override context at different DOM levels** - just like any CSS custom property, `--ctx` cascades and can be reassigned in nested selectors
- You work in a **framework-agnostic setup** - CSS context assignment is independent of the host template engine
**Define shared properties with the `*` token:**
```js
class UploadBtn extends Symbiote {
init$ = { '*files': [] }
onUpload(newFile) {
this.$['*files'] = [...this.$['*files'], newFile];
}
}
class FileList extends Symbiote {
init$ = { '*files': [] } // same shared prop - first-registered value wins
}
```
Both components read and write the same `*files` store. When one updates it, the other reacts automatically - no parent component, no prop drilling, no global store.
### Context name resolution
The `ctx` name is resolved in this order (first match wins):
1. `ctx="name"` HTML attribute on the element
2. `--ctx` CSS custom property inherited from ancestors
> **IMPORTANT**: In Symbiote 3.x, `*` properties **require** an explicit `ctx` attribute or `--ctx` variable. Without one, no shared context is created, `*` props have no effect, and dev mode will warn (W6).
---
## CSS Data context
Symbiote components can initialize their properties from CSS custom property values, enabling CSS-driven configuration: theme tokens, layout parameters, or localized strings - all settable from a stylesheet without touching JavaScript.
Use `cssInit$` to explicitly declare CSS-initialized properties with fallback values:
```js
class MyWidget extends Symbiote {
cssInit$ = {
'--columns': 1,
'--label': '',
}
}
MyWidget.template = html`
{{--label}}
`;
```
```css
my-widget {
--columns: 3;
--label: 'Click me';
}
```
You can also use `--` bindings directly in templates without `cssInit$`:
```js
class TestApp extends Symbiote {}
TestApp.template = html`
{{--header}}
{{--text}}
`;
```
```css
:root {
--header: 'CSS Data';
--text: 'Hello!';
}
```
> CSS custom property values must be valid JSON - use quoted strings (`'text'`), numbers, and `0`/`1` for booleans.
> CSS properties are used for **initialization only**. After the component mounts, they act as normal local context properties and no longer track CSS changes. Call `this.updateCssData()` to re-read them after runtime CSS updates.
Full details - `updateCssData()`, `dropCssDataCache()`, `ResizeObserver` patterns, and SSR caveats - in the [CSS Data →](./css-data.md) section.
---
## Choosing the right context type
| Situation | Use |
|-----------|-----|
| Component-local reactive state | Local - no token |
| Behavior or data passed from an ancestor | Pop-up - `^` |
| Sibling components sharing a workflow state | Shared - `*` |
| App-wide data, accessed from anywhere in the tree | Named - `/` |
| Component configured via CSS / design tokens | CSS Data - `--` |
| Reacting to HTML attribute values | Attribute - `@` |
| Values derived from other properties | Computed - `+` |
---
## All context types in one component
A concise example showing local, attribute, named, shared, and pop-up contexts working together:
```js
import Symbiote, { html } from '@symbiotejs/symbiote';
class MyApp extends Symbiote {
init$ = {
localCtxProp: 'LOCAL',
'@attr-test': '', // bound to HTML attribute
'APP/namedProp': 'NAMED', // named context
'*sharedProp': 'SHARED', // shared context (requires ctx="..." in HTML)
}
onUpdate() {
let suffix = ' updated';
this.$.localCtxProp += suffix;
this.$['APP/namedProp'] += suffix;
this.$['*sharedProp'] += suffix;
}
}
MyApp.template = html`
local: {{localCtxProp}}
attribute: {{@attr-test}}
named: {{APP/namedProp}}
shared: {{*sharedProp}}
`;
MyApp.reg('my-app');
class InnerEl extends Symbiote {}
InnerEl.template = html`
pop-up: {{^localCtxProp}}
`;
InnerEl.reg('inner-el');
```
```html
```
---
## Property token summary
| Token | Context Type | Example |
|-------|-------------|---------|
| _(none)_ | Local | `myProperty` |
| `^` | Pop-up | `^parentProp` |
| `*` | Shared | `*sharedProp` |
| `/` | Named | `APP/myProp` |
| `--` | CSS Data | `--my-css-var` |
| `@` | Attribute - see [Attributes →](./attributes.md) | `@my-attribute` |
| `+` | Computed - see [Properties →](./properties.md#computed-properties) | `+computedProp` |
---
# CSS Data Binding
Symbiote components can read CSS custom property values into component state. This enables CSS-driven configuration: theme values, layout parameters, localized strings — all settable from CSS without touching JavaScript.
## `cssInit$`
Use `cssInit$` to define properties initialized from CSS custom properties:
```js
class MyWidget extends Symbiote {
cssInit$ = {
'--columns': 1, // fallback value if CSS prop not set
'--label': '',
}
}
MyWidget.template = html`
{{--label}}
`;
```
```css
my-widget {
--columns: 3;
--label: 'Click me';
}
```
CSS values are parsed automatically — quoted strings become strings, numbers become numbers.
> Values should be valid JSON, parseable with `JSON.parse()`. Use numbers for boolean flags (`0`/`1`).
## `--` prefix in templates
You can bind to CSS custom properties directly in templates:
```js
class TestApp extends Symbiote {}
TestApp.template = html`
{{--header}}
{{--text}}
`;
```
```css
:root {
--header: 'CSS Data';
--text: 'Hello!';
}
```
> CSS custom properties are used for value initialization only. After that, they act like normal local context properties.
## Updating CSS data
Call `this.updateCssData()` to re-read CSS custom properties after runtime CSS changes.
Call `this.dropCssDataCache()` to clear the cached CSS data.
### Reacting to layout changes
CSS data is read once on initialization. To react dynamically — for example, when container queries change custom property values — use `ResizeObserver`:
```js
class AdaptiveWidget extends Symbiote {
cssInit$ = {
'--layout': 'stack',
}
renderCallback() {
new ResizeObserver(() => this.updateCssData()).observe(this);
}
}
```
```css
adaptive-widget {
container-type: inline-size;
--layout: 'stack';
}
@container (min-width: 600px) {
adaptive-widget {
--layout: 'grid';
}
}
```
> [!WARNING]
> **CSS data bindings and SSR.** Computed styles (`getComputedStyle`) are not available during server-side rendering. In `ssrMode` / `isoMode` components, CSS data properties will use their init value (from `cssInit$` or empty string). Enable `devMode` to see warnings for this.
## Why CSS as data?
- **Browser-native**: CSS custom properties are a platform feature for passing context down the DOM cascade — no JS library needed.
- **Shadow DOM friendly**: CSS custom properties are accessible inside nested shadow roots, unlike other CSS properties.
- **Flexible**: Redefine values at any level using `style` or `class` attributes.
- **Framework-agnostic**: Works with any external framework, library, or meta-platform.
- **CSP-safe**: Unlike inline scripts, CSS is typically allowed by CSP policies — making it ideal for configuration.
---
# Dev Mode
Enable verbose warnings during development to catch common mistakes early.
## Enabling
```js
Symbiote.devMode = true;
```
This also sets `PubSub.devMode = true`.
## Dev messages module
All warning and error messages are stored in an optional module. Without it, warnings print short numeric codes like `[Symbiote W5]`. Import the dev messages module once to get full human-readable messages and automatically enable `devMode`:
```js
import '@symbiotejs/symbiote/core/devMessages.js';
```
This single import enables the full dev experience — both verbose messages and dev-only diagnostics. It is typically added in your development entry point and removed (or excluded via tree-shaking) for production builds.
## Warning codes reference
| Code | Type | Guard | Description |
|------|------|-------|-------------|
| W1 | warn | always | PubSub: cannot read/publish/subscribe — property not found |
| W2 | warn | devMode | PubSub: type change detected on publish |
| W3 | warn | always | PubSub: context already registered |
| W4 | warn | always | PubSub: context not found |
| W5 | warn | always | Custom template selector not found |
| W6 | warn | devMode | `*prop` used without `ctx` attribute or `--ctx` CSS variable |
| W7 | warn | devMode | Shared prop already has a value — keeping existing |
| W8 | warn | always | Tag already registered with a different class |
| W9 | warn | always | CSS data parse error |
| W10 | warn | devMode | CSS data binding will not read computed styles during SSR |
| W11 | warn | devMode | Binding key not found in `init$` (auto-initialized to `null`) |
| W12 | warn | devMode | Text-node binding has no hydration attribute in SSR/ISO mode |
| W13 | warn | always | AppRouter message |
| W14 | warn | always | History API is not available |
| E15 | error | always | `this` used in template interpolation |
| W16 | warn | always | Itemize data must be Array or Object |
## Usage
Enable `devMode` and load messages in development, omit for production:
```js
// development — one import does it all
import '@symbiotejs/symbiote/core/devMessages.js';
// or, if you only want devMode without full messages:
Symbiote.devMode = true;
// production — no overhead, all dev checks are fully gated,
// messages module is not loaded
```
---
# Symbiote.js Examples
Complete, working examples covering major Symbiote.js features. Each example includes JavaScript, HTML, and CSS where applicable.
---
## 1. Basic Component
```js
import Symbiote, { html } from '@symbiotejs/symbiote';
class MyComponent extends Symbiote {
count = 0;
increment() {
this.$.count++;
}
}
MyComponent.template = html`
`;
MyApp.reg('my-app');
```
```html
```
---
## 16. SSR Markup Hydration
`ssrMode = true` skips template injection and attaches bindings to server-rendered HTML. The component connects to existing DOM without re-rendering.
```js
import Symbiote from '@symbiotejs/symbiote';
class MyApp extends Symbiote {
ssrMode = true;
heading = 'Some heading after hydration';
text = 'Some text after hydration...';
onUpdate() {
this.notify('heading');
this.notify('text');
}
}
MyApp.reg('my-app');
```
```html
Hello world!
Some initial text from server...
```
---
## 17. SSR CSS Variables Binding
CSS custom properties read reactively at runtime, with SSR hydration mode.
```js
import Symbiote from '@symbiotejs/symbiote';
class MyCom extends Symbiote {
ssrMode = true;
onUpdate() {
this.$['--text'] = `Updated text... (${Date.now()})`;
}
}
MyCom.reg('my-com');
```
```html
`;
MyWidget.shadowStyles = css`
:host {
display: inline-block;
border: 2px solid #333;
border-radius: 8px;
overflow: hidden;
font-family: sans-serif;
margin: 8px;
}
.header {
background: #333;
color: #fff;
padding: 6px 12px;
font-size: 12px;
text-transform: uppercase;
}
.body {
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.count {
font-size: 28px;
font-weight: bold;
min-width: 2ch;
text-align: center;
}
button {
background: #0057ff;
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 18px;
cursor: pointer;
&:hover { background: #0040cc; }
}
`;
MyWidget.reg('my-widget');
```
```html
```
---
# Flags
Flags are settings that enable or disable specific features or behaviors.
## Complete list
| Flag | Default | Description |
|------|---------|-------------|
| `renderShadow` | `false` | Render template into Shadow DOM |
| `ssrMode` | `false` | Hydrate server-rendered HTML |
| `isoMode` | `false` | Isomorphic mode: hydrate if children exist, render template otherwise |
| `isVirtual` | `false` | Replace element with its template fragment |
| `allowCustomTemplate` | `false` | Allow `use-template` attribute |
| `pauseRender` | `false` | Skip automatic rendering |
| `processInnerHtml` | `false` | Process existing inner HTML |
| `readyToDestroy` | `true` | Allow cleanup on disconnect |
| `allowTemplateInits` | `true` | Auto-add props found in template |
| `lazyMode` | `false` | Defer initialization until component enters the viewport |
| `mcpToolMode` | `false` | Generate experimental WebMCP tools from bound event handlers |
## renderShadow
Enables Shadow DOM mode. When enabled, you can use standard slots and `:host` selectors:
```js
class MyComponent extends Symbiote {
renderShadow = true;
}
```
## ssrMode
Enables the hydration workflow — the component uses its own nested markup as a template (provided by the server as regular HTML).
Text and attribute bindings are activated on first update, not at state initialization:
```js
class MyComponent extends Symbiote {
ssrMode = true;
}
```
In 3.x, `ssrMode` supports both light DOM and Declarative Shadow DOM hydration. Template injection is skipped; bindings attach to existing DOM.
> **Note**: `ssrMode` is a client-side flag. It is separate from `__SYMBIOTE_SSR` (server-side global). See [SSR](./ssr.md) for details.
## isoMode
Isomorphic rendering flag. If the component has children when it connects on the client (server-rendered content), it behaves like `ssrMode = true` — hydrates existing DOM. If the component has **no children**, it renders the template normally:
```js
class MyComponent extends Symbiote {
isoMode = true;
}
```
This is useful for components that may or may not be server-rendered — the same component code works in both scenarios without conditional logic.
## isVirtual
The component renders its template only, without the wrapping Custom Element. The Custom Element is used as a placeholder and disappears after initial rendering. Data bindings continue to work in memory:
```js
class MyComponent extends Symbiote {
isVirtual = true;
}
```
## allowCustomTemplate
The component's template is taken from an accessible part of the document by the provided selector:
```js
class MyComponent extends Symbiote {
allowCustomTemplate = true;
}
```
Then use the `use-template` attribute:
```html
{{someHeading}}
```
## pauseRender
Disables the default render stage to allow additional logic before rendering. Call `render()` manually:
```js
class MyComponent extends Symbiote {
pauseRender = true;
initCallback() {
fetch('../my-data.json').then((response) => {
response.json().then((data) => {
this.set$(data);
this.render();
});
});
}
}
```
## processInnerHtml
Similar to `ssrMode`, but all initiated data is rendered immediately. You can use standard template binding syntax for text nodes:
```js
class MyComponent extends Symbiote {
processInnerHtml = true;
}
```
```html
{{someHeading}}
```
## readyToDestroy
Controls whether the component is destroyed when removed from DOM. Set to `false` to **disable** destruction:
```js
class MyComponent extends Symbiote {
readyToDestroy = false;
}
```
See also: `destructionDelay` in [Lifecycle](./lifecycle.md).
## allowTemplateInits
Controls automated property initialization from template mentions. Disable to require explicit `init$` declarations:
```js
class MyComponent extends Symbiote {
allowTemplateInits = false;
}
// The 'heading' property won't be auto-initialized:
MyComponent.template = html`
{{heading}}
`;
```
## lazyMode
Defers component initialization until it enters the viewport using a global \`IntersectionObserver\`. When the component scrolls out of view, its internal DOM is cleared to save memory, and its dimensions (\`min-height\` and \`min-width\`) are preserved to prevent scrollbar jumping. State updates are preserved while hidden and applied when the component re-enters the viewport.
```js
class MyComponent extends Symbiote {
lazyMode = true;
}
```
This is most commonly used by adding the \`lazy\` attribute to an \`itemize\` container to optimize rendering of massive lists:
```html
{{title}}
{{description}}
```
## mcpToolMode
Enables experimental WebMCP auto-tool generation for bound event handlers. WebMCP is an optional extension, so import it before WebMCP-enabled components render:
```js
import Symbiote, { html } from '@symbiotejs/symbiote';
import '@symbiotejs/symbiote/webmcp';
Symbiote.mcpToolMode = true; // global opt-in
```
You can also enable it per component:
```js
class MyComponent extends Symbiote {
mcpToolMode = true;
}
```
See [WebMCP Experimental](./webmcp.md) for tool descriptors, naming, and browser requirements.
---
# Get Started
## NPM installation
```shell
npm i @symbiotejs/symbiote
```
> If you are using CDN module sharing approach, you should add the package to `devDependencies`, because it will be used for static analysis only (TypeScript and "Go to Definition" support).
Installation as a `dev` dependency:
```shell
npm install @symbiotejs/symbiote --save-dev
```
## HTTPS/CDN
To easily share Symbiote.js as a common dependency between independent application parts (widgets, micro-frontends, meta-applications), you can use one of the modern code CDNs:
```js
import Symbiote from 'https://esm.run/@symbiotejs/symbiote';
```
TypeScript support (my-types.d.ts):
```ts
declare module 'https://esm.run/@symbiotejs/symbiote' {
export * from '@symbiotejs/symbiote';
}
```
In some cases, you will need to add `maxNodeModuleJsDepth` setting to your `tsconfig.json` file:
```json
{
"compilerOptions": {
"allowJs": true,
"maxNodeModuleJsDepth": 2
}
}
```
You can also publish your own Symbiote.js build as a regular static file to your own server and use it via HTTPS. HTTPS-imports are supported in all modern browsers.
It's convenient to define a common base class for your application to manage the HTTPS dependency in one place:
```js
import Symbiote from 'https://esm.run/@symbiotejs/symbiote';
export class AppComponent extends Symbiote {
// Your code...
}
```
## Git submodule (optional)
Initial submodule connection:
```shell
git submodule add -b main https://github.com/symbiotejs/symbiote.js.git ./symbiote
```
Activation at the cloned host repository and getting updates:
```shell
git submodule update --init --recursive --remote
```
Switch to a certain revision:
```shell
cd symbiote && git checkout
```
`package.json` scripts section example:
```json
{
"scripts": {
"git-modules": "git submodule update --init --recursive --remote",
"sym-version": "cd symbiote && git checkout && cd ..",
"setup": "npm run git-modules && npm run sym-version && npm i"
}
}
```
Then:
```shell
npm run setup
```
## Your first Symbiote component
Create the HTML file `my-app.html`:
```html
```
That's it! Open this HTML file in your browser and check the result.
> To run this example, you'll need a browser and a text editor only. No installation, build setup or local server is required.
> **IMPORTANT**: `template`, `rootStyles` and `shadowStyles` are **static property setters** — they must be assigned **outside** the class body. Using `static template = html\`...\`` inside the class **will NOT work**.
## Export structure
### Core (`@symbiotejs/symbiote`)
`Symbiote` (default), `html`, `css`, `PubSub`, `DICT`, `animateOut`
### Utils (`@symbiotejs/symbiote/utils`)
`UID`, `setNestedProp`, `applyStyles`, `applyAttributes`, `create`, `kebabToCamel`, `reassignDictionary`
### WebMCP experimental (`@symbiotejs/symbiote/webmcp`)
`ToolDescriptor`, `installWebMCP`, `webMCPRegistry`, `syncWebMCPTools`, `unregisterWebMCPTools`, `getActiveWebMCPTools`
Individual module imports (tree-shaking):
```js
import Symbiote from '@symbiotejs/symbiote/core/Symbiote.js';
import { PubSub } from '@symbiotejs/symbiote/core/PubSub.js';
import { AppRouter } from '@symbiotejs/symbiote/core/AppRouter.js';
import { html } from '@symbiotejs/symbiote/core/html.js';
import { css } from '@symbiotejs/symbiote/core/css.js';
import { ToolDescriptor } from '@symbiotejs/symbiote/core/webmcp.js';
```
## Platform specs & standards
It's important to know what Web Components are in general. Some links to useful platform documentation (MDN):
- [Custom Elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements)
- [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM)
- [Templates and slots](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_templates_and_slots)
- [Constructable Stylesheets](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet)
- [CSS Custom Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties)
- [ECMAScript Modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import)
- [Import maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap)
---
# Lifecycle
Symbiote component is an extension of a native Custom Element, so it has all regular [lifecycle stages](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks):
- `constructor()`
- `connectedCallback()`
- `disconnectedCallback()`
- `adoptedCallback()`
- `attributeChangedCallback(name, oldValue, newValue)`
## Additional lifecycle callbacks
| Method | When called |
|--------|------------|
| `initCallback()` | Once, after state initialized, before render (if `pauseRender=true`) or normally after render |
| `renderCallback()` | Once, after template is rendered and attached to DOM |
| `destroyCallback()` | On disconnect, after delay, only if `readyToDestroy=true` |
`renderCallback()` is the most common place to describe component logic:
```js
class MyComponent extends Symbiote {
renderCallback() {
// You have access to data and all DOM API methods here
// this.ref, this.$, DOM children are all available
}
}
```
## Destruction and cleanup
By default, components are destroyed when disconnected from DOM (after a 100ms delay). This delay allows synchronous DOM moves (e.g. list reordering) without triggering destruction.
If you do **NOT** plan to permanently remove your component from DOM or keep it just in memory, disable destruction:
```js
class MyComponent extends Symbiote {
readyToDestroy = false;
}
```
Otherwise, the component will be destroyed on DOM detachment if you don't return it back with a synchronous DOM API call.
### `destructionDelay`
Configure the delay (in milliseconds) before cleanup in `disconnectedCallback`:
```js
class MyComponent extends Symbiote {
destructionDelay = 500; // default is 100
}
```
This is useful when components might be temporarily removed and re-added to the DOM (animations, transitions, sorting, caching, etc.) or for some in-memory calculations.
### `destroyCallback()`
Called when the component is about to be destroyed and removed from memory:
```js
class MyComponent extends Symbiote {
destroyCallback() {
// Clean up external resources, event listeners, etc.
}
}
```
## Exit animations
`animateOut(el)` delays element removal until CSS transitions finish. This works with the destruction lifecycle — the `[leaving]` attribute is set, the transition plays, and only then the element is removed from the DOM and destroyed:
```css
my-item {
opacity: 1;
transition: opacity 0.3s;
&[leaving] { opacity: 0; }
}
```
The itemize API uses `animateOut` automatically for item removal.
> More details in the [Animations](./animations.md) section.
---
# List Rendering
## Using `itemize` API
To create a dynamic list inside your component, use the `itemize` attribute on the list container element:
```js
class MyComponent extends Symbiote {
init$ = {
userList: [
{ firstName: 'John', secondName: 'Snow' },
{ firstName: 'Peter', secondName: 'Sand' },
],
};
}
MyComponent.template = html`
First name: {{firstName}}
Second name: {{secondName}}
`;
```
The `itemize` value points to a key in the component's data context. You can use any type of data context token or a computed list property:
Pop-up (parent must define `userList` in `init$`):
```js
html`
`;
```
> You can also use the `${{itemize: 'prop'}}` binding syntax if preferred — it produces the same result.
## List items
> **CRITICAL**: Items inside `itemize` are full Symbiote components with their own state scope.
> There are two patterns — **dumb items** and **smart items** — and they differ in how event handlers and logic are bound.
### Dumb items (inline ``)
When you define the item markup directly inside a `` tag, items have **no class definition** — they only receive data properties from the array. Any event handler, method, or additional data must come from an **external context** — not from the item itself.
All standard Symbiote context prefixes work inside dumb item templates:
| Prefix | Source | Example |
|--------|--------|---------|
| `^` | Pop-up (parent component) | `${{onclick: '^onItemClick'}}` |
| `/` | Named context | `${{onclick: 'APP/onItemClick'}}` |
| `*` | Shared context | `${{onclick: '*onItemClick'}}` |
The most common pattern is `^` (pop-up to parent):
```js
class MyList extends Symbiote {
init$ = {
userList: [
{ firstName: 'John', secondName: 'Snow' },
{ firstName: 'Peter', secondName: 'Sand' },
],
};
onItemClick(e) {
console.log('Item clicked');
}
}
MyList.template = html`
{{firstName}} {{secondName}}
`;
```
> **Context prefix is required here.** Without it, the binding looks for the handler on the item itself — which doesn't have it, so the event handler breaks silently.
This pattern is best for **simple, display-only items** where all logic lives outside the item.
### Smart items (custom `item-tag` component)
When you define a separate Symbiote component for items, each item has its **own class, template, methods, and state**. Handlers defined on the item component bind directly — **no `^` needed**:
```js
class UserCard extends Symbiote {
firstName = '';
secondName = '';
onCardClick() {
alert(`Hello ${this.$.firstName} ${this.$.secondName}!`);
}
}
UserCard.template = html`
{{firstName}} {{secondName}}
`;
UserCard.reg('user-card');
```
Use it in the parent list:
```js
html`
`;
```
> **No `^` needed** — `onCardClick` is the item's own method. You can still use `^` to reach the parent list component if needed (e.g., `${{onclick: '^removeItem'}}`).
This pattern is best for **items with their own logic, lifecycle, or complex templates**.
### Styling list items
By default, all list items are Symbiote components wrapped with a corresponding custom element. If you don't need an extra container for styling, use `display: contents` CSS — this is added to each item by default when you don't set custom tag names.
Use `item-tag` to assign a named tag for styling:
```css
user-card {
display: flex;
gap: 8px;
}
```
### Item template
We recommend wrapping item templates in the `` tag:
```js
html`
{{firstName}}
{{secondName}}
`;
```
> The `` tag helps the browser ignore specific tag behavior before the template is copied as item contents. When using an external component as a list item, template wrapping is not necessary.
## Data types and structure
Source data can be `Array` or `Object` collections. Each item descriptor should have a flat structure.
For `Object` collections, each item key is reflected via the `_KEY_` property:
```js
class MyComponent extends Symbiote {
init$ = {
userList: {
id1: { firstName: 'John', secondName: 'Snow' },
id2: { firstName: 'Peter', secondName: 'Sand' },
},
};
}
MyComponent.template = html`
ID: {{_KEY_}}
{{firstName}}
{{secondName}}
`;
```
## Dynamic updates
Assign a new data collection to trigger re-render:
```js
this.$.userList = await (await window.fetch('https://.io')).json();
```
Existing items are updated in-place via `set$`, new items appended, excess removed. Setting the value to `null` or `false` clears the entire list.
## Exit animations
Both itemize processors use `animateOut` automatically for item removal. Items with CSS `transition` + `[leaving]` styles will animate out before being removed:
```css
user-card {
opacity: 1;
transition: opacity 0.3s;
&[leaving] {
opacity: 0;
}
}
```
More details in the [Animations](./animations.md) section.
## Keyed itemize processor
For performance-critical lists, use the optional keyed itemize processor with reference-equality fast paths and key-based reconciliation:
```js
import { itemizeProcessor } from '@symbiotejs/symbiote/core/itemizeProcessor-keyed.js';
import { itemizeProcessor as defaultProcessor } from '@symbiotejs/symbiote/core/itemizeProcessor.js';
class BigList extends Symbiote {
constructor() {
super();
this.templateProcessors.delete(defaultProcessor);
this.templateProcessors = new Set([itemizeProcessor, ...this.templateProcessors]);
}
}
```
Up to **3× faster** for appends, **2×** for in-place updates, **32×** for no-ops.
## Lazy initialization (Massive lists)
For extremely large lists, you can add the `lazy` attribute to the `itemize` container. This enables `lazyMode` on all generated list items, heavily optimizing memory usage and initial render time:
```html
```
When `lazy` is used, items will defer their initialization until they enter the viewport using a global `IntersectionObserver`. When they scroll out of view, their internal DOM is automatically cleared to save memory, while their physical dimensions (`min-height` and `min-width`) are preserved to prevent the scrollbar from jumping. Any state updates that occur while the item is out of view are safely cached and applied seamlessly when the item re-enters the viewport.
## Nested lists
List nesting is fully supported. To render hierarchical data, define a custom item component that contains its own `itemize`:
```js
class CategoryItem extends Symbiote {
name = '';
init$ = { items: [] }
}
CategoryItem.template = html`
{{name}}
{{title}}
`;
CategoryItem.reg('category-item');
```
Then use it in the parent:
```js
class MyApp extends Symbiote {
init$ = {
categories: [
{
name: 'Frontend',
items: [
{ title: 'Symbiote.js' },
{ title: 'HTML' },
{ title: 'CSS' },
],
},
{
name: 'Backend',
items: [
{ title: 'Node.js' },
{ title: 'Rust' },
],
},
],
};
}
MyApp.template = html`
`;
```
Each nesting level is an independent Symbiote component with its own state scope, so updates at any level are handled efficiently.
## Custom raw web components as items
Symbiote.js allows using any custom component as a list item, including raw web components for maximum performance:
```js
class TableRow extends HTMLElement {
set rowData(data) {
data.forEach((cellContent, idx) => {
if (!this.children[idx]) {
this.appendChild(document.createElement('td'));
}
this.children[idx].textContent = cellContent;
});
}
}
window.customElements.define('table-row', TableRow);
class MyTable extends Symbiote {
init$ = {
tableData: [],
}
initCallback() {
window.setInterval(() => {
let data = [];
for (let i = 0; i < 10000; i++) {
data.push({ rowData: [i + 1, Date.now()] });
}
this.$.tableData = data;
}, 1000);
}
}
MyTable.rootStyles = css`
table-row {
display: table-row;
}
td {
border: 1px solid currentColor;
}
`;
MyTable.template = html`
Hello table!
`;
MyTable.reg('my-table');
```
> The `itemize` API can be used as a convenient benchmarking tool — test your components for performance by adding them into large lists.
---
# Properties
## Property initialization
For simple components, define properties as class fields — Symbiote picks them up automatically via fallback:
```js
class MyComponent extends Symbiote {
myProp = 'some value';
someOtherProp = 123;
oneMoreProp = true;
}
```
For complex components with shared context, computed props, or many reactive properties, use `init$` to explicitly declare state:
```js
class MyComponent extends Symbiote {
init$ = {
myProp: 'some value',
'*sharedProp': [],
'+computed': () => this.$.myProp.length,
}
}
```
The `init$` map should be a flat object that provides access to all values by top-level keys. Nested property key access (e.g. `'obj.prop'`) is **not supported** — use flat names instead.
## Reading and writing values
Use the `$` proxy (a standard JavaScript `Proxy` object) to access property values:
```js
class MyComponent extends Symbiote {
time = Date.now();
renderCallback() {
// Write a new value:
this.$.time = Date.now();
// Read current value:
console.log(this.$.time);
}
}
```
## Bulk updates
To change multiple property values at once, use the `set$` method:
```js
class MyComponent extends Symbiote {
init$ = {
firstProp: 'some value',
secondProp: 'some value',
}
renderCallback() {
this.set$({
firstProp: 'new value',
secondProp: 'new value',
});
}
}
```
The optional second argument `forcePrimitives` triggers callbacks even if the value hasn't changed (for primitive types):
```js
this.set$({ count: 0 }, true); // forces notification even though value may be the same
```
## Property subscription
To subscribe to property changes, use the `sub` method:
```js
class MyComponent extends Symbiote {
init$ = {
myProp: 'some value',
}
renderCallback() {
this.sub('myProp', (newVal) => {
console.log(newVal);
});
// This will invoke the subscription callback:
this.$.myProp = 'Changed value';
}
}
```
The third argument `init` (default `true`) controls whether the handler is called immediately with the current value:
```js
this.sub('myProp', handler, false); // don't call handler immediately
```
To trigger change notification manually:
```js
this.notify('myProp');
```
## Adding properties dynamically
Add properties to the context at runtime:
```js
this.add('newProp', 'initial value'); // single property
this.add$({ prop1: 'a', prop2: 'b' }); // bulk add
```
Check if a property exists:
```js
this.has('myProp'); // true / false
```
## Computed properties
Computed properties recalculate their values automatically when dependencies change. Use the `+` prefix for the property name.
### Local computed (auto-tracked)
Dependencies are recorded automatically when the function executes:
```js
class MyComponent extends Symbiote {
init$ = {
a: 1,
b: 2,
'+sum': () => this.$.a + this.$.b, // auto-tracks 'a' and 'b'
}
}
MyComponent.template = html`
{{+sum}}
`;
```
> Computed values are recalculated asynchronously (via `queueMicrotask`). The computed doesn't make an excess re-render if the final value has not changed.
### Cross-context computed (explicit deps)
When a computed property depends on external named context properties, you must declare dependencies explicitly:
```js
init$ = {
local: 0,
'+total': {
deps: ['GAME/score'],
fn: () => this.$['GAME/score'] + this.$.local,
},
};
```
To trigger manual recalculation:
```js
this.notify('+computedText');
```
## Property context and tokens
Symbiote components can interact with different types of properties, not just local ones. The context type is set by key prefixes:
| Prefix | Meaning | Description |
|--------|---------|-------------|
| _(none)_ | Local | Component's own local state |
| `^` | Pop-up | Direct access to upper level component property (must be in parent's `init$`) |
| `*` | Shared | Share properties between components in the same workflow context |
| `/` | Named | Access abstract named data context |
| `--` | CSS Data | Initiate property from CSS custom property value |
| `@` | Attribute | Bind state property to HTML attribute value |
| `+` | Computed | Auto-calculated derived value |
> More details in the [Context](./context.md) section.
---
# PubSub
`PubSub` is the main Symbiote.js entity for data manipulation. It implements the [Publish-Subscribe](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) pattern and provides everything you need to organize data flow inside and outside your components. It is integrated into the `Symbiote` base class but can also be used standalone:
```js
import { PubSub } from '@symbiotejs/symbiote';
let myDataCtx = PubSub.registerCtx({
myProp: 'some value',
myOtherProp: 'some other value',
});
```
## Static methods
### PubSub.registerCtx()
Create and register a `PubSub` instance.
```js
registerCtx(schema)
registerCtx(schema, id)
// > PubSub instance
```
| Argument | Type | Required | Description |
|:--|:--|:--|:--|
| `schema` | `Object` | yes | Property map |
| `id` | `String \| Symbol` | no | Context ID |
```js
let myDataCtx = PubSub.registerCtx({
myProp: 'some value',
myOtherProp: 'some other value',
}, 'MY_CTX_ID');
```
### PubSub.getCtx()
Get a PubSub object from the registry.
```js
getCtx(id)
getCtx(id, notify)
// > PubSub instance or null
```
| Argument | Type | Required | Description |
|:--|:--|:--|:--|
| `id` | `String \| Symbol` | yes | Context ID |
| `notify` | `Boolean` | no | Trigger notification on retrieval |
```js
let myDataCtx = PubSub.getCtx('MY_CTX_ID');
```
### PubSub.deleteCtx()
Remove a `PubSub` object from the registry and clear memory.
```js
PubSub.deleteCtx('MY_CTX_ID');
```
## Instance methods
### pub()
Publish a new property value.
| Argument | Type | Required | Description |
|:--|:--|:--|:--|
| `propertyKey` | `String` | yes | Property name |
| `newValue` | `*` | yes | Property value |
```js
myDataCtx.pub('propertyName', 'newValue');
```
### multiPub()
Publish multiple changes at once.
| Argument | Type | Required | Description |
|:--|:--|:--|:--|
| `propertyMap` | `Object` | yes | Key/value update map |
```js
myDataCtx.multiPub({
propertyName: 'new value',
otherPropertyName: 'other new value',
});
```
### sub()
Subscribe to property changes.
| Argument | Type | Required | Description |
|:--|:--|:--|:--|
| `propertyName` | `String` | yes | Property name |
| `handler` | `(newValue) => void` | yes | Update handler |
```js
myDataCtx.sub('propertyName', (newValue) => {
console.log(newValue);
});
```
### read()
Read a property value.
```js
myDataCtx.read('propertyName');
```
### has()
Check whether a property exists.
```js
myDataCtx.has('propertyName'); // true / false
```
### add()
Add a new property.
| Argument | Type | Required | Description |
|:--|:--|:--|:--|
| `propertyName` | `String` | yes | Property name |
| `initValue` | `*` | yes | Initial value |
```js
myDataCtx.add('propertyName', 'init value');
```
### notify()
Manually invoke all subscription handlers for a property.
```js
myDataCtx.notify('propertyName');
```
## Dev mode
Enable `PubSub.devMode` for verbose warnings (type mismatches, missing properties):
```js
Symbiote.devMode = true; // also sets PubSub.devMode
```
See [Dev Mode](./dev-mode.md) for details.
---
# Routing
Symbiote.js has a built-in SPA routing solution based on the standard [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API).
> In 3.x, `AppRouter` is imported separately from the main package:
> ```js
> import { AppRouter } from '@symbiotejs/symbiote/core/AppRouter.js';
> ```
> Or use the `full` entry point to get everything in one import:
> ```js
> import Symbiote, { html, css, AppRouter } from '@symbiotejs/symbiote/full';
> ```
> [!TIP]
> **Importmap users**: If you resolve `@symbiotejs/symbiote` and `AppRouter` via separate importmap entries (e.g. different CDN URLs), PubSub contexts will still work correctly — `PubSub.globalStore` is shared via `globalThis.__SYMBIOTE_PUBSUB_STORE` across all module copies.
## Path-based routing (recommended)
Routes with the `pattern` property use path-based URLs with `:param` extraction:
```js
import Symbiote, { html } from '@symbiotejs/symbiote';
import { AppRouter } from '@symbiotejs/symbiote/core/AppRouter.js';
const routerCtx = AppRouter.initRoutingCtx('R', {
home: { pattern: '/', title: 'Home', default: true },
user: { pattern: '/users/:id', title: 'User Profile' },
settings: { pattern: '/settings', title: 'Settings' },
notFound: { pattern: '/404', title: 'Not Found', error: true },
});
// Navigate programmatically:
AppRouter.navigate('user', { id: '42' });
// URL becomes: /users/42
// React to route changes in any component:
class AppShell extends Symbiote {
renderCallback() {
this.sub('R/route', (route) => {
console.log('Route:', route);
});
this.sub('R/options', (opts) => {
console.log('Params:', opts); // { id: '42' }
});
}
}
AppShell.template = html`
{{R/title}}
`;
AppShell.reg('app-shell');
```
## Query-string routing (legacy/alternative)
Routes **without** `pattern` use query-string mode automatically:
```js
const routerCtx = AppRouter.initRoutingCtx('R', {
home: { title: 'Home', default: true },
about: { title: 'About' },
error: { title: 'Error...', error: true },
});
AppRouter.navigate('about', { section: 'team' });
// URL becomes: ?about§ion=team
```
Mode is auto-detected: routes with `pattern` → path-based, without → query-string.
## Route guards
Register a guard function that runs before every navigation:
```js
let unsub = AppRouter.beforeRoute((to, from) => {
if (!isAuth && to.route === 'settings') {
return 'login'; // redirect to 'login' route
}
// return false to cancel navigation
// return nothing to proceed
});
unsub(); // remove guard
```
## Lazy loaded routes
Use `load` in route descriptors for dynamic imports (loaded once, cached):
```js
AppRouter.initRoutingCtx('R', {
settings: {
pattern: '/settings',
title: 'Settings',
load: () => import('./pages/settings.js'),
},
});
```
## Static methods
### AppRouter.initRoutingCtx()
```js
initRoutingCtx(id, routingMap)
// > PubSub instance
```
| Argument | Type | Required | Description |
|:--|:--|:--|:--|
| `id` | `String` | yes | Context ID |
| `routingMap` | `Object` | yes | Routing map |
`RouteDescriptor` type:
| Property | Type | Required | Description |
|:--|:--|:--|:--|
| `pattern` | `String` | no | URL path pattern (enables path-based mode) |
| `title` | `String \| Function` | no | Page title (string or `() => string` for dynamic/localized titles) |
| `default` | `Boolean` | no | Default route |
| `error` | `Boolean` | no | Error (404) route |
| `load` | `Function` | no | Lazy loader `() => import(...)` |
### AppRouter.navigate()
Navigate and dispatch route change event:
```js
AppRouter.navigate('user', { id: '42' });
```
> **Migration note**: `applyRoute()` from 2.x has been renamed to `navigate()` in 3.x.
### AppRouter.reflect()
Update the URL without triggering a route change event:
```js
AppRouter.reflect('user', { id: '42' });
```
### AppRouter.notify()
Read the current URL, run guards, lazy load if needed, and dispatch the route change event:
```js
AppRouter.notify();
```
### AppRouter.readAddressBar()
Read and parse the current URL:
```js
let { route, options } = AppRouter.readAddressBar();
```
### AppRouter.beforeRoute()
Register a route guard. Returns an unsubscribe function:
```js
let unsub = AppRouter.beforeRoute((to, from) => { ... });
unsub();
```
### AppRouter.setRoutingMap()
Extend routes dynamically:
```js
AppRouter.setRoutingMap({
new_route: { pattern: '/new', title: 'New Section' },
});
```
### AppRouter.setSeparator()
Set custom separator for query-string mode (default: `&`):
```js
AppRouter.setSeparator('@');
```
### AppRouter.setDefaultTitle()
Set default page title when route title is not defined. Accepts a string or a function:
```js
AppRouter.setDefaultTitle('My App');
AppRouter.setDefaultTitle(() => i18n.t('app.title'));
```
### Dynamic titles (i18n)
Both route `title` and `defaultTitle` accept a function `() => string` that is called at navigation time. This enables localized or computed page titles:
```js
AppRouter.initRoutingCtx('R', {
home: {
pattern: '/',
title: () => t('pages.home'),
default: true,
},
about: {
pattern: '/about',
title: () => t('pages.about'),
},
});
AppRouter.setDefaultTitle(() => t('app.title'));
```
The function is re-evaluated on every `navigate()` / `notify()`, so language changes take effect on the next navigation.
### AppRouter.removePopstateListener()
Remove the popstate event listener.
## SSR & isomorphic usage
`AppRouter` is SSR-safe. In Node.js or linkedom environments, `initRoutingCtx()` creates the PubSub context (so `{{R/title}}` bindings resolve during server rendering) while browser-only APIs are skipped automatically:
```js
import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
import { AppRouter } from '@symbiotejs/symbiote/core/AppRouter.js';
await SSR.init();
// Works in both browser and SSR — creates the 'R' context:
AppRouter.initRoutingCtx('R', {
home: { pattern: '/', title: 'Home', default: true },
about: { pattern: '/about', title: 'About' },
});
await import('./my-app.js');
let html = SSR.renderToString('my-app');
SSR.destroy();
```
In SSR, `navigate()`, `reflect()`, and `notify()` are no-ops — they return immediately without errors.
---
# Security
## Trusted Types
Symbiote.js is compatible with strict Content Security Policy (CSP) headers that require [Trusted Types](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API).
Template `innerHTML` writes use a Trusted Types policy when the API is available:
```js
// Symbiote creates a passthrough policy automatically:
// trustedTypes.createPolicy('symbiote', { createHTML: (s) => s })
```
### CSP configuration
To use Symbiote.js with strict Trusted Types:
```
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types symbiote
```
The policy name is `'symbiote'`.
> No sanitization is performed — templates are developer-authored, not user input. The policy exists to satisfy the Trusted Types API requirement.
## CSP nonce for SSR styles
When using [SSR](./ssr.md), component styles (`rootStyles` / `shadowStyles`) are serialized as inline `...
```
Then set a matching CSP header:
```
Content-Security-Policy: style-src 'nonce-'
```
> On the client side, Symbiote.js applies styles via `adoptedStyleSheets`, which is CSP-safe and requires no nonce.
---
# SSR and Your Server Setup
Practical recipes for serving Symbiote.js SSR with Node.js — static build or streaming, with Express, Fastify, or plain `http`.
> [!NOTE]
> These examples assume you've already read the [SSR basics](./ssr.md). Make sure `linkedom` is installed.
## Project structure
A typical isomorphic setup:
```
project/
├── app/ # shared state and config
├── components/ # Symbiote components (isoMode = true)
├── dist/ # build output and static assets
├── node/
│ ├── server.js # dev server
│ ├── ssr.js # static SSR build script
│ ├── imports.js # server-side component imports
│ └── main-tpl.html # HTML shell template
└── package.json
```
**`node/imports.js`** — import all isomorphic modules for server rendering:
```js
import '../app/app.js';
import '../components/app-shell/app-shell.js';
import '../components/nav-menu/nav-menu.js';
// ... other isomorphic components
```
**`node/main-tpl.html`** — HTML shell with a content placeholder:
```html
My App
{{CONTENT}}
```
---
## Static SSR build
Generate a static HTML file at build time. This is ideal for static hosting:
```js
// node/ssr.js
import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
import fs from 'fs';
await SSR.init();
await import('./imports.js');
let html = await SSR.processHtml('', {
nonce: Date.now().toString(36),
});
SSR.destroy();
const template = fs.readFileSync('./node/main-tpl.html', 'utf-8');
html = template.replace('{{CONTENT}}', html);
fs.writeFileSync('./dist/index.html', html);
```
```json
// package.json
{
"scripts": {
"ssr": "node ./node/ssr.js"
}
}
```
---
## Dev server with streaming SSR
A development server that serves SSR-streamed HTML on the root route and static assets from the project:
```js
// node/server.js
import http from 'http';
import fs from 'fs';
import path from 'path';
import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
const PORT = 3000;
const DIST_DIR = path.resolve('./dist');
const ROOT_DIR = path.resolve('.');
const MIME = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
};
// Init SSR once at startup:
await SSR.init();
await import('./imports.js');
const tpl = fs.readFileSync('./node/main-tpl.html', 'utf-8');
const [head, tail] = tpl.split('{{CONTENT}}');
function serveFile(filePath, res) {
try {
let stat = fs.statSync(filePath);
if (stat.isFile()) {
let ext = path.extname(filePath);
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
fs.createReadStream(filePath).pipe(res);
return true;
}
} catch {}
return false;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
if (url.pathname !== '/') {
// Try dist/ first, then project root (for ESM modules):
if (serveFile(path.join(DIST_DIR, url.pathname), res)) return;
if (serveFile(path.join(ROOT_DIR, url.pathname), res)) return;
res.writeHead(404);
res.end('Not found');
return;
}
// SSR streaming for root:
let nonce = Date.now().toString(36);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.write(head);
for await (let chunk of SSR.renderToStream('app-shell', {}, { nonce })) {
res.write(chunk);
}
res.end(tail);
});
server.listen(PORT, () => console.log(`\nSSR server: http://localhost:${PORT}\n`));
```
```json
// package.json
{
"scripts": {
"dev": "node ./node/server.js"
}
}
```
---
## Express
### Streaming SSR
```js
import express from 'express';
import fs from 'fs';
import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
await SSR.init();
await import('./node/imports.js');
const tpl = fs.readFileSync('./node/main-tpl.html', 'utf-8');
const [head, tail] = tpl.split('{{CONTENT}}');
const app = express();
app.use(express.static('./dist', { index: false }));
app.get('/', async (req, res) => {
let nonce = Date.now().toString(36);
res.type('html');
res.write(head);
for await (let chunk of SSR.renderToStream('app-shell', {}, { nonce })) {
res.write(chunk);
}
res.end(tail);
});
app.listen(3000, () => console.log('http://localhost:3000'));
```
### String SSR
```js
app.get('/', async (req, res) => {
let nonce = Date.now().toString(36);
let content = SSR.renderToString('app-shell', {}, { nonce });
res.type('html').send(tpl.replace('{{CONTENT}}', content));
});
```
---
## Fastify
### Streaming SSR
```js
import Fastify from 'fastify';
import fastifyStatic from '@fastify/static';
import fs from 'fs';
import path from 'path';
import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
await SSR.init();
await import('./node/imports.js');
const tpl = fs.readFileSync('./node/main-tpl.html', 'utf-8');
const [head, tail] = tpl.split('{{CONTENT}}');
const app = Fastify();
app.register(fastifyStatic, {
root: path.resolve('./dist'),
prefix: '/',
wildcard: false,
});
app.get('/', async (req, reply) => {
let nonce = Date.now().toString(36);
reply.type('text/html');
reply.raw.write(head);
for await (let chunk of SSR.renderToStream('app-shell', {}, { nonce })) {
reply.raw.write(chunk);
}
reply.raw.end(tail);
});
app.listen({ port: 3000 }, () => console.log('http://localhost:3000'));
```
### String SSR
```js
app.get('/', async (req, reply) => {
let nonce = Date.now().toString(36);
let content = SSR.renderToString('app-shell', {}, { nonce });
reply.type('text/html').send(tpl.replace('{{CONTENT}}', content));
});
```
---
## Key points
- **Init once** — call `SSR.init()` and import components at server startup, not per-request
- **`SSR.destroy()` is for build scripts only** — don't call it in a running server
- **Streaming** (`renderToStream`) gives faster TTFB on large pages
- **String** (`renderToString`) is simpler and works well for small pages or build steps
- **CSP nonce** — pass `{ nonce }` to add `nonce` attributes to SSR-generated `...content
'
SSR.destroy(); // cleanup globals
```
If `SSR.init()` was already called, `processHtml` reuses the existing environment; otherwise it auto-initializes and auto-destroys after.
## Using `html` helper on the server
You can define server-side templates using the `html` helper — it outputs clean HTML with `bind=` attributes:
```js
import { html } from '@symbiotejs/symbiote/core/html.js';
export default html`
0
`;
```
This transforms to:
```html
0
```
## `renderToString`
Render a single component to an HTML string:
```js
await SSR.init();
await import('./my-component.js');
let html = SSR.renderToString('my-component', { title: 'Hello' });
// => '
Hello
'
SSR.destroy();
```
## Streaming — `renderToStream`
For large pages, stream HTML chunks instead of building a string:
```js
import http from 'node:http';
import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
await SSR.init();
await import('./my-app.js');
http.createServer(async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.write('');
for await (let chunk of SSR.renderToStream('my-app')) {
res.write(chunk);
}
res.end('');
}).listen(3000);
```
## API reference
| Method | Description |
|--------|-------------|
| `SSR.init()` | `async` — creates linkedom document, polyfills CSSStyleSheet/NodeFilter/MutationObserver/adoptedStyleSheets, patches globals |
| `SSR.processHtml(html, options?)` | `async` — parses HTML string, renders all custom elements, returns processed HTML. Auto-inits if needed |
| `SSR.renderToString(tagName, attrs?, options?)` | Creates element, triggers `connectedCallback`, serializes to HTML string |
| `SSR.renderToStream(tagName, attrs?, options?)` | Async generator — yields HTML chunks (same output as `renderToString`, streamed for lower TTFB) |
| `SSR.destroy()` | Removes global patches, cleans up document |
**Options:**
| Property | Type | Description |
|----------|------|-------------|
| `nonce` | `string` | CSP nonce value to add to generated `
```
On the client, styles are applied via `adoptedStyleSheets` — no nonce needed.
## Shadow DOM output
Shadow components produce Declarative Shadow DOM markup with styles inlined:
```html
Content
Light DOM content here
```
## SSR context detection
`SSR.init()` sets `globalThis.__SYMBIOTE_SSR = true`. This is separate from the instance `ssrMode` flag:
| Flag | Scope | Purpose |
|------|-------|---------|
| `__SYMBIOTE_SSR` | Server (global) | Preserves binding attributes (`bind`, `ref`, `itemize`) in HTML output. Bypasses `ssrMode` effects |
| `ssrMode` | Client (instance) | Skips template injection, hydrates existing DOM with bindings |
| `isoMode` | Client (instance) | Isomorphic mode: hydrates if children exist, renders template otherwise |
> [!IMPORTANT]
> **SSR rendering is synchronous.** Async subscription callbacks (e.g. with `await import(...)`) will not affect SSR output — the HTML is serialized before the callback resolves. Any state that must appear in SSR output should be initialized synchronously via class properties or `init$`.
## Client-side hydration
Use `isoMode = true` to make components work in both SSR and client-only scenarios. It detects children automatically: hydrates pre-rendered content when it exists, renders from template otherwise. No conditional logic needed:
```js
class MyComponent extends Symbiote {
isoMode = true;
count = 0;
increment() {
this.$.count++;
}
}
MyComponent.template = html`
0
`;
MyComponent.reg('my-component');
```
> [!TIP]
> `isoMode` is the recommended default for isomorphic components. It works correctly whether the component was server-rendered or created dynamically on the client.
### Hydration flow
1. **Server**: `SSR.processHtml()` / `SSR.renderToString()` produces HTML with `bind=` / `itemize=` attributes preserved
2. **Client**: `isoMode` detects pre-rendered children → attaches bindings to existing DOM (no template injection)
3. State mutations on client update DOM reactively
> [!WARNING]
> **Text-node bindings (`{{prop}}`) are not hydratable.** They produce no `bind=` attribute in SSR output, so the client has no marker to re-attach the binding. The server-rendered value will display correctly, but won't update on the client. Use `${{textContent: 'prop'}}` for text that must stay reactive after hydration. Enable `devMode` to see warnings for this.
> [!WARNING]
> **CSS data bindings (`cssInit$`, `--prop`) use fallback values during SSR.** Computed styles are not available on the server — `getCssData()` returns `null` and the init value is used instead. If your component relies on CSS-driven configuration, ensure the `cssInit$` fallback is a sensible server-side default. Enable `devMode` to see warnings for this.
### `ssrMode` — strict SSR-only
For components that are **always** server-rendered and never created client-side, you can use `ssrMode = true` instead. Unlike `isoMode`, it unconditionally skips template injection — the component must have pre-rendered content:
```js
class MyComponent extends Symbiote {
ssrMode = true;
// ...
}
```
### `isVirtual` — clean HTML without wrappers
For server-only components that should produce clean HTML without custom element tags, use `isVirtual = true`. The component replaces itself with its template fragment — only the inner content appears in the output:
```js
class PageHeader extends Symbiote {
isVirtual = true;
init$ = {
title: 'My Page',
};
}
PageHeader.template = html``;
PageHeader.reg('page-header');
let html = SSR.renderToString('page-header');
// => '
My Page
'
// No wrapper in output
```
This is useful when you want the organizational benefits of components (encapsulated state, templates, styles) during development, but need standard HTML elements in the final output — for example, generating static pages, emails, or content consumed by systems that don't support custom elements.
---
# Styling
Symbiote.js utilizes the [CSSStyleSheet API](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet) for efficient in-memory style manipulation. It works similarly to the styles of built-in browser elements (inputs, video tags), but you have full control.
This API also helps parse CSS rules in JavaScript without Content Security Policy (CSP) violations.
You can style your components using any other styling approach — or combine them. Symbiote components support regular stylesheets for Document or Shadow Roots.
## Style interfaces
Every Symbiote component has two major static style interfaces:
### rootStyles (Light DOM)
Creates and adds a stylesheet via `adoptedStyleSheets` to the closest root in the document:
```js
class MyComponent extends Symbiote {}
MyComponent.rootStyles = css`
my-component {
display: block;
color: var(--text-color);
& button {
color: var(--accent);
}
}
`;
MyComponent.reg('my-component');
```
Use the custom tag name as the CSS selector.
### shadowStyles (Shadow DOM)
Creates and adds a stylesheet to the component's Shadow Root. If the Shadow Root doesn't exist, it's created automatically:
```js
class MyComponent extends Symbiote {}
MyComponent.shadowStyles = css`
:host {
display: block;
}
button {
color: red;
}
`;
MyComponent.reg('my-component');
```
> You can combine shadow scope styles with external scope styles for maximum control.
### addRootStyles / addShadowStyles
Append additional stylesheets:
```js
MyComponent.addRootStyles(anotherSheet);
MyComponent.addShadowStyles(anotherSheet);
```
## `css` tag function
The `css` tag function returns a `CSSStyleSheet` instance (constructable stylesheet):
```js
import { css } from '@symbiotejs/symbiote';
let styles = css`
h1 {
color: red;
}
`;
```
### CSS processing
You can add processing via `css.useProcessor()`:
```js
css.useProcessor((cssText) => {
return cssText.replaceAll('red', 'green');
});
```
Or add a processing sequence:
```js
class MyComponent extends Symbiote {}
let randomTag = 'tag-' + Math.round(Math.random() * Date.now());
MyComponent.reg(randomTag);
css.useProcessor(
(cssText) => cssText.replaceAll(' blue;', ' green;'),
(cssText) => cssText.replaceAll('random-tag', randomTag),
);
MyComponent.rootStyles = css`
random-tag {
background-color: blue;
}
`;
```
## SSR style output
When using [server-side rendering](./ssr.md):
- **rootStyles** → emitted as `