Skip to content

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 MutationObserver that picks up new nodes and class changes.
  • A _mutating flag 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:

  1. 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.
  2. 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