Symbiote.js: Code Examples
Basics
Basic Example
A minimal counter component -- shows state initialization, reactive property access via this.$, and inline event binding in the template.
Live Example ref.js ref.htmlCode Reference
import Symbiote, { html } from '@symbiotejs/symbiote';
class MyComponent extends Symbiote {
count = 0;
increment() {
this.$.count++;
}
}
MyComponent.template = html`
<h2>{{count}}</h2>
<button ${{onclick: 'increment'}}>Click me!</button>
`;
MyComponent.reg('my-component');
<my-component></my-component>
Attributes
Demonstrates three attribute binding strategies: auto-mapping with @attr prefix in init$, manual mapping via bindAttributes, and property binding (@checked) to reflect state onto a DOM element property.
Live Example ref.js ref.htmlCode Reference
import Symbiote, { html } from '@symbiotejs/symbiote';
class MyComponent extends Symbiote {
init$ = {
'@attr': 'initial value', // Automatically mapped from DOM attribute
prop: 'initial value',
checked: true,
}
}
// Manual mapping from attribute to state property
MyComponent.bindAttributes({
'data-attr': 'prop',
});
MyComponent.template = html`
<h2>{{@attr}}</h2>
<div>{{prop}}</div>
<input type="checkbox"
${{/* bind property to template attribute: */'@checked': 'checked'}}
>
`;
MyComponent.reg('my-component');
<my-component attr="CUSTOM VALUE" data-attr="CUSTOM VALUE 2"></my-component>
Tag Names
Shows that custom element tag names can be set explicitly with Com.reg('my-tag'), auto-generated via Com.is (no name collision), and referenced dynamically in templates using template literals.
Live Example ref.js ref.htmlCode Reference
import Symbiote, { html } from '@symbiotejs/symbiote';
class Com1 extends Symbiote {}
Com1.template = `<button>Component 1</button>`;
// Tag name is set:
Com1.reg('my-component');
class Com2 extends Symbiote {}
Com2.template = `<button>Component 2</button>`;
// Tag name is not provided ^
// In this case, it will be generated automatically without collisions
class MyApp extends Symbiote {}
// Usage:
MyApp.template = html`
<h2>Tag name is defined explicitly (${Com1.is}), but used dynamically:</h2>
<${Com1.is} />
<h2>Auto-generated tag name: ${Com2.is}</h2>
<${Com2.is} />
<h2>Tag name is used in markup directly:</h2>
<my-component />
`;
MyApp.reg('my-app');
<my-app></my-app>
Auto closing tag syntax <my-component /> is supported with html-helper function.
Templates
Manual Template Rendering
Calls this.render(htmlString) from initCallback to render a template at runtime from a plain string, bypassing the static template property.
Live Example ref.js ref.htmlCode Reference
import Symbiote from '@symbiotejs/symbiote';
// Direct template rendering form a string:
const HTML = '<h2>{{text}}</h2>';
class MyApp extends Symbiote {
text = 'Hello world!';
initCallback() {
this.render(HTML);
}
}
MyApp.reg('my-app');
<my-app></my-app>
Template Processor (Custom Styling)
Adds a templateProcessors hook that reads a custom css attribute on elements and applies inline styles from a JS object map -- a pattern for JS-based styling without CSS files.
Live Example ref.js ref.htmlCode Reference
import Symbiote from '@symbiotejs/symbiote';
// @ts-ignore
import { applyStyles } from 'https://cdn.jsdelivr.net/npm/@symbiotejs/[email protected]/utils/+esm';
const styles = {
host: {
'display': 'inline-block',
'padding': '20px',
'border': '1px solid currentColor',
},
first_name: {
'color': '#f00',
'font-size': '20px',
},
last_name: {
'color': '#00f',
'font-size': '18px',
},
};
class StyledComponent extends Symbiote {
constructor() {
super();
this.templateProcessors.add((fr) => {
let cssElArr = [...fr.querySelectorAll('[css]')];
cssElArr.forEach((el) => {
let cssName = el.getAttribute('css');
// @ts-ignore
applyStyles(el, styles[cssName]);
});
});
applyStyles(this, styles.host);
}
}
class MyApp extends StyledComponent {}
MyApp.template = `
<div css="first_name">{{firstName}}</div>
<div css="last_name">{{lastName}}</div>
`;
MyApp.bindAttributes({
'first-name': 'firstName',
'last-name': 'lastName',
});
MyApp.reg('my-app');
<my-app first-name="John" last-name="Snow"></my-app>
External Custom Template
Sets allowCustomTemplate = true so the component accepts a use-template attribute pointing to a <template> element in the document, enabling consumer-defined rendering without subclassing.
Live Example ref.js ref.htmlCode Reference
import Symbiote from '@symbiotejs/symbiote';
class MyCom extends Symbiote {
allowCustomTemplate = true;
text = 'MY TEXT';
}
MyCom.reg('my-com');
<template id="my-tpl">
<h1>{{text}}</h1>
</template>
<my-com use-template="template#my-tpl"></my-com>
Data Contexts
Named Context Usage
Registers two named PubSub contexts (USER, APP) externally. A stateless component binds to both via CTX_NAME/prop syntax in its template -- no own init$ needed.
Live Example ref.js ref.htmlCode Reference
import Symbiote, { html, PubSub } from '@symbiotejs/symbiote';
PubSub.registerCtx({
name: 'rnd-pro',
login: () => {
alert('Logged in');
},
}, 'USER');
const appCtx = PubSub.registerCtx({
text: 'Some text',
onButtonClick: () => {
console.log(appCtx.read('text'));
PubSub.getCtx('USER').pub('name', 'NEW NAME ' + Date.now());
}
}, 'APP');
// This component doesn't have own state properties:
class MyDumbComponent extends Symbiote {}
// But it can use data bindings from any external data source
// in it's template:
MyDumbComponent.template = html`
<h3>User name: {{USER/name}}</h3>
<button ${{onclick: 'USER/login'}}>Login</button>
<button ${{onclick: 'APP/onButtonClick'}}>Click me!</button>
`;
MyDumbComponent.reg('my-dumb-component');
<my-dumb-component></my-dumb-component>
Shared Context
Two instances of the same component share state by carrying the same ctx attribute or by inheriting the --ctx CSS custom property from a parent element -- click one to update both.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote from '@symbiotejs/symbiote';
class CtxEl extends Symbiote {
init$ = {
'*time': 'Click me to show time!',
};
renderCallback() {
this.onclick = () => {
this.$['*time'] = Date.now();
};
}
}
CtxEl.template = '{{*time}}';
CtxEl.reg('ctx-el');
<h3>Manual data context name setting (same level elements):</h3>
<ctx-el ctx="ctx1"></ctx-el>
<ctx-el ctx="ctx1"></ctx-el>
<h3>CSS based data context (cascading):</h3>
<div style="--ctx: 'ctx1'">
<ctx-el>
<ctx-el></ctx-el>
</ctx-el>
</div>
ctx-el {
display: inline-block;
border: 1px solid #00f;
padding: 20px;
user-select: none;
}
Context Types
Illustrates all four context scopes in a single component: local (localCtxProp), attribute-bound (attributeProp), named external (X/namedCtxProp), and global shared (*sharedCtxProp). A child component reads the attribute-bound value via parent-traversal prefix (^).
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote, { html } from '@symbiotejs/symbiote';
class MyApp extends Symbiote {
init$ = {
localCtxProp: 'LOCAL',
attributeProp: 'Initial value...',
'X/namedCtxProp': 'NAMED',
'*sharedCtxProp': 'SHARED',
};
onUpdate() {
let updStr = ' updated... ';
this.$.localCtxProp += updStr;
this.$.attributeProp += updStr;
this.$['X/namedCtxProp'] += updStr;
this.$['*sharedCtxProp'] += updStr;
}
}
MyApp.template = html`
<div>{{localCtxProp}}</div>
<div>{{attributeProp}}</div>
<div>{{X/namedCtxProp}}</div>
<div>{{*sharedCtxProp}}</div>
<button ${{onclick: 'onUpdate'}}>Update</button>
<inner-el></inner-el>
`;
MyApp.bindAttributes({
'attr-test': 'attributeProp',
});
MyApp.reg('my-app');
class InnerEl extends Symbiote {}
InnerEl.template = '<h1>{{^attributeProp}}</h1>';
InnerEl.reg('inner-el');
<my-app attr-test="HTML ATTRIBUTE VALUE" ctx="my-ctx"></my-app>
my-app {
display: block;
> * {
border: 1px solid green;
margin: 10px;
padding: 10px;
}
inner-el {
display: inline-block;
}
}
CSS Data
Reads CSS custom properties (--heading, --text) directly as template bindings. Different instances get different content purely through CSS class rules -- no JS data passing needed.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote from '@symbiotejs/symbiote';
class MyCom extends Symbiote {}
MyCom.template = `
<h2>{{--heading}}</h2>
<div>{{--text}}</div>
`;
MyCom.reg('my-com');
<my-com class="css-data-1"></my-com>
<my-com class="css-data-2"></my-com>
my-com {
display: block;
padding: 10px;
margin: 10px;
border: 1px solid green;
}
.css-data-1 {
--heading: 'CSS Data 1';
--text: 'Some text...';
}
.css-data-2 {
--heading: 'CSS Data 2';
--text: 'Some other text...';
}
Lists
Dynamic List Rendering (Itemize API)
Generates 1000 table rows via the itemize attribute on <table>, using a dedicated TableRow component per row. Demonstrates high-performance list rendering with renderCallback for row-level interaction.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote, { html } from '@symbiotejs/symbiote';
// Dynamic list item component:
class TableRow extends Symbiote {
renderCallback() {
this.onclick = () => {
this.classList.toggle('selected');
};
}
}
TableRow.template = `
<td>{{rowNum}}</td>
<td>Random number: {{randomNum}}</td>
<td>{{date}}</td>
`;
TableRow.reg('table-row');
// Dynamic list wrapper component:
class TableApp extends Symbiote {
tableData = [];
generateTableData() {
let data = [];
for (let i = 0; i < 1000; i++) {
data.push({
rowNum: i + 1,
randomNum: Math.random() * 100,
date: Date.now(),
});
}
this.$.tableData = data;
}
}
TableApp.template = html`
<button ${{onclick: 'generateTableData'}}>Generate data</button>
<table itemize="tableData" item-tag="table-row"></table>
`;
TableApp.reg('table-app');
<table-app></table-app>
table-row {
display: table-row;
}
table-row.selected {
background-color: rgba(255, 0, 200, .3);
}
td {
background-color: rgba(0, 0, 0, .1);
padding: 2px;
}
Alternative List Rendering (DOM API)
Manages a to-do list by directly appending/removing ListItem custom elements via DOM API. Uses Symbiote.animateOut for animated removal and @starting-style CSS for entry animation.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote, { html } from '@symbiotejs/symbiote';
// Item element:
class ListItem extends Symbiote {
onRemove() {
Symbiote.animateOut(this);
}
get checked() {
return this.ref.checkbox.checked;
}
clear() {
this.$.text = '';
};
renderCallback() {
this.ref.edit.focus();
}
}
ListItem.template = html`
<input ref="checkbox" type="checkbox">
<div
ref="edit"
contenteditable="true"
${{textContent: 'text'}}></div>
<button ${{onclick: 'onRemove'}}>Remove Item</button>
`;
ListItem.reg('list-item');
// Application element:
class MyApp extends Symbiote {
get items() {
return [...this.ref.list_wrapper.children];
}
onAddItem() {
this.ref.list_wrapper.appendChild(new ListItem());
}
onClearChecked() {
this.items.forEach((item) => {
if (item.checked) {
item.clear();
}
});
}
onRemoveChecked() {
this.items.forEach((item) => {
if (item.checked) {
Symbiote.animateOut(item);
}
});
}
renderCallback() {
// Add first item:
this.onAddItem();
}
}
MyApp.template = html`
<div ref="list_wrapper"></div>
<div class="toolbar">
<button ${{onclick: 'onAddItem'}}>Add Item</button>
<button ${{onclick: 'onClearChecked'}}>Clear Checked</button>
<button ${{onclick: 'onRemoveChecked'}}>Remove Checked</button>
</div>
`;
MyApp.reg('my-app');
<my-app></my-app>
button {
cursor: pointer;
white-space: nowrap;
margin-left: 10px;
}
list-item {
padding: 10px;
display: grid;
grid-template-columns: min-content auto min-content;
border-bottom: 1px solid currentColor;
transition: .4s;
@starting-style {
opacity: 0;
transform: translateY(20px);
}
&[leaving] {
opacity: 0;
transform: translateX(100px);
}
}
list-item > [type="checkbox"] {
margin-right: 10px;
}
list-item > [contenteditable] {
padding: 5px;
}
list-item > [contenteditable]:empty::before {
content: "List item...";
opacity: .3;
}
list-item:hover {
background-color: rgba(0, 0, 0, .05);
}
my-app > .toolbar {
padding: 10px;
display: flex;
}
Nested Lists (Structured Data)
Uses processInnerHtml = true so that itemize and bind directives are parsed from the component's inner HTML rather than a JS template. Nested itemize attributes walk a multi-level data tree.
Live Example ref.js ref.htmlCode Reference
import Symbiote from '@symbiotejs/symbiote';
function nestedDataGen(idx) {
let data = [];
for (let i = 0; i < 3; i++) {
data.push({
nestedName: 'Nested: ' + (i + 1),
nestedText: Date.now(),
nestedData: [
{
nestedName: '111',
nestedText: Date.now(),
},
{
nestedName: '222',
nestedText: Date.now(),
},
],
});
}
return data;
}
// Dynamic list wrapper component:
class NestApp extends Symbiote {
// Flag:
processInnerHtml = true;
// Properties:
data = [];
buttonActionName = 'Generate';
// Methods:
onGenerateData() {
this.set$({
buttonActionName: 'Update',
});
let data = [];
for (let i = 0; i < 3; i++) {
data.push({
name: i + 1,
nestedData: nestedDataGen(i),
});
}
this.$.data = data;
}
}
NestApp.reg('nest-app');
<nest-app>
<button bind="onclick: onGenerateData">{{buttonActionName}} list data</button>
<div itemize="data">
<div>{{name}}</div>
<div itemize="nestedData">
<div>{{nestedName}}</div>
<div>{{nestedText}}</div>
<div itemize="nestedData">
<div>{{nestedName}}</div>
<div>{{nestedText}}</div>
</div>
</div>
</div>
</nest-app>
CSS Defined Table Rendering
Renders a table using custom elements (table-css, table-row, td-css) styled with display: table / table-row / table-cell via CSS -- no native <table> elements required. Data is injected via element.$.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote from '@symbiotejs/symbiote';
class MyTable extends Symbiote {
tableData = [];
onSelect(e) {
e.target?.closest('table-row')?.classList.toggle('selected');
}
}
MyTable.template = /*html*/ `
<table-css
bind="onclick: onSelect"
itemize="tableData"
item-tag="table-row">
<td-css>{{rowNumber}}</td-css>
<td-css>{{date}}</td-css>
</table-css>
`;
MyTable.reg('my-table');
<my-table></my-table>
<script>
window.onload = () => {
document.querySelector('my-table').$.tableData = [
{ rowNumber: 1, date: Date.now() },
{ rowNumber: 2, date: Date.now() },
{ rowNumber: 3, date: Date.now() },
];
}
</script>
table-css {
display: table;
border-spacing: 2px;
border-collapse: separate;
table-row {
display: table-row;
&.selected {
background-color: rgba(255, 0, 200, .3);
}
td-css {
display: table-cell;
border: 1px solid currentColor;
padding: 4px;
}
}
}
Customized Built-in Element as a List Item
Extends HTMLOptionElement to create <option is="option-item"> via the Customized Built-in Elements API (polyfilled for Safari). Uses item-tag to populate a native <select> with custom option elements.
Live Example ref.js ref.htmlCode Reference
// First, get polyfill to make customizable built-in elements work in Safari:
// @ts-ignore
import {} from 'https://cdn.jsdelivr.net/npm/@ungap/custom-elements/+esm';
import Symbiote, { html } from '@symbiotejs/symbiote';
// Register custom <option> element:
window.customElements.define('option-item', class extends HTMLOptionElement {}, {
extends: 'option',
});
class MyApp extends Symbiote {
init$ = {
options: [
{ value: '1', textContent: 'Option 1' },
{ value: '2', textContent: 'Option 2' },
{ value: '3', textContent: 'Option 3' },
],
selectedValue: '1',
};
onChange(e) {
this.$.selectedValue = e.target.value;
}
}
MyApp.template = html`
<h3>Selected value: {{selectedValue}}</h3>
<select ${{
onchange: 'onChange',
itemize: 'options',
// This will create an <option> tags for the list items:
'item-tag': 'option-item',
}}></select>
`;
MyApp.reg('my-app');
<my-app></my-app>
SSR
SSR Markup Hydration
Sets ssrMode = true so the component treats its existing inner HTML (rendered on the server) as its template. bind attributes wire up events and state to pre-rendered nodes without replacing them.
Live Example ref.js ref.htmlCode Reference
import Symbiote from '@symbiotejs/symbiote';
class MyApp extends Symbiote {
ssrMode = true;
heading = 'Some heading after hydration';
text = 'Some text after hydration...';
onUpdate() {
this.notify('heading');
this.notify('text');
}
}
MyApp.reg('my-app');
<i>This HTML comes from the server:</i>
<my-app>
<h1 bind="textContent: heading">Hello world!</h1>
<div bind="textContent: text">Some initial text from server...</div>
<button bind="onclick: onUpdate">Click me!</button>
</my-app>
<i>And then, it has been connected to the Symbiote.js state.</i>
SSR CSS Variables Binding
Combines ssrMode = true with CSS custom property bindings ({{--prop}}). Multiple instances of the same component display different content driven purely by cascading CSS variables from :root, container, class, or inline styles.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote from '@symbiotejs/symbiote';
class MyCom extends Symbiote {
ssrMode = true;
onUpdate() {
this.$['--text'] = `Updated text... (${Date.now()})`;
}
}
MyCom.reg('my-com');
<my-com>
<button bind="onclick: onUpdate">Update</button>
<div>{{--text}}</div>
</my-com>
<my-com>
<h1>{{--heading}}</h1>
<div>{{--text}}</div>
</my-com>
<div container>
<my-com>
<h1>{{--heading}}</h1>
<div>{{--text}}</div>
</my-com>
</div>
<my-com class="my-class">
<h1>{{--heading}}</h1>
<h2>{{--local-custom-data}}</h2>
<div>{{--text}}</div>
</my-com>
:root {
--heading: 'Root CSS heading';
--text: 'Root CSS text...';
}
[container] {
--heading: 'Container heading';
--text: 'Container text...';
}
.my-class {
--heading: 'CSS class heading';
--text: 'CSS class text...';
--local-custom-data: 'Some custom data in custom template...'
}
my-com {
display: block;
padding: 20px;
border: 1px solid blue;
margin-bottom: 10px;
}
Misc
Localization
Creates a named PubSub context (L10N) with locale maps and swaps all strings at once via multiPub when the user picks a language -- no per-component logic needed.
Live Example ref.js ref.htmlCode Reference
import Symbiote, { html, PubSub } from '@symbiotejs/symbiote';
// Create localization map:
let lMap = {
EN: {
users: 'Users',
comments: 'Comments',
likes: 'Likes',
},
ES: {
users: 'Usuarios',
comments: 'Comentarios',
likes: 'Gustos',
},
RU: {
users: 'Пользователи',
comments: 'Комментарии',
likes: 'Лайки',
},
};
// Create localization context and set the default locale:
let l10nCtx = PubSub.registerCtx(lMap.EN, 'L10N');
// Then, you can use localized strings in your components and templates:
class MyApp extends Symbiote {
numberOfUsers = 10;
numberOfComments = 2;
numberOfLikes = 12;
onLangSelect(e) {
l10nCtx.multiPub(lMap[e.target.value]);
}
}
MyApp.template = html`
<select ${{onchange: 'onLangSelect'}}>
${Object.keys(lMap).map(lang => (`<option>${lang}</option>`)).join('')}
</select>
<div>{{L10N/users}} -- {{numberOfUsers}}</div>
<div>{{L10N/comments}} -- {{numberOfComments}}</div>
<div>{{L10N/likes}} -- {{numberOfLikes}}</div>
`;
MyApp.reg('my-app');
<my-app></my-app>
Icons (Template Processor)
A templateProcessors hook scans for [icon] attributes in the rendered fragment and injects inline SVG elements before the matched nodes -- icon injection without shadow DOM or slot mechanics.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote from '@symbiotejs/symbiote';
// App Icons (SVG path map):
const ICON_SET = {
star: 'M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z',
ok: 'M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z',
};
// SVG template:
function getSvg(iconName) {
return `
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="${ICON_SET[iconName] || ICON_SET.star}"></path>
</svg>
`.trim();
}
// Application super class with "icon" attribute support:
class AppSuper extends Symbiote {
constructor() {
super();
this.templateProcessors.add((/** @type {DocumentFragment} */ fr) => {
[...fr.querySelectorAll('[icon]')].forEach((el) => {
let iconKey = el.getAttribute('icon');
let icon = document.createElement('icon-el');
icon.innerHTML = getSvg(iconKey);
el.prepend(icon);
});
});
}
}
// Application component:
class MyCom extends AppSuper {}
MyCom.template = `
<h1 icon="star">Header</h1>
<button icon="ok">Button</button>
`;
MyCom.reg('my-com');
<my-com></my-com>
icon-el {
display: inline-flex;
justify-content: center;
align-items: center;
height: 1.1em;
width: 1.1em;
margin-right: .3em;
transform: translateY(.2em);
> svg {
height: 100%;
width: 100%;
path {
fill: currentColor;
}
}
}
my-com {
color: blue;
}
Icons 2 (Computed Attribute Binding)
An icon component with isoMode = true (render once, reuse on client). Uses a computed property (+path) derived from the name attribute to resolve the SVG path from a map -- demonstrates computed state and attribute-to-property reactivity.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote, { html } from '@symbiotejs/symbiote';
// App Icons (SVG path map):
const ICONS = {
star: 'M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z',
ok: 'M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z',
};
// Icon component:
class IconSvg extends Symbiote {
// Render template once on server and reuse on client
isoMode = true;
init$ = {
'@name': 'star',
// Computed property (local context dependencies):
'+path': () => ICONS[this.$['@name']],
}
}
IconSvg.template = html`
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path ${{'@d': '+path'}}></path>
</svg>
`;
IconSvg.reg('icon-svg');
<h1><icon-svg name="star"></icon-svg> Heading</h1>
<button><icon-svg name="ok"></icon-svg> Ok</button>
icon-svg {
display: inline-flex;
justify-content: center;
align-items: center;
height: 1.2em;
width: 1.2em;
transform: translateY(.2em);
svg {
height: 100%;
width: 100%;
path {
fill: currentColor;
}
}
}
CSS Icons
An icon component with zero JS state -- the SVG d attribute is bound to a CSS custom property (--path) via {'@d': '--path'}. Different icon variants are defined purely with CSS attribute selectors and --path overrides.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote, { html } from '@symbiotejs/symbiote';
class ICon extends Symbiote {}
// Native path() CSS function not supported in Safari.
// This will work in any modern browser:
ICon.template = html`
<svg viewBox="0 0 24 24">
<path ${{'@d': '--path'}} />
</svg>
`;
ICon.reg('i-con');
<i-con></i-con>
<i-con home></i-con>
<i-con dashboard></i-con>
<i-con settings></i-con>
i-con {
display: inline-flex;
height: 60px;
width: 60px;
--fill: rgba(255, 0, 0, .2);
--stroke: #000;
--stroke-width: 1;
--stroke-linecap: round;
--stroke-linejoin: round;
--path: "M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z";
svg {
width: 100%;
height: 100%;
path {
fill: var(--fill);
stroke: var(--stroke);
stroke-width: var(--stroke-width);
stroke-linecap: var(--stroke-linecap);
stroke-linejoin: var(--stroke-linejoin);
d: path(var(--path));
}
}
&[home] {
--fill: rgba(0, 255, 0, .2);
--path: "M10,20V14H14V20H20V12H24L12,0L0,12H4V20H10Z";
}
&[dashboard] {
--fill: rgba(0, 0, 255, .2);
--path: "M3,3H11V11H3V3M13,3H21V11H13V3M13,13H21V21H13V13M3,13H11V21H3V13Z";
}
&[settings] {
--fill: rgba(0, 0, 0, .2);
--path: "M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12...";
}
}
Universal Tabs
Two coordinating components (super-tabs + super-tabs-view) share a named *currentTabName context. The selector sets the active tab key; the view toggles [active] on matching [tab-ctx] panels -- no router needed.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote, { css } from '@symbiotejs/symbiote';
// Tab selector component:
class SuperTabs extends Symbiote {
init$ = {
'*currentTabName': 'first',
};
renderCallback() {
this.tabEls = [...this.querySelectorAll('[tab]')];
this.tabEls.forEach((/** @type {HTMLElement} */ el) => {
let tab = el.getAttribute('tab');
if (el.hasAttribute('current')) {
this.$['*currentTabName'] = tab;
}
el.onclick = () => {
this.$['*currentTabName'] = tab;
};
});
this.sub('*currentTabName', (val) => {
this.tabEls.forEach((/** @type {HTMLElement} */ el) => {
if (el.getAttribute('tab') === val) {
el.setAttribute('current', '');
} else {
el.removeAttribute('current');
}
});
});
}
}
SuperTabs.reg('super-tabs');
// View component:
class SuperTabsView extends Symbiote {
renderCallback() {
this.tabCtxEls = [...this.querySelectorAll('[tab-ctx]')];
this.sub('*currentTabName', (val) => {
this.tabCtxEls.forEach((/** @type {HTMLElement} */ el) => {
if (el.getAttribute('tab-ctx') === val) {
el.setAttribute('active', '');
} else {
el.removeAttribute('active');
}
});
});
}
}
SuperTabsView.reg('super-tabs-view');
<super-tabs ctx="section-select">
<button tab="first">First</button>
<button tab="second">Second</button>
<button tab="third">Third</button>
</super-tabs>
<super-tabs-view ctx="section-select">
<div tab-ctx="first">First content</div>
<div tab-ctx="second">Second content</div>
<div tab-ctx="third">Third content</div>
</super-tabs-view>
super-tabs {
display: inline-flex;
gap: 2px;
[tab] {
cursor: pointer;
&[current] {
background-color: transparent;
pointer-events: none;
}
}
}
super-tabs-view {
display: block;
[tab-ctx] {
display: none;
&[active] {
display: contents;
}
}
}
Widget Routing
Implements self-contained widget routing via a PubSub context (R) -- no History API. A nav list rendered with itemize publishes route descriptors; computed properties (+sectionHtml, +optionsJson) derive the current section HTML and display route metadata.
Live Example ref.js ref.htmlCode Reference
import Symbiote, { html, PubSub } from '@symbiotejs/symbiote';
const routes = [
{ route: 'home', title: 'Home', options: { timestamp: Date.now() } },
{ route: 'user', title: 'User', options: { timestamp: Date.now() } },
{ route: 'settings', title: 'Settings', options: { timestamp: Date.now() } },
];
const router = PubSub.registerCtx(routes[0], 'R');
class AppShell extends Symbiote {
init$ = {
routes: structuredClone(routes),
'+optionsJson': {
deps: ['R/options'],
fn: () => {
return JSON.stringify(this.$['R/options'], undefined, 2);
},
},
'+sectionHtml': {
deps: ['R/route'],
fn: () => {
let sec = this.$['R/route'];
return `<${sec}-section></${sec}-section>`;
},
},
onNav: (e) => {
let route = e.target.getAttribute('route');
if (route) {
let routeDescriptor = routes.find((desc) => desc.route === route);
if (routeDescriptor) {
routeDescriptor.options.timestamp = Date.now();
router.multiPub(routeDescriptor);
}
}
}
}
}
AppShell.template = html`
<h1>Section title: {{R/title}}</h1>
<h2>Current route: {{R/route}}</h2>
<label>Navigation panel:</label>
<nav itemize="routes">
<button ${{onclick: '^onNav', '@route': 'route'}}>{{title}}</button>
</nav>
<label>Route options:</label>
<code>{{+optionsJson}}</code>
<label>Wrapper element for the current section:</label>
<div class="viewport" ${{
innerHTML: '+sectionHtml',
'@inner-html': '+sectionHtml',
}}></div>
`;
AppShell.reg('app-shell');
<app-shell></app-shell>
Smart Textarea (AI-Assisted)
A real-world composite component that wraps a textarea with an OpenAI API call. Uses ssrMode = true, ref for direct element access, a #sourceText private field for undo, and a range slider to control rewrite tone across seven style levels.
Live Example ref.js ref.html ref.cssCode Reference
import Symbiote from '@symbiotejs/symbiote';
const CFG = {
apiUrl: 'https://api.openai.com/v1/chat/completions',
apiKey: '<YOUR_API_KEY>',
};
const textStyles = [
'Free informal speech, jokes, memes, emoji, possibly long',
'Casual chat, friendly tone, occasional emoji, short and relaxed',
'Medium formality, soft style, basic set of emoji possible, compact',
'Neutral tone, clear and direct, minimal slang or emoji',
'Professional tone, polite and respectful, no emoji, short sentences',
'Strict business language. Polite and grammatically correct.',
'Highly formal, authoritative, extensive use of complex vocabulary, long and structured',
];
export class SmartTextarea extends Symbiote {
ssrMode = true;
#sourceText = '';
init$ = {
'@model': 'gpt-4o',
currentTextStyle: textStyles[3],
saveSourceText: () => {
this.#sourceText = this.ref.text.value;
},
revertChanges: () => {
this.ref.text.value = this.#sourceText;
},
onTextStyleChange: (e) => {
this.$.currentTextStyle = textStyles[e.target.value - 1];
},
askAi: async () => {
if (!this.ref.text.value.trim()) {
alert('Your text input is empty');
return;
}
let aiResponse = await (await window.fetch(CFG.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${CFG.apiKey}`,
},
body: JSON.stringify({
model: this.$['@model'],
messages: [
{
role: 'system',
content: JSON.stringify({
useLanguage: this.ref.lang.value || 'Same as the initial text language',
textStyle: this.$.currentTextStyle,
}),
},
{
role: 'assistant',
content: 'You are the text writing assistant. Rewrite the input text according to parameters provided.',
},
{
role: 'user',
content: this.ref.text.value,
},
],
temperature: 0.7,
}),
})).json();
this.ref.text.value = aiResponse?.choices?.[0]?.message.content || this.ref.text.value;
},
}
}
SmartTextarea.reg('smart-textarea');
<smart-textarea model="gpt-4o-mini">
<textarea
bind="oninput: saveSourceText"
placeholder="AI assisted text input..."
ref="text"></textarea>
<input
type="text"
placeholder="Preferred Language"
ref="lang">
<label>Text style: {{currentTextStyle}}</label>
<input
bind="onchange: onTextStyleChange"
type="range"
min="1"
max="7"
step="1"
ref="textStyleRange">
<button bind="onclick: askAi">Rewrite text</button>
<button bind="onclick: revertChanges">Revert AI changes</button>
</smart-textarea>
smart-textarea {
display: inline-flex;
flex-flow: column;
gap: 10px;
width: 500px;
textarea {
width: 100%;
height: 200px;
}
}
Styling
Symbiote.js uses the CSSStyleSheet API for efficient in-memory style management — the same mechanism used by built-in browser elements. Every component has two static style interfaces: rootStyles for the Light DOM and shadowStyles for the Shadow DOM.
rootStyles
Assigns a stylesheet to the component via adoptedStyleSheets on the closest document root. Use the custom element tag name as the CSS selector. Supports CSS custom properties for theming.
Live Example ref.js ref.htmlCode Reference
import Symbiote, { html, css } from '@symbiotejs/symbiote';
class MyCard extends Symbiote {
init$ = {
'@title': 'Card title',
'@text': 'Card text content...',
}
}
MyCard.template = html`
<h3>{{@title}}</h3>
<p>{{@text}}</p>
<button>Action</button>
`;
MyCard.rootStyles = css`
my-card {
display: block;
padding: 20px;
border: 1px solid var(--border-color, #ccc);
border-radius: 8px;
background-color: var(--card-bg, #f9f9f9);
margin: 10px;
max-width: 300px;
& h3 {
margin: 0 0 8px;
color: var(--heading-color, #333);
}
& p {
margin: 0 0 12px;
color: var(--text-color, #666);
}
& button {
background-color: var(--accent, #0057ff);
color: #fff;
border: none;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
}
}
`;
MyCard.reg('my-card');
<my-card title="Default theme"></my-card>
<div style="--card-bg: #1a1a2e; --heading-color: #e0e0ff; --text-color: #a0a0cc; --border-color: #444; --accent: #7c4dff;">
<my-card title="Dark theme" text="Styled via CSS custom properties on a parent container."></my-card>
</div>
shadowStyles
Assigns a stylesheet directly to the component's Shadow Root (created automatically if absent). Use :host to target the component element itself; all other selectors are scoped to the shadow tree.
Live Example ref.js ref.htmlCode Reference
import Symbiote, { html, css } from '@symbiotejs/symbiote';
class MyWidget extends Symbiote {
init$ = {
'@label': 'Widget',
count: 0,
}
increment() {
this.$.count++;
}
}
MyWidget.template = html`
<div class="header">{{@label}}</div>
<div class="body">
<span class="count">{{count}}</span>
<button ${{onclick: 'increment'}}>+1</button>
</div>
`;
MyWidget.shadowStyles = css`
:host {
display: inline-block;
border: 2px solid #333;
border-radius: 8px;
overflow: hidden;
font-family: sans-serif;
margin: 8px;
}
.header {
background: #333;
color: #fff;
padding: 6px 12px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
}
.body {
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.count {
font-size: 28px;
font-weight: bold;
min-width: 2ch;
text-align: center;
}
button {
background: #0057ff;
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 18px;
cursor: pointer;
&:hover {
background: #0040cc;
}
}
`;
MyWidget.reg('my-widget');
<my-widget label="Counter A"></my-widget>
<my-widget label="Counter B"></my-widget>