Hi, DEV!
My name is Alex, and I'm the maintainer of Symbiote.js - a library for building UI components and isomorphic applications using the latest web standards.
Today I'm going to talk about our important major update - version 3.
The Idea in Few Words
Symbiote.js is a lightweight (~5.9 kb brotli) wrapper around Custom Elements that adds reactivity, templates, and data layer management. No Virtual DOM, no special compiler, no mandatory build step - you can plug components right from a CDN.
But the most important thing is not the size or the absence of dependencies. The main thing is loose coupling. The entire design of the library is built around a crucial idea: a component can beforehand not know who configures it, what surrounds it, and in what context it is used. Configuration and data can come from HTML markup, from CSS, from a parent component in the DOM tree, or from a dedicated data context - and the component simply checks what it's ready to bind to, finding itself in a specific place at a specific time, and enters into a symbiosis.
Another important point: I know many developers are afraid to get involved with Shadow DOM. Well, in Symbiote.js, Shadow DOM is an optional feature. You can freely apply the most conservative approaches to styling, use the isolation layer only where necessary, and implement any hybrid scheme with maximum flexibility and efficiency.
Why Loose Coupling is Important
Loose coupling is not just an abstract architectural principle. These are concrete scenarios where hard dependencies between components create real problems:
- Embeddable widgets. Your component has to work on someone else's site - and you don't control its stack, CSS, and build process. If a widget requires a specific framework, provider, or build pipeline, you will complicate your life and the lives of others. If it gets configured via HTML attributes or CSS, it will easily fit anywhere.
- Micro-frontends. Several teams are building different parts of the same application. This works much better when components communicate via declarative contracts (HTML attributes, CSS variables, named data contexts) rather than direct JS imports and shared memory objects.
- CMS and no-code platforms. A content manager or designer configures a component via HTML markup or CSS without touching JavaScript. This is possible only if the component knows how to get configuration from these sources.
- Multi-team development. One team makes a design system, another - product features. The fewer explicit dependencies between modules, the fewer merge conflicts.
- Gradual migration. You can't rewrite everything at once. Symbiote allows you to embed new components into an existing React, Angular, Vue, Svelte or jQuery application - without wrappers, adapters, and double renders, organizing data exchange seamlessly.
- Runtime-dedicated modules without conditional logic. Since all binding in Symbiote.js is key-based (text strings like
'app/theme','*files','^onAction'), you can create modules that are specific to a runtime environment (server, browser, worker) without wrapping them inif/elseblocks. For example, the server importsnode-imports.jsand the browser importsbrowser-imports.js- both register components that bind to the same named context keys. Modules don't import each other, don't checktypeof window, and don't share objects in memory. They connect indirectly, through matching data keys. This is composition without coupling.
Symbiote.js is designed so that all these scenarios work out of the box. Below are the specific mechanisms.
Everything described below can be seen in action in the reference application with a live demo - a universal app with SSR streaming, SPA routing, declarative server-side Shadow DOM, localization, and basic patterns for connecting to reactive data. No bundlers, no build pipelines - pure ESM + import maps.
You can also see examples and play with live code without installation here: Live Examples
Configuration outside JavaScript
Most UI libraries dictate a similar and familiar way of configuring components - via props or attributes passed by a parent JS component. Symbiote.js expands this model: components can be configured from multiple data sources. All of them work equally transparently.
Describing bindings in HTML attributes
Any Symbiote.js template can be written as regular HTML that knows absolutely nothing about the JavaScript context:
<div bind="textContent: myProp"></div>
<button bind="onclick: handler; @hidden: !flag">Click me</button>
The bind attribute is exactly the declarative binding of an element to the reactive state of the component. You can write it by hand in an HTML file, generate it on the server, or create it in any template engine. The component's JavaScript code doesn't care - it will see bind in the DOM, substitute data, and connect handlers.
The html helper in JS files simply generates these attributes from a more convenient syntax:
html`<button ${{onclick: 'handler'}}>Click me</button>`
// → <button bind="onclick: handler">Click me</button>
The template can be stored wherever and however you like: in a JS file, in an HTML document, on the server. It is not tied to the execution context.
Configuration from CSS
This is perhaps the most unusual feature. Components can read CSS variables to initialize their state:
my-widget {
--label: 'Upload files';
}
@media (max-width: 768px) {
my-widget {
--label: 'Upload';
}
}
class MyWidget extends Symbiote {...}
MyWidget.template = html`
<button>{{--label}}</button>
`;
The component uses --label from CSS. Change the theme - parameters change. A media query triggers - an adaptive template is applied. Switch a class on a container - new configuration.
Why do this? Advanced themes, responsiveness without additional JS window listeners, localization, or simply passing parameters to embedded widgets from a host app's stylesheet.
External templates
A component can use a template defined anywhere in the HTML document:
class MyComponent extends Symbiote {
allowCustomTemplate = true;
}
<template id="custom-view">
<h1>{{title}}</h1>
<p>{{description}}</p>
</template>
<my-component use-template="#custom-view"></my-component>
This is useful when the component provides only data and handlers, and different markup variants form different representations in the DOM.
Component communication without prop drilling
Symbiote.js has several mechanisms for component communication that do not require explicit passing of data from parent to child or between instances.
Shared context (ctx + *)
Components can be grouped via an HTML attribute - same as the native HTML name attribute groups radio buttons:
<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(newFile) {
this.$['*files'] = [...this.$['*files'], newFile];
}
}
class FileList extends Symbiote {
init$ = { '*files': [] }
}
Three components, one shared data context gallery and a *files field. Without a shared parent component, without prop drilling, without an event bus. Put ctx="gallery" in the markup - components are linked, done.
The group can also be assigned via CSS:
.gallery-section {
--ctx: gallery;
}
<div class="gallery-section">
<upload-btn></upload-btn>
<file-list></file-list>
</div>
This is layout-driven grouping: the visual container defines the logical relationship between components.
An obvious use case: a complex widget where, for example, the file upload interface and the upload progress bar are in different parts of the host application's DOM tree.
Pop-up binding (^)
A component can access the properties of the nearest ancestor in the DOM tree - without imports, without knowing about the specific parent:
class ToolbarBtn extends Symbiote {}
ToolbarBtn.template = html`
<button ${{onclick: '^onAction'}}>{{^label}}</button>
`;
^onAction - Symbiote will go up the DOM and find the first component that has onAction registered in its state. Like a CSS cascade, only bottom-up, for data and handlers.
This allows creating reusable "dumb" components that adapt to the context of use.
Named data contexts
For situations where global or feature-dedicated state is needed, 3 lines of code is all it takes:
import { PubSub } from '@symbiotejs/symbiote';
// app/app.js - register once
PubSub.registerCtx({
darkTheme: true,
toDoList: [],
}, 'app');
In any component:
// Access:
this.$['app/darkTheme'] = false; // write
console.log(this.$['app/darkTheme']); // read
// Subscription:
this.sub('app/toDoList', (items) => {
console.log('Tasks:', items);
});
Without a store, without a provider, without useContext.
SSR and Universal Components
The killer feature of version 3 is server-side rendering. One flag, one code, one component, works everywhere, on the server and on the client:
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');
isoMode = true - if there is server content, the component hydrates it. If not, it renders the template from scratch. Without conditions, without 'use client'.
On the server:
import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
await SSR.init();
await import('./my-app.js');
let html = await SSR.processHtml('<my-app></my-app>');
SSR.destroy();
Hydration mismatches are impossible in principle, by design - there is no diffing. The server writes bind= attributes into the markup, the client reads them and attaches reactivity. No kilometer-long JSONs for hydration.
Components with Shadow DOM are also supported in SSR via the Declarative Shadow DOM (DSD) mechanism.
Static Site Generation (SSG)
The same SSR mechanism works for static generation - SSR.processHtml() returns a string that you simply write to a file:
import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
import fs from 'node:fs';
await SSR.init();
await import('./my-app.js');
let html = await SSR.processHtml(mainTemplate);
fs.writeFileSync('dist/index.html', html);
SSR.destroy();
No separate SSG framework required. The reference app uses exactly this approach - npm run ssr generates a fully rendered dist/index.html that can be opened directly in a browser or deployed to any static hosting (e.g. GitHub Pages). Client-side JS takes over from there and the app becomes an SPA.
Fun fact: recently I came across yet another heavily upvoted comment on Reddit claiming that Custom Elements are a purely browser API and that rendering web components on the server is impossible. So, friends, here we are easily doing the impossible. In general, web components as a group of standards are surrounded by many myths and misconceptions - and Symbiote helps fight those misconceptions.
What else is in the new version?
- Computed properties - derived state properties with dependency tracking.
- Exit animations - CSS enter and exit animations using
@starting-styleand[leaving]. - SPA Router - an optional module with path-based URLs, parameters, guards, and lazy loading.
- Keyed itemize - key-based reconciliation for lists, running multiple times faster for immutable data.
- CSP & Trusted Types - out-of-the-box compatibility with strict CSP headers.
- Dev mode - verbose warnings about problems in bindings and hydration.
Bundle Size
Size is not just a badge number. It is concrete loading time for your real users.
| 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 |
In the 5.9 kb base bundle, Symbiote gives you reactivity, data contexts, dynamic lists, animations, computed properties, hydration - all the most important stuff. For comparable functionality in Lit or React, you'll need additional packages. And I won't even mention SSR.
Summary
Symbiote.js is a library that significantly expands the capabilities of web components while staying close to the platform and standards. Configuration from CSS, binding via HTML attributes, data contexts without direct links between components in JS, minimal boilerplate, optimal DX. Components don't know about each other, but work together - on the client and on the server.
If you need widgets that embed into any environment, micro-frontends without extra hassle, complex hybrid framework-agnostic applications, or a reusable component library for different projects - take a look at Symbiote.js.
Even if you don't plan to use the library itself right away, but saw some interesting approaches - give the project a star, it really helps us Open Source developers not to lose heart.
Top comments (0)