Symbiote.js
Tired of pointless re-renders? Bloated bundles? Chaotic data flow? Laggy UI? Magic abstractions and compiler quirks? Done with frameworks that add complexity instead of removing it?
Miss the vanilla flavor, but still want great DX?
So, what's the big idea?
Symbiote.js isn't trying to be yet another frontend library. Think of it more like a philosophy for web development that embraces the power of what's already built into your browser.
We believe in using modern, standard web technologies, not reinventing them. Symbiote.js simply adds a touch of modern developer experience (DX) on top of vanilla Custom Elements. You get reactive data bindings, flexible HTML-based templates, powerful state management, computed properties, built-in routing, SSR support, and easily extendible components — all with minimal boilerplate and zero build-step requirement.
Here are the three most important differences between Symbiote.js and other frameworks:
1. Natural DOM Extension Philosophy
No opaque abstractions. Unlike frameworks that wrap your code in complex abstractions (virtual DOM, JSX compilers, proprietary syntax), Symbiote.js works directly with the native DOM API. For you, this means:
- No Black Boxes: What you write is what the browser runs. No mandatory compilation steps, no additional magic.
- Native Speed: It leverages the browser's own optimizations.
- No Framework Lock-in: It plays nicely with any other web technology you want to use.
- Feather-Light: At only ~6kb (br/gzip), it won't weigh your pages down.
- Zero Dependencies: It's entirely built on native web platform features.
- Real DOM Elements: Symbiote components are actual Custom Elements, so you can interact with them just like any other DOM node.
- The Classic Trio: Just HTML, CSS, and JavaScript. Everything you need for solid, enterprise-ready work.
- Use the DOM API When You Need It: It's no longer considered an anti-pattern.
- Extensible: Easily add any features you need to your application's base class.
2. Runtime-Agnostic HTML Templates
Symbiote.js offers outstanding flexibility in how you customize and compose your UI. Templates are just standard HTML strings that can be defined or reused anywhere - client-side or server-side:
- Just HTML: Use standard tags and attributes with a simple binding syntax for reactivity.
htmlHelper Function: Automatically transforms JS object declarations into HTML binding attributes.- Plays Well With Others: Works seamlessly with any templating system or server-side technology.
- Loosely Coupled: More flexibility for customizations. One change doesn't break everything.
- Ready for SSR: A perfect fit for server-side rendering (SSR) and static site generation (SSG).
- Integrates with Anything: Easily use it alongside React, Vue, Angular, or any other framework.
// Define templates anywhere, client or server:
import html from '@symbiotejs/symbiote/core/html.js';
const myTemplate = html`
<div class="my-component" ${{onclick: 'showBubble'}}>
<h1>{{title}}</h1>
<div>{{content}}</div>
</div>
`;
3. App-wide State Management
Symbiote.js offers a fresh take on how components communicate and manage state:
- Powerful Data-Binding Syntax: Connect data and action handlers from any local or external data context with minimal boilerplate.
- Natural Data Flow: Leverage the DOM structure itself to naturally model your application's state.
- Compose with Ease: Components can share common properties effortlessly, much like native radio buttons. No prop-drilling or extra wrappers required.
- Loosely Coupled State: Components can share state without being tightly bound to one another.
- Global Named Data Contexts: Create app-wide data sources that any component can access by name prefix.
- Isomorphic State Management: Access data values on the server and the client in the exact same way, without any additional component logic.
- Simple Pub/Sub Pattern: Use fundamental data-bricks to build robust data flows of any complexity.
// Pop-up data context (^) - bind directly to some DOM-tree's ancestor component's state
html`<button ${{onclick: '^onButtonClicked'}}>Click me!</button>`;
// Explicitly shared data context (*)
html`<div>{{*sharedProperty}}</div>`;
// Global named data context (/)
html`<div>{{APP/someProperty}}</div>`;
Other Features
Itemize API
Render dynamic lists declaratively — no manual DOM manipulation, no key prop juggling. Just point itemize at a data array or object and define an inline <template> for each item:
<div itemize="users">
<template>
<span>{{name}}</span> — <span>{{role}}</span>
<button ${{onclick: '^removeUser'}}>Remove</button>
</template>
</div>
- Each item - is a component: Created automatically and connected to the proper data context.
- Custom items: Use
item-tagattribute to delegate rendering to a dedicated component for complex list items. - Auto-animated removals: Items with CSS transitions animate out automatically via the
[leaving]attribute — zero JS needed. - List nesting: Nested lists are supported for complex tree-like structured data.
- Optional keyed updates: Keyed processor for efficient reordering and minimal DOM mutations.
Server Side Rendering
Use same-code isomorphic components on server and client. Just one flag isoMode = true for efficient hydration. One SSR class for server-side markup generation. Streaming is also supported. Light DOM styles and Declarative Shadow DOM — all handled automatically during SSR output.
Built-in SPA Router
Full-featured client-side routing, right out of the box — no extra dependencies needed:
- Path-based routes with
:paramextraction - Route guards for authentication and access control
- Lazy loading of route components
- Named routing context — bind route data directly in templates
CSS-Driven Animations
Exit transitions with zero JS animation code. The animateOut helper sets a [leaving] attribute, waits for CSS transitionend, then removes the element. Works automatically with the Itemize list rendering API.
Enterprise-Grade Security
- Trusted Types compatible — template writes use a named
'symbiote'policy when the API is available. - Full CSP compliance — works with the strictest Content Security Policy headers.
TypeScript Support
Symbiote.js leverages a JSDoc declaration approach alongside *.d.ts files for complex and global types. This allows you to have robust type security without a mandatory transpilation step. It also enables you to seamlessly use the source code module-by-module in either JavaScript or TypeScript projects with zero additional setup.
Ecosystem
Symbiote.js stays close to the platform, so it works seamlessly with any popular library or CSS framework - there is no forced Shadow DOM isolating your components from the rest of the page. Need Three.js for 3D, D3 or Chart.js for data visualization, or any CSS utility library? Just use them as you normally would - no adapters, no wrappers, no compatibility layers.
For those who want a batteries-included starting point, there is JSDA-Kit - a ready-made toolkit built around Symbiote.js that lets you spin up a new web project in under a minute. JSDA-Kit works as a static site generator (SSG) for JAMStack sites, or as a lightweight framework for more complex dynamic applications - with built-in SSR, esbuild-powered bundling, automatic import maps, and a zero-config CLI. One scaffold command and you're up and running.
Symbiote.js vs React / Next.js vs Lit
Let's highlight the key differences with some popular options:
| Symbiote.js | React / Next.js | Lit | |
|---|---|---|---|
| Size | ~6 KB gzip | ~44 KB (React+DOM), Next.js adds much more | ~16 KB gzip |
| Virtual DOM | None — direct DOM | Virtual DOM + diffing | None — direct DOM |
| Compiler / Build | Not required | Required (JSX, bundler) | Not required |
| Standard | Native Web Components | Proprietary component model | Native Web Components |
| Templates | Context-less HTML strings | JSX (JS only) | Tagged template literals (JS only) |
| SSR | Built-in, streaming | Next.js (complex setup) | Limited (@lit-labs/ssr) |
| Routing | Built-in (SPA) | Next.js filesystem routing | Not included |
| State Management | Built-in PubSub + contexts | External (Redux, Zustand, etc.) | Not included |
| Computed Props | Auto-tracked | useMemo (manual deps) | Manual |
| CSS Scoping | Light DOM + Shadow DOM | CSS Modules / CSS-in-JS | Shadow DOM only |
| Framework Lock-in | None | High | Low |
| CSP / Trusted Types | Built-in | Depends on setup | Partial |
| Dependencies | 0 | Many | 1 (lit-html) |
Why not React? React requires a build pipeline, proprietary JSX syntax, a virtual DOM layer that adds overhead, and external libraries for routing, state, and SSR. A full Next.js stack is hundreds of kilobytes. Symbiote.js gives you all of that in ~6 KB with zero lock-in.
Why not Lit? Lit is a solid Web Components library, but it lacks built-in routing, state management, SSR streaming, and computed properties. It forces Shadow DOM for styling, which can be limiting. Symbiote.js offers more batteries-included features at a smaller footprint, plus unique concepts like DOM-based data context and CSS Data binding.
Where It Really Shines
- Building rich multipart widgets
- Crafting complex interactive components
- Micro-frontend architectures
- Creating reusable component libraries
- High-performance web apps
- Framework-agnostic solutions
- Meta-applications
- JamStack / Hybrid sites
The Core Goodness
- Loosely Coupled Architecture
- Ultralight: Just ~6kb (br/gzip)
- Wickedly Fast: Native DOM performance, no virtual DOM overhead
- Computed Properties: Auto-tracked, microtask-batched
- Memory Savvy: No wasteful immutable data structures
- Enterprise-Ready: Full CSP + Trusted Types compliance
- Type-Safe: Full TypeScript support
- Built-in Router: Path-based routing, guards, lazy loading
- SSR Out of the Box: Server rendering, streaming, hydration
- Reactive CSS Data: CSS custom properties as reactive state
- CSS-Driven Animations: Exit transitions with zero JS code
- Dev Mode: Verbose warnings for debugging with zero production overhead
- Plays Well Everywhere: Works with any stack
- Tidy: Automatic cleanup with configurable destruction delay
- Open Source: MIT licensed, of course!
Give It a Spin
<script type="importmap">
{
"imports": {
"@symbiotejs/symbiote": "https://esm.run/@symbiotejs/symbiote"
}
}
</script>
<script type="module">
import Symbiote, { html } from '@symbiotejs/symbiote';
export class MyComponent extends Symbiote {
// Initialize state:
init$ = {
count: 0,
}
onIncrement() {
this.$.count++;
}
}
// Define template:
MyComponent.template = html`
<h2>{{count}}</h2>
<button ${{onclick: 'onIncrement'}}>Click me!</button>
`;
// Register new tag name:
MyComponent.reg('my-component');
</script>
Use the component anywhere in your HTML:
<my-component></my-component>
This little HTML example has everything you need to get a Symbiote.js app running. No build tools, no installation, no local server — just open it in a browser. And of course, you can use it with TypeScript, bundlers, linters, and all that good stuff.