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.
- 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.
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/symbioteimport Symbiote, { html, css } from '@symbiotejs/symbiote';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.
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.
| 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.
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 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}
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$.
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$.
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';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.
import { AppRouter } from '@symbiotejs/symbiote/core/AppRouter.js';
AppRouter.initRoutingCtx('R', {
home: { pattern: '/' },
profile: { pattern: '/user/:id' },
about: { pattern: '/about', lazyComponent: () => import('./about.js') },
});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.
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.
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.
- 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
| 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.
All modern browsers: Chrome, Firefox, Safari, Edge, Opera.
- Documentation
- Lit vs Symbiote.js - Side-by-side comparison
- Live Examples - Interactive Code Playground
- JSDA-Kit - All-in-one companion tool: server, SSG, bundling, import maps, and native Symbiote.js SSR integration
- AI Reference
- Changelog
Questions or proposals? Welcome to Symbiote Discussions! ❤️
© rnd-pro.com - MIT License