A lightweight DOM attachment framework that automatically attaches behavior to elements based on CSS selectors.
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.
npm install symbiotic
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();
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();
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();
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();
<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>
You can scope attachment to a specific container.
const root = document.querySelector('#sidebar');
await symbiote.attach(root);
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.
.js-button
.remove
unregisters the behavior and runs cleanup on all matched elements.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.
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();
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();
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();
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();
Elements can match multiple selectors. Each setup function runs independently, which makes behaviors easy to compose.
Yes. It works with anything that renders HTML. It is a small DOM utility, not a component system.
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.
Yes. Instances share the same global nodule registry. Each instance observes its own root and applies the same behaviors inside that scope.
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.
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.
MIT