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 (2)
Deconstructing utility classes into raw CSS is the quickest way to grasp how token pipelines actually operate under the hood. Building your own parser makes you realise Tailwind is basically a strict design system scale mapped to shorthand strings. Once you try extending a tool like this to handle pseudo-classes or arbitrary values, the true complexity of modern CSS generation engines becomes glaringly obvious.
Thanks Alexander — you're hitting on exactly the part I cut from scope.
For variants (hover:, md:, dark:), the parser stays cheap — you split on : and recognise the prefix. The complexity moves into the emitted CSS:
hover:bg-blue-500 → .hover:bg-blue-500:hover { background-color: #3b82f6 }
md:bg-blue-500 → @media (min-width: 768px) { .md:bg-blue-500 { ... } }
md:hover:bg-blue-500 → @media (min-width: 768px) { .md:hover:bg-blue-500:hover { ... } }
Three things stop being trivial:
Arbitrary values like p-[17px] are easy to parse (just [(.+)]), but the real cost is unit recognition — the same text- overload trap from the
article, scaled up. bg-[#abcdef] is a colour, bg-[url(...)] is an image, bg-[length:200px] is a sizing hint. Same prefix, different value spaces. And
grid-cols-[200px_1fr_100px] needs Tailwind's underscore-to-space convention because spaces in class names would break the HTML.
Honestly this is the article I want to write next: "the real hard part of Tailwind isn't the lookup table, it's everything that disambiguates the
lookup." Thanks for the nudge.