JSDA JSDA Stack Reference: Symbiote.js

Tests npm version npm downloads bundle size types license

Symbiote.js

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

Quick start

No install needed - run this directly in a browser:

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

npm i @symbiotejs/symbiote
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:

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:

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 and server setup recipes.

How it compares

Symbiote.jsNext.js (React)Lit (@lit-labs/ssr)
Isomorphic codeSame code, isoMode auto-detectsServer Components vs Client Components splitSame code, but load-order constraints
HydrationBinding-based - attaches to existing DOM, no diffinghydrateRoot() - must produce identical output or errorsRequires ssr-client + hydrate support module
Packages1 module + linkedom peer depFull framework buy-in3 packages: ssr, ssr-client, ssr-dom-shim
StreamingrenderToStream() async generatorrenderToPipeableStream()Iterable RenderResult
Mismatch handlingNot needed - bindings attach to whatever DOM existsHard errors if server/client output differsN/A
Template outputClean HTML with bind= attributesHTML with framework markersHTML with <!--lit-part--> comment markers
Lock-inNone - standard Web ComponentsFull framework commitmentLit-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

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:

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:

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

Itemize (dynamic reactive lists)

Render lists from data arrays with efficient updates:

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$()):

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

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:

<upload-btn ctx="gallery"></upload-btn>
<file-list  ctx="gallery"></file-list>
<status-bar ctx="gallery"></status-bar>
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)

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:

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:

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

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

Shadow DOM - opt-in isolation when needed:

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

my-widget {
  --label: 'Click me';
}
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

Bundle size

LibraryMinifiedGzipBrotli
Symbiote.js (core)18.9 kb6.6 kb5.9 kb
Symbiote.js (full, with AppRouter)23.2 kb7.9 kb7.2 kb
Lit 3.315.5 kb6.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

Questions or proposals? Welcome to Symbiote Discussions! ❤️


© rnd-pro.com - MIT License

arrow_forward Template Repoarrow_forward JSDA Manifestarrow_forward Symbiote.jsarrow_forward Cloud Images Toolkit
chevron_right