DEV Community

SEN LLC
SEN LLC

Posted on

Building a Mini Tailwind-to-CSS Converter — How Utility Class Names Map to Real CSS

Tailwind is "just CSS." But what exactly does bg-blue-500 text-white px-6 py-3 rounded-lg font-bold shadow-md resolve 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

Screenshot

Why build this?

Two reasons:

  1. 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.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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"),
  // ...
];
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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%";
Enter fullscreen mode Exit fullscreen mode

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"]);
Enter fullscreen mode Exit fullscreen mode

"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"]] },
//   ] }
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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.set over the parsed rules in order — no specificity tricks needed.
  • Keeping the parser DOM-free lets node --test cover 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)