Tailwind is "just CSS." But what exactly does
bg-blue-500 text-white px-6 py-3 rounded-lg font-bold shadow-mdresolve to? I wrote a 500-line vanilla JS converter that takes a Tailwind class string and prints the equivalent CSS, with a live preview. The exercise turned out to be a clearer lesson in Tailwind's design than reading the docs.
🌐 Demo: https://sen.ltd/portfolio/tw-to-css/
📦 GitHub: https://github.com/sen-ltd/tw-to-css
Why build this?
Two reasons:
- Most "Tailwind tutorials" never show the resulting CSS. Readers learn class names by pattern-matching, not by understanding what each one does. A side-by-side input/output view makes the map visible.
-
Implementing it yourself is the fastest way to learn Tailwind's design. Once you've written
bg-{color}-{shade}as a handler, you understand why Tailwind feels so consistent — the same shape is repeated for every utility family.
This tool isn't a Tailwind reimplementation. It's a subset (~100 utilities) covering the parts most tutorials and quickstarts use. Goal: explain the mapping, not run production sites.
Architecture
tailwind-data.js ← Lookup tables: colour palette, spacing scale, font sizes, …
parser.js ← Tokenizer + handler array (no DOM, Node-testable)
app.js ← UI glue: input → parser → live preview + CSS output
parser.js exports parse(input) which returns { rules, unrecognised }. The whole pipeline is pure — node --test runs 43 cases without spinning up a browser.
The handler array — adding a utility = adding a function
Every Tailwind utility is one entry in a HANDLERS array. The parser tries them in order; first non-null wins.
const HANDLERS = [
// exact-match utilities
layoutHandler("flex", [["display", "flex"]]),
layoutHandler("flex-col", [["flex-direction", "column"]]),
layoutHandler("hidden", [["display", "none"]]),
// prefix utilities driven by a callback
prefixHandler("bg-", (v) => {
const hex = resolveColor(v);
return hex ? [["background-color", hex]] : null;
}),
prefixHandler("text-", (v) => {
if (v in FONT_SIZE) { const [s, l] = FONT_SIZE[v]; return [["font-size", s], ["line-height", l]]; }
if (["left","center","right","justify"].includes(v)) return [["text-align", v]];
const hex = resolveColor(v);
return hex ? [["color", hex]] : null;
}),
// spacing utilities share one helper because they all key into SPACING_MAP
spacingHandler("p", "padding"),
spacingHandler("px", ["padding-left", "padding-right"]),
spacingHandler("py", ["padding-top", "padding-bottom"]),
spacingHandler("w", "width"),
spacingHandler("h", "height"),
// ...
];
This shape is the whole reason Tailwind feels coherent: utility name = prefix + scale key, where the scale is the same 0, 0.5, 1, 2, 4, 8, … numbers everywhere. Implementing it forces you to internalise that.
The class name overload (the text- problem)
text- is the most overloaded prefix in Tailwind:
| Class | What it sets |
|---|---|
text-sm |
font-size + line-height |
text-center |
text-align |
text-blue-500 |
color |
A naive parser picks one and breaks the others. The actual handler does ordered dispatch:
prefixHandler("text-", (v) => {
// 1. font-size scale first (sm, md, lg, xl, 2xl, 3xl, ...)
if (v in FONT_SIZE) {
const [size, lh] = FONT_SIZE[v];
return [["font-size", size], ["line-height", lh]];
}
// 2. text alignment keywords
if (["left","center","right","justify"].includes(v)) {
return [["text-align", v]];
}
// 3. fall through to colour resolution
const hex = resolveColor(v);
return hex ? [["color", hex]] : null;
});
The order matters and isn't arbitrary — Tailwind itself parses text-{x} in roughly this order, so text-sm always wins over a hypothetical color named sm, and text-blue-500 falls into the colour bucket because the blue-500 form doesn't match FONT_SIZE keys.
Same pattern applies to border- (colour vs width) and a handful of others.
The spacing scale is just n * 0.25rem
const SPACING_BASE = [
0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12,
14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96,
];
for (const n of SPACING_BASE) {
SPACING_MAP[String(n)] = n === 0 ? "0px" : `${n * 0.25}rem`;
}
SPACING_MAP["px"] = "1px";
SPACING_MAP["auto"] = "auto";
SPACING_MAP["full"] = "100%";
That's the whole spacing system. p-4 is padding: 1rem. mt-8 is margin-top: 2rem. gap-2 is gap: 0.5rem. The 8px-grid intuition you've internalised from Tailwind is 0.25rem * 2 = 0.5rem ≈ 8px at default 16px root font — that's it.
The directional shortcuts (px, py, mx, my) are a one-line generalisation:
function spacingHandler(prefix, cssProp) {
return (cls) => {
if (!cls.startsWith(`${prefix}-`)) return null;
const v = cls.slice(prefix.length + 1);
if (!(v in SPACING_MAP)) return null;
const props = Array.isArray(cssProp) ? cssProp : [cssProp];
return props.map((p) => [p, SPACING_MAP[v]]);
};
}
// usage:
spacingHandler("px", ["padding-left", "padding-right"]);
"Last class wins"
parse("bg-red-500 bg-blue-500")
// → { rules: [
// { class: "bg-red-500", declarations: [["background-color", "#ef4444"]] },
// { class: "bg-blue-500", declarations: [["background-color", "#3b82f6"]] },
// ] }
Both rules survive parsing, but toCSS() collapses them into a single block where later wins:
export function toCSS(rules, selector = ".preview") {
const seen = new Map();
for (const r of rules) {
for (const [prop, value] of r.declarations) {
seen.set(prop, value); // overwrites earlier value for same prop
}
}
// ...
}
This matches Tailwind's intuitive "the last class you write for a given property wins." In real Tailwind that's done with CSS source ordering and JIT compilation — but for a static class list, Map.set over rules in order is equivalent.
(Real Tailwind has a more sophisticated @layer system for component vs utility precedence, but inside a single class list the rule is the same.)
Test what you can't see
Because parser.js is pure, every utility is unit-testable:
test("text-blue-500", () => {
assert.deepEqual(classToDeclarations("text-blue-500"),
[["color", "#3b82f6"]]);
});
test("h-screen → 100vh", () => {
assert.deepEqual(classToDeclarations("h-screen"),
[["height", "100vh"]]);
});
test("w-screen → 100vw, NOT 100vh", () => {
assert.deepEqual(classToDeclarations("w-screen"),
[["width", "100vw"]]);
});
test("invalid class returns null", () => {
assert.equal(classToDeclarations("not-a-real-class"), null);
});
43 tests cover tokenisation, each utility family, the text- ordered dispatch, the no-match path, and the CSS rendering. The "w-screen vs h-screen" test is a real bug guard — early in implementation I wrote 100vh for both, which is wrong for width.
What's NOT implemented (and why)
-
Variants (
hover:,md:,dark:) — would require generating media-query / pseudo-class wrappers. Out of scope for a "name→value" demonstrator. -
Arbitrary values (
p-[17px],bg-[#abcdef]) — easy to add, but the goal was the standard theme, not a Tailwind reimplementation. - Plugins / custom themes — same reason.
If you wanted any of these, the handler-array architecture is the right shape: each one is just additional handlers (or wrappers around existing ones for variants).
Try it
Paste your real Tailwind classes from a project and see the CSS. If something resolves to an empty preview, it's in the "unrecognised" list and you'll see why.
Takeaways
- Tailwind is mostly a 50-line lookup table for the default theme: colour palette × shades, spacing scale, font sizes, weights. The rest is naming convention.
-
bg-{color}-{shade}is a prefix + key into a 2-D table. Implementing it makes the design space obvious. -
text-is overloaded by intent (size vs align vs color). Ordered dispatch (size first, then align, then color) resolves the ambiguity cleanly. -
"Last class wins" in a single class list collapses to
Map.setover the parsed rules in order — no specificity tricks needed. - Keeping the parser DOM-free lets
node --testcover every utility branch.
This is OSS portfolio #241 from SEN LLC (Tokyo). We ship small, sharp tools continuously: https://sen.ltd/portfolio/

Top comments (0)