Symbiote.js
Simple, light and very powerful library to create embedded components for any purpose, with a data flow management included.
Install / Use
/learn @symbiotejs/Symbiote.jsREADME
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 withSSR.renderToStream(). Client-side hydration viassrModeattaches bindings to existing DOM without re-rendering. - Isomorphic components -
isoModeflag 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
AppRoutermodule with:paramextraction, route guards, and lazy loading. - Exit animations -
animateOut(el)for CSS-driven exit transitions, integrated into itemize API. - Dev mode -
Symbiote.devModeenables verbose warnings; importdevMessages.jsfor full human-readable messages. - DSD hydration -
ssrModesupports both light DOM and Declarative Shadow DOM. - Class property fallback - binding keys not in
init$fall back to own class properties/methods. - And more.
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.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
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:
- Object → reactive binding:
${{onclick: 'handler'}} - String/number → native concatenation:
${pageTitle}
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$.
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 ininit$.
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
Related Skills
node-connect
341.2kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
84.5kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
341.2kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
commit-push-pr
84.5kCommit, push, and open a PR
