DOM scanning → string parsing → inline injection → live-watching via MutationObserver. Every part explained with diagrams and annotated code.
Before anything can be scanned or injected, chai-wind needs a source of truth: a plain JavaScript object mapping class name strings to CSS declaration strings. Everything else in the engine reads from or writes to this object.
customStyles - the engine's complete knowledge base. A plain JS object. O(1) lookup.
Map? Two
reasons: object literals are more readable for this use case, and
Object.assign() makes runtime merging trivial - which is
exactly what the register() API uses to extend the style
map at runtime.
"chai-text-lg" maps to multiple semicolon-separated
declarations. One class, many properties. Tailwind does the same
internally for spacing, typography, and layout utilities.
Once a class name matches, we have a CSS string like
"padding-left: 16px; padding-right: 16px". Three
different browser APIs can write inline styles - only one is safe.
setProperty() is additive - it never touches properties it didn't set.
With the right API chosen, the parsing pipeline itself is straightforward:
indexOf(":") not split(":") - preserves colons inside values like url(https://...)
indexOf(":") instead of
split(":")?
CSS values can contain colons -
background-image: url(https://example.com/img.png).
Splitting on ":" would shatter that value into three
pieces. Finding the first colon and using
.slice() gives us the property name on the left and the
complete value on the right, always intact.
The core function that takes a single DOM node and applies all matching chai styles to it. Three early-exit guards run upfront - each has a specific, non-obvious reason.
Three early-exit guards keep the main loop clean and safe.
The nodeType guard matters because the DOM tree contains
many non-element nodes. Accessing .classList on a text
node returns undefined, causing a silent crash.
| nodeType | Represents | Passes guard? |
|---|---|---|
1 - ELEMENT_NODE |
<div>, <p>, <span>, etc. | YES |
3 - TEXT_NODE |
Text content between tags | NO |
8 - COMMENT_NODE |
HTML comments <!-- ... --> | NO |
9 - DOCUMENT_NODE |
The document itself | NO |
11 - DOCUMENT_FRAGMENT |
DocumentFragment | NO |
el.classList.forEach() rather than parsing
el.className.split(" ") manually. The
classList API (DOMTokenList) handles
multiple spaces, leading/trailing whitespace, and empty strings
automatically. Less code, fewer edge cases.
data-chai-applied="chai-p-2 chai-flex ...". It's also a
test-assertion hook:
expect(el.dataset.chaiApplied).toContain("chai-p-4").
The initial full-DOM pass. Uses a targeted CSS attribute selector so the browser's native selector engine - written in C++ - does the heavy filtering before a single line of JavaScript runs.
[class*="chai-"] for broad scanning vs [class~="cls"] for exact token matching in register()
root element itself is never included in the
NodeList. Without the explicit
processElement(root) call, any element passed as
root - like a freshly inserted component wrapper - would
be silently skipped.
scanDOM() runs once at initialisation. But real
applications constantly mutate the DOM - React renders, Vue updates
templates, plain JS inserts elements on user interaction. Any element
added after the scan would miss the styling pass entirely. The
MutationObserver API solves this.
The observer accumulates records; a single rAF frame drains them all at once.
The observer is configured with four options, each chosen deliberately:
observer.observe(document.body, {
childList: true, // watch nodes added/removed
subtree: true, // entire tree, not just direct children
attributes: true, // watch attribute changes
attributeFilter: ["class"], // only class changes - ignore id, style, data-*
});
document.body would be watched. Any element
inside a nested component - which is the normal case - would be
invisible to the observer.
style, id,
data-*, or aria-* mutation would fire the
callback. We only care about class changes, so we filter down to one
attribute name.
The callback handles two mutation types:
| mutation.type | Trigger | What we collect |
|---|---|---|
"childList" |
Nodes added/removed (e.g.
appendChild, innerHTML =)
|
Each node in mutation.addedNodes that is an
ELEMENT_NODE
|
"attributes" |
Class attribute changed (e.g.
el.classList.add("chai-p-4"))
|
mutation.target - the element itself |
The observer callback doesn't process nodes immediately. Instead it
collects them in a Set and schedules a single
requestAnimationFrame flush. This is the
batch-and-drain pattern.
If the same node appears in multiple mutation records within one frame, the Set deduplicates it at zero cost.
let _pendingNodes = new Set(); let _rafId = null; function _flushPending() { _pendingNodes.forEach((node) => { processElement(node); node.querySelectorAll?.('[class*="chai-"]').forEach(processElement); }); _pendingNodes.clear(); _rafId = null; } // In the observer callback: if (!_rafId) { _rafId = requestAnimationFrame(_flushPending); // schedule once }
_rafId guard ensures only one frame
is ever pending at a time. Mutations keep arriving and accumulating in
_pendingNodes, but no second
requestAnimationFrame is scheduled until the current one
fires and clears _rafId back to null.
When chai-wind's script executes, the HTML parser may still be running
- meaning document.body doesn't yet contain all its
elements. The initialisation block checks
document.readyState to decide whether to run immediately
or wait.
Scripts at the bottom of <body> see "interactive". Scripts in <head> without defer see "loading". Both cases handled.
if (document.readyState === "loading") { // Parser still running - wait for it to finish document.addEventListener("DOMContentLoaded", () => { ChaiWind.init({ debug: true }); }); } else { // DOM is already available - init immediately ChaiWind.init({ debug: true }); }
<head> without defer, end of
<body>, or as a module. The
readyState check removes any placement constraint.
Every layer in one picture - from the HTML file loading to a styled element on screen.
The same processElement() call handles both the initial scan and every subsequent observer-triggered update.
Four methods are exposed on window.ChaiWind. Three are
functional; one is a developer ergonomics helper.
Merges optional extra styles into the map via
Object.assign(), then calls
scanDOM() and starts the MutationObserver. Called
automatically - you only call it manually if you want custom
styles or need to re-init.
ChaiWind.init({
debug: true, // log matched elements to console
styles: { // extend the style map before first scan
"my-card": "background:#fff; border-radius:8px"
}
});
Adds new class mappings at runtime. After merging into
customStyles, it uses [class~="cls"] to
find only the elements that actually carry the new class -
avoiding a full re-scan.
ChaiWind.register({
"chai-card-elevated": `
background-color: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
padding: 24px
`
});
Elements with that class that loaded before
register() was called are retroactively styled - the
scan-and-match pattern is not a one-shot operation.
scanDOM() manually. An escape hatch for cases
where DOM mutations happened through a framework's reconciler in a
way the observer missed, or after a batch class update. In practice
the observer handles everything, but the escape hatch is good API
design.
console.table(customStyles) - renders the full
style map as a sortable, searchable table in DevTools. Useful for
checking what classes are registered and how they resolve.
The key insight in register() is that it targets only
elements that need updating:
register() is surgical - it only processes the elements that actually need the new style.
chai-wind is small, but it exercises a dense cluster of browser APIs. Building it from scratch surfaces behaviours that are easy to take for granted:
querySelectorAll gives you a static NodeList at the
moment of calling, but the DOM keeps changing.
MutationObserver is the right tool for tracking
changes - not polling with setInterval.
cssText is only safe if you own all
inline styles on that element. The moment any other code touches
them, you have a conflict. setProperty is additive
and cooperative.
add/remove/toggle, and
fires MutationObserver attribute events when you use it.
undefined errors that are
hard to debug.
<head> without defer run before
the body is parsed. Checking readyState and falling
back to DOMContentLoaded makes a DOM-manipulation
script safe regardless of where it's placed.
Set and flushing on the
next frame coalesces many changes into one pass.
The system has four layers: a data layer (style map) → a parsing layer (applyStyleString) → a traversal layer (processElement, scanDOM) → a reactivity layer (MutationObserver + RAF). Each layer does one thing. Each can be understood, tested, and replaced independently. That separation is not accidental - it's what makes the whole thing legible.