Back to Home

Symbiote.js: composition of web components without virtual DOM | Guide

Technical guide on using Symbiote.js to create web components. We break down the implementation of tabs, shared context mechanisms and low-code approach benefits. Suitable for middle/senior developers.

Symbiote.js: how to assemble interfaces like a constructor through HTML
Advertisement 728x90

Symbiote.js: Advanced Composition of Web Components Without Virtual DOM

Symbiote.js is a lightweight (~6 KB Brotli) library for creating web components, focusing on advanced composition through HTML markup. It solves the problem of structural organization of interfaces by eliminating dependency on virtual DOM and providing flexible mechanisms for working with data contexts. Unlike traditional frameworks, Symbiote.js works directly with the DOM API, minimizing boilerplate and allowing components to be combined through declarative markup.

Basics of Structural Composition

Interface development consists of two aspects: the logical model (components, data) and structural composition (component connections, data flow). Symbiote.js offers a unique approach to the latter, using HTML as a first-class tool for defining dependencies. Key principles:

  • Component templates are independent HTML strings
  • External markup defines structural connections
  • No binding to JS runtime
  • Support for hybrid approaches (server/client rendering)

This allows you to bring pre-generated markup to life, create "dumb" components without their own logic, or use context providers. An example is implementing tabs using two linked components:

Google AdInline article slot
import Symbiote, { css } from '@symbiotejs/symbiote';

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.rootStyles = css`
super-tabs {
  display: inline-flex;
  gap: 2px;
  [tab] {
    cursor: pointer;
    &[current] {
      background-color: transparent;
      pointer-events: none;
    }
  }
}
`;

SuperTabs.reg('super-tabs');

The second component handles content display:

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.rootStyles = css`
super-tabs-view {
  display: block;
  [tab-ctx] {
    display: none;
    &[active] {
      display: contents;
    }
  }
}
`;

SuperTabsView.reg('super-tabs-view');

Key Technical Features

The example demonstrates five critically important mechanisms:

  • rootStyles — styles are applied correctly regardless of the presence of Shadow DOM in parent elements. This ensures compatibility with Tailwind, Bootstrap, and global styles.
  • renderCallback — lifecycle with access to child nodes (unlike the standard connectedCallback).
  • init$ — initialization of reactive properties with default values.
  • sub() — subscription to property changes via Pub/Sub without virtual DOM.
  • *Properties via (asterisk)** — declaring shared context (similar to the name attribute on input[type=radio]).

The architecture feature is direct work with DOM API without abstractions. This is not an anti-pattern but a deliberate choice that eliminates the overhead of virtual representation. At the same time, the library supports typing, reactivity, and runtime debugging.

Google AdInline article slot

Low-code Paradigm in Action

The advantage of Symbiote.js shines in scenarios where interfaces are assembled via HTML attributes without JavaScript. Consider a component for displaying the current tab:

class SuperCurrent extends Symbiote {}

SuperCurrent.rootStyles = css`
super-current {
  display: block;
  h2 {
    text-transform: capitalize;
  }
}
`;

SuperCurrent.template = html`<h2>{{*currentTabName}}</h2>`;

SuperCurrent.reg('super-current');

Usage:

<super-current ctx="section-select"></super-current>

This approach opens up possibilities for:

Google AdInline article slot
  • Designers: prototyping interfaces directly in HTML
  • Content managers: customizing display via attributes without code
  • Embedded solutions: configuring widgets through markup
  • AI systems: generating interfaces with predictable markup and behavior connections

The more components in the library, the easier it is to assemble complex interfaces via HTML. Each group of components is isolated via the ctx attribute, allowing multiple independent instances on the page.

What Matters

  • Zero dependency on bundlers: components work without Webpack/Vite
  • Context via DOM: connections between components are defined by markup structure
  • 6 KB size: minimal impact on performance
  • Compatibility with any stack: integration into existing projects without migration
  • Types out of the box: TypeScript support without extra setup

Symbiote.js doesn't replace frameworks but complements them in the area of structural composition. For logical tasks, it provides a minimalist API, focusing on what other solutions implement via third-party libraries (state management, routing). This reduces cognitive load when designing interfaces, especially in teams with diverse members.

— Editorial Team

Advertisement 728x90

Read Next