Symbiote

A lightweight DOM attachment framework that automatically attaches behavior to elements based on CSS selectors.

Why Symbiote

Symbiote is a tiny utility that binds behaviors to DOM elements using plain CSS selectors. You describe what elements you care about and provide a function that wires them up. Symbiote finds those elements, runs your function, and keeps watching the page for new matches that appear later.

Installation

npm install symbiotic

Quick start

Map CSS selectors to setup functions. Call attach() once and Symbiote does the rest.

import createSymbiote from 'symbiotic';

const symbiote = createSymbiote({
  '.js-button': (el) => {
    const onClick = () => console.log('Button clicked!');
    el.addEventListener('click', onClick);
    return () => el.removeEventListener('click', onClick);
  },
  '.js-modal': (el) => {
    const onClick = () => { el.style.display = 'none'; };
    el.addEventListener('click', onClick);
    return () => el.removeEventListener('click', onClick);
  }
});

// Automatically waits for DOM to be ready when targeting document.body
await symbiote.attach();

Usage patterns

ES Modules default export

import createSymbiote from 'symbiotic';

const symbiote = createSymbiote({
  '.js-button': (el) => {
    const onClick = () => console.log('Button clicked!');
    el.addEventListener('click', onClick);
    return () => el.removeEventListener('click', onClick);
  }
});

await symbiote.attach();

Named imports

import { createSymbiote, defineSetup } from 'symbiotic';

const symbiote = createSymbiote({
  '.js-button': (el) => {
    const onClick = () => console.log('Button clicked!');
    el.addEventListener('click', onClick);
    return () => el.removeEventListener('click', onClick);
  }
});

await symbiote.attach();

// Add another behavior later
const modalNodule = defineSetup('.js-modal', (el) => {
  const onClick = () => { el.style.display = 'none'; };
  el.addEventListener('click', onClick);
  return () => el.removeEventListener('click', onClick);
});

// Remove it when you no longer want it
// modalNodule.remove();

CommonJS

const { createSymbiote } = require('symbiotic');

const symbiote = createSymbiote({
  '.js-button': (el) => {
    const onClick = () => console.log('Button clicked!');
    el.addEventListener('click', onClick);
    return () => el.removeEventListener('click', onClick);
  }
});

symbiote.attach();

Direct script tag

<script src="https://unpkg.com/symbiotic/dist/symbiote.iife.min.js"></script>
<script>
  const symbiote = Symbiote.createSymbiote({
    '.js-button': (el) => {
      const onClick = () => console.log('Button clicked!');
      el.addEventListener('click', onClick);
      return () => el.removeEventListener('click', onClick);
    }
  });

  symbiote.attach().then(() => {
    console.log('Symbiote attached!');
  });
</script>

Attach to a subtree

You can scope attachment to a specific container.

const root = document.querySelector('#sidebar');
await symbiote.attach(root);

API

createSymbiote(setupFunctions)

Create a Symbiote instance from a mapping of selectors to setup functions.

symbiote.attach(root?)

Attach behaviors to the DOM. If root is omitted, it defaults to document.body and waits for DOM ready.

defineSetup(selector, noduleFunction)

Register a behavior globally after an instance already exists. Matches are applied immediately across all instances, including elements already in the DOM.

Cleanup contract

If your setup function returns a function, Symbiote stores it and calls it when the nodule is removed. Use this to remove event listeners or teardown observers.

Examples

Counter

import createSymbiote from 'symbiotic';

const symbiote = createSymbiote({
  '.js-counter': (el) => {
    let count = 0;
    const onClick = () => {
      count += 1;
      el.textContent = `Clicked ${count} times`;
    };
    el.addEventListener('click', onClick);
    return () => el.removeEventListener('click', onClick);
  }
});

await symbiote.attach();

Modal open and close

const symbiote = createSymbiote({
  '.js-modal-trigger': (el) => {
    const onClick = () => {
      const modal = document.querySelector('.js-modal');
      if (modal) modal.style.display = 'block';
    };
    el.addEventListener('click', onClick);
    return () => el.removeEventListener('click', onClick);
  },
  '.js-modal-close': (el) => {
    const onClick = () => {
      const modal = el.closest('.js-modal');
      if (modal) modal.style.display = 'none';
    };
    el.addEventListener('click', onClick);
    return () => el.removeEventListener('click', onClick);
  }
});

await symbiote.attach();

Form validation

const symbiote = createSymbiote({
  '.js-validate': (el) => {
    const onBlur = () => {
      if (!el.value) el.classList.add('error');
      else el.classList.remove('error');
    };
    el.addEventListener('blur', onBlur);
    return () => el.removeEventListener('blur', onBlur);
  }
});

await symbiote.attach();

Functional composition with defineSetup

import { defineSetup } from 'symbiotic';

const createButtonNodule = (message) => {
  return defineSetup('.js-button', (el) => {
    const handler = () => console.log(message);
    el.addEventListener('click', handler);
    return () => el.removeEventListener('click', handler);
  });
};

const createModalNodule = () => {
  return defineSetup('.js-modal', (el) => {
    const handler = () => { el.style.display = 'none'; };
    el.addEventListener('click', handler);
    return () => el.removeEventListener('click', handler);
  });
};

// Use them
const buttonNodule = createButtonNodule('Button clicked!');
const modalNodule = createModalNodule();

// Later
// buttonNodule.remove();
// modalNodule.remove();

How it works

  1. Registration — you map selectors to setup functions. Symbiote stores them in a global registry so multiple instances share the same behaviors.
  2. Initial scan — a TreeWalker scans the target root and calls your setup function for each matching element. If your setup returns a function, Symbiote keeps it so it can clean up later.
  3. Observation — a MutationObserver watches for new elements or class attribute changes. New or changed nodes are checked against the registry and wired up when they match.
  4. Cleanup — removing a nodule runs the stored cleanup for each matched element and stops tracking those elements for that selector.

Elements can match multiple selectors. Each setup function runs independently, which makes behaviors easy to compose.

FAQ

Does Symbiote work with any framework

Yes. It works with anything that renders HTML. It is a small DOM utility, not a component system.

What happens if I register a selector twice

Registering a new nodule for the same selector first cleans up the old one on all matched elements, then applies the new one. This prevents leaks.

Can I attach multiple Symbiote instances

Yes. Instances share the same global nodule registry. Each instance observes its own root and applies the same behaviors inside that scope.

What about performance

The initial pass uses a TreeWalker that visits elements once. After that, a MutationObserver checks only changed nodes. Keep your setup functions small and return a cleanup function if you add listeners or observers.

Do I need to wait for DOM ready

If you call attach() with no root, Symbiote waits for the DOM before scanning document.body. If you pass a container you manage, it attaches immediately.

License

MIT