How it works
The library is one file with three layers: a theme (data), a rule set (parsing logic), and an engine (DOM glue). Everything else is plumbing.
The pipeline
DOMContentLoaded │ ▼ scan(document.body) │ ▼ for each element → │ ▼ apply(element) │ ├─ for each class in classList: │ starts with prefix? → parseClass(class) │ │ │ ▼ │ try each rule in order │ first match wins → style object │ ├─ merge all matched style objects ├─ element.style.setProperty(prop, value) for each └─ remove matched chai-* classes (unless keepClasses=true) ▼ attach MutationObserver: - childList(addedNodes) → scan(node) - attributes(class) on element → apply(element)Three layers
1. Theme — data
The theme is a plain object that holds palettes, font sizes, weights, radii, and shadows.
{ colors: { red: { 50: '#fef2f2', /* … */ }, white: '#ffffff', /* … */ }, fontSize: { sm: '14px', base: '16px', /* … */ }, fontWeight:{ thin: '100', /* … */ bold: '700' }, radius: { DEFAULT: '4px', md: '6px', lg: '8px', full: '9999px' }, shadow: { sm: '…', DEFAULT: '…', lg: '…' }}You can override any of these via new Chai({ theme: { … } }). Your overrides are shallow-merged into the defaults.
2. Rules — parsing logic
A rule is a function: (token, theme) => style | null. The library ships with ~70 rules covering display, position, spacing, sizing, flex/grid, typography, borders, radius, shadow, opacity, cursor, and color (background/border/text). They’re tried in order — first match wins.
Two helpers make rules concise:
staticRule('block', { display: 'block' });// matches token === 'block'
patternRule(/^p-(.+)$/, (m) => ({ padding: parseLength(m[1]) }));// matches "p-2", "p-[1.5rem]", etc.You can add your own through chai.extend(rule) or new Chai({ rules: [...] }). See Custom rules.
3. Engine — DOM glue
Chai is a class that owns:
- Configuration (prefix, keepClasses, observe, …).
- A list of custom rules merged with the built-ins.
- A
MutationObserverthat picks up new nodes and class changes. - A
_mutatingflag that suppresses observer callbacks during the engine’s own writes (otherwise removing a class would re-trigger).
The two methods you’ll touch most are scan(root) (walk a subtree) and apply(element) (do one node). Both are idempotent and safe to call any time.
Why inline styles, not a stylesheet?
Two reasons:
- No globals. Every element gets exactly the rules it asked for. There’s no specificity battle, no leaky
*selectors, no FOUC tied to stylesheet load order. - Simplicity. Generating a stylesheet would require deduplication, atomic class hashing, and something to invalidate when classes change. Inline styles let the library stay tiny.
The cost is real (see Tradeoffs) — but for the scenarios chaiTailwind targets, the cost is acceptable and the simplicity wins.
Made by Saad · @developedbysaad on X