[![Tests](https://github.com/symbiotejs/symbiote.js/actions/workflows/tests.yml/badge.svg)](https://github.com/symbiotejs/symbiote.js/actions/workflows/tests.yml)
[![npm version](https://img.shields.io/npm/v/@symbiotejs/symbiote)](https://www.npmjs.com/package/@symbiotejs/symbiote)
[![npm downloads](https://img.shields.io/npm/dm/@symbiotejs/symbiote)](https://www.npmjs.com/package/@symbiotejs/symbiote)
![bundle size](https://img.shields.io/badge/brotli-5.9_kb-blue)
![types](https://img.shields.io/badge/types-JSDoc+d.ts-blue)
![license](https://img.shields.io/badge/license-MIT-green)

# Symbiote.js

<img src="https://rnd-pro.com/svg/symbiote/index.svg" width="200" alt="Symbiote.js">

A lightweight, standards-first UI library built on Web Components. No virtual DOM, no compiler, no build step required - works directly in the browser. A bundler is recommended for production performance, but entirely optional. **~6kb** brotli / **~7kb** gzip.

Symbiote.js gives you the convenience of a modern framework while staying close to the native platform - HTML, CSS, and DOM APIs. Components are real custom elements that work everywhere: in any framework, in plain HTML, or in a micro-frontend architecture. And with **isomorphic mode**, the same component code works on the server and the client - server-rendered pages hydrate automatically, no diffing, no mismatch errors.

## What's new in v3

- **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** - 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.
- And [more](https://github.com/symbiotejs/symbiote.js/blob/main/CHANGELOG.md).

## Quick start

No install needed - run this directly in a browser:

```html
<script type="module">
  import Symbiote, { html } from 'https://esm.run/@symbiotejs/symbiote';

  class MyCounter extends Symbiote {
    count = 0;
    increment() {
      this.$.count++;
    }
  }

  MyCounter.template = html`
    <h2>{{count}}</h2>
    <button ${{onclick: 'increment'}}>Click me!</button>
  `;

  MyCounter.reg('my-counter');
</script>

<my-counter></my-counter>
```

Or install via npm:

```bash
npm i @symbiotejs/symbiote
```

```js
import Symbiote, { html, css } from '@symbiotejs/symbiote';
```

## Isomorphic Web Components

One component. Server-rendered or client-rendered - automatically. Set `isoMode = true` and the component figures it out: if server-rendered content exists, it hydrates; otherwise it renders from template. No conditional logic, no separate server/client versions:
```js
class MyComponent extends Symbiote {
  isoMode = true;
  count = 0;
  increment() {
    this.$.count++;
  }
}

MyComponent.template = html`
  <h2 ${{textContent: 'count'}}></h2>
  <button ${{onclick: 'increment'}}>Click me!</button>
`;
MyComponent.reg('my-component');
```

This exact code runs **everywhere** - SSR on the server, hydration on the client, or pure client rendering. No framework split, no `'use client'` directives, no hydration mismatch errors.

### SSR - one class, zero config

Server rendering doesn't need a virtual DOM, a reconciler, or framework-specific packages:

```js
import { SSR } from '@symbiotejs/symbiote/node/SSR.js';

await SSR.init();              // patches globals with linkedom
await import('./my-app.js');   // components register normally

let html = await SSR.processHtml('<my-app></my-app>');
SSR.destroy();
```

For large pages, stream HTML chunks with `SSR.renderToStream()` for faster TTFB. See [SSR docs](./docs/ssr.md) and [server setup recipes](./docs/ssr-server.md).

### How it compares

| | **Symbiote.js** | **Next.js (React)** | **Lit** (`@lit-labs/ssr`) |
|--|----------------|---------------------|----|
| **Isomorphic code** | Same code, `isoMode` auto-detects | Server Components vs Client Components split | Same code, but load-order constraints |
| **Hydration** | Binding-based - attaches to existing DOM, no diffing | `hydrateRoot()` - must produce identical output or errors | Requires `ssr-client` + hydrate support module |
| **Packages** | 1 module + `linkedom` peer dep | Full framework buy-in | 3 packages: `ssr`, `ssr-client`, `ssr-dom-shim` |
| **Streaming** | `renderToStream()` async generator | `renderToPipeableStream()` | Iterable `RenderResult` |
| **Mismatch handling** | Not needed - bindings attach to whatever DOM exists | Hard errors if server/client output differs | N/A |
| **Template output** | Clean HTML with `bind=` attributes | HTML with framework markers | HTML with `<!--lit-part-->` comment markers |
| **Lock-in** | None - standard Web Components | Full framework commitment | Lit-specific, but Web Components |

**Key insight:** There are no hydration mismatches because there's no diffing. The server produces HTML with binding attributes. The client reads those attributes and adds reactivity. That's it.

## Core concepts

### Reactive state

```js
class TodoItem extends Symbiote {
  text = '';
  done = false;
  toggle() {
    this.$.done = !this.$.done;
  }
}

TodoItem.template = html`
  <span ${{onclick: 'toggle'}}>{{text}}</span>
`;
```

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 - context-free, easy to test, easy to move between files:

```js
// Separate file: my-component.template.js
import { html } from '@symbiotejs/symbiote';

export default html`
  <h1>{{title}}</h1>
  <button ${{onclick: 'doSomething'}}>Go</button>
`;
```

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 with efficient updates:
```js
class TaskList extends Symbiote {
  tasks = [
    { name: 'Buy groceries' },
    { name: 'Write docs' },
  ];
  init$ = {
    // Needs to be defined in init$ for pop-up binding to work
    onItemClick: () => {
      console.log('clicked!');
    },
  }
}

TaskList.template = html`
  <div itemize="tasks">
    <template>
      <div ${{onclick: '^onItemClick'}}>{{name}}</div>
    </template>
  </div>
`;
```

Items have their own state scope. Use the **`^` prefix** to reach higher-level component properties and handlers - `'^onItemClick'` binds to the parent's `onItemClick`, not the item's. Properties referenced via `^` must be defined in the parent's `init$`.

> **Performance Tip:** For massive lists, add the `lazy` attribute to the container (`<div itemize="tasks" lazy>`). It defers component initialization until they enter the viewport and cleans them up when they leave, heavily optimizing memory and rendering performance.

### 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
<!-- Text binding to parent property: -->
<div>{{^parentTitle}}</div>

<!-- Handler binding to parent method: -->
<button ${{onclick: '^parentHandler'}}>Click</button>
```

> **Note:** Class property fallbacks are not checked by the `^` walk - the parent must define the property in `init$`.

### Named data contexts

Share state across components without prop drilling:

```js
import { PubSub } from '@symbiotejs/symbiote';

PubSub.registerCtx({
  user: 'Alex',
  theme: 'dark',
}, 'APP');

// Any component can read/write:
this.$['APP/user'] = 'New name';
```

### Shared context (`*`)

Inspired by native HTML `name` attributes - like how `<input name="group">` groups radio buttons - the `ctx` attribute groups components into a shared data context. Components with the same `ctx` value share `*`-prefixed properties:

```html
<upload-btn ctx="gallery"></upload-btn>
<file-list  ctx="gallery"></file-list>
<status-bar ctx="gallery"></status-bar>
```

```js
class UploadBtn extends Symbiote {
  init$ = { '*files': [] }

  onUpload() {
    this.$['*files'] = [...this.$['*files'], newFile];
  }
}

class FileList extends Symbiote {
  init$ = { '*files': [] }
}

class StatusBar extends Symbiote {
  init$ = { '*files': [] }
}
```

All three components access the same `*files` state - no parent component, no prop drilling, no global store boilerplate. Just set `ctx="gallery"` in HTML and use `*`-prefixed properties. This makes it trivial to build complex component relationships purely in markup, with ready-made components that don't need to know about each other.

The context name can also be inherited via CSS custom property `--ctx`, enabling layout-driven grouping.

### Routing (optional module)

```js
import { AppRouter } from '@symbiotejs/symbiote/core/AppRouter.js';

AppRouter.initRoutingCtx('R', {
  home:    { pattern: '/' },
  profile: { pattern: '/user/:id' },
  about:   { pattern: '/about', lazyComponent: () => import('./about.js') },
});
```

### Exit animations

CSS-driven transitions with zero JS animation code:

```css
task-item {
  opacity: 1;
  transition: opacity 0.3s;

  @starting-style { opacity: 0; }  /* enter */
  &[leaving] { opacity: 0; }       /* exit  */
}
```

`animateOut(el)` sets `[leaving]`, waits for `transitionend`, then removes. Itemize uses this automatically.

### Styling

Shadow DOM is **optional** in Symbiote - use it when you need isolation, skip it when you don't. This gives full flexibility:

**Light DOM** - style components with regular CSS, no barriers:

```js
MyComponent.rootStyles = css`
  my-component {
    display: flex;
    gap: 1rem;

    & button { color: var(--accent); }
  }
`;
```

**Shadow DOM** - opt-in isolation when needed:

```js
class Isolated extends Symbiote {}

Isolated.shadowStyles = css`
  :host { display: block; }
  ::slotted(*) { margin: 0; }
`;
```

All native CSS features work as expected: CSS variables flow through shadow boundaries, `::part()` exposes internals, modern nesting, `@layer`, `@container` - no framework abstractions in the way. Mix light DOM and shadow DOM components freely in the same app.

### CSS Data Binding

Components can read CSS custom properties as reactive state via `cssInit$`:

```css
my-widget {
  --label: 'Click me';
}
```

```js
class MyWidget extends Symbiote {...}

MyWidget.template = html`
  <span>{{--label}}</span>
`;
```

CSS values are parsed automatically - quoted strings become strings, numbers become numbers. Call `this.updateCssData()` to re-read after runtime CSS changes. This enables CSS-driven configuration: theme values, layout parameters, or localized strings - all settable from CSS without touching JS.

## Best for

- **Complex widgets** embedded in any host application
- **Micro frontends** - standard custom elements, no framework coupling
- **Reusable component libraries** - works in React, Vue, Angular, or plain HTML
- **SSR-powered apps** - lightweight server rendering without framework lock-in
- **Framework-agnostic solutions** - one codebase, any context

## Bundle size

| Library | Minified | Gzip | Brotli |
|---------|----------|------|--------|
| **Symbiote.js** (core) | 18.9 kb | 6.6 kb | **5.9 kb** |
| **Symbiote.js** (full, with AppRouter) | 23.2 kb | 7.9 kb | **7.2 kb** |
| **Lit** 3.3 | 15.5 kb | 6.0 kb | **~5.1 kb** |
| **React 19 + ReactDOM** | ~186 kb | ~59 kb | **~50 kb** |

Symbiote and Lit have similar base sizes, but Symbiote's **5.9 kb** core includes more built-in features: global state management, lists (itemize API), exit animations, computed properties etc. Lit needs additional packages for comparable features. React is **~8× larger** before adding a router, state manager, or SSR framework.

## Browser support

All modern browsers: Chrome, Firefox, Safari, Edge, Opera.

## Docs & Examples

- [Documentation](https://github.com/symbiotejs/symbiote.js/blob/main/docs/README.md)
- [Lit vs Symbiote.js](https://github.com/symbiotejs/symbiote.js/blob/main/docs/lit-vs-symbiote.md) - Side-by-side comparison
- [Live Examples](https://rnd-pro.com/symbiote/3x/examples/) - Interactive Code Playground
- [JSDA-Kit](https://github.com/rnd-pro/jsda-kit) - All-in-one companion tool: server, SSG, bundling, import maps, and native Symbiote.js SSR integration
- [AI Reference](https://github.com/symbiotejs/symbiote.js/blob/main/AI_REFERENCE.md)
- [Changelog](https://github.com/symbiotejs/symbiote.js/blob/main/CHANGELOG.md)

**Questions or proposals? Welcome to [Symbiote Discussions](https://github.com/symbiotejs/symbiote.js/discussions)!** ❤️

---

© [rnd-pro.com](https://rnd-pro.com) - MIT License
