DEV Community

Cover image for How RelaxCSS Works: Building a TailwindCSS-Like Utility Engine
Ravi Kishan
Ravi Kishan

Posted on

How RelaxCSS Works: Building a TailwindCSS-Like Utility Engine

Overview and Introduction

RelaxCSS is a lightweight, Tailwind-inspired utility-first CSS engine implemented as a PostCSS plugin and CLI tool. Like Tailwind, it provides a rich set of pre-defined utility classes (e.g. p-4, bg-blue-500, hover:text-white) and a configuration-driven design system. RelaxCSS aims to be minimal and extensible: it supports custom theme values (colors, spacing, etc.), responsive and pseudo-class variants, JIT compilation of only used utilities, and a plugin API for adding new utilities. Under the hood, a “preflight” (CSS reset) is injected (similar to Tailwind’s) and directives like @apply and @relax in CSS enable class composition.

In practice, you install RelaxCSS, write your CSS/HTML with utility classes, and run the CLI (or PostCSS plugin) to watch your files. The tool scans source files for class names (using glob patterns and regex), generates the corresponding utility CSS on-the-fly (JIT), and writes out a final output.css. The default configuration (which you can customize via relaxcss.config.js) includes sensible defaults for colors, spacing, screens, etc., and even a built-in example plugin (adding a fancy-border utility). Overall, RelaxCSS provides a Tailwind-like workflow—class-heavy markup and zero custom CSS—while remaining small and easy to extend.

Architecture Overview

RelaxCSS consists of several major components that work together in a build pipeline:

  • CLI/Watcher: A command-line interface (cli.ts/cli-watch.ts) runs RelaxCSS. It uses glob and chokidar to watch source files (HTML, CSS, JS, etc.) and trigger rebuilds on changes.

  • File Scanner (JIT Engine): The CLI watches file patterns (e.g. src/**/*.{html,js,jsx,ts,tsx,css}), reads each file’s content, and extracts class names with regex.

  • PostCSS Parser: RelaxCSS is implemented as a PostCSS plugin. The plugin receives the CSS AST parsed from your combined stylesheet (including your @apply rules).

  • Theme/Config: User configuration (from relaxcss.config.js or plugin options) is merged with the default theme/variants. This config object (colors, spacing, screens, etc.) is provided to the plugin and to any utility-generating code.

  • Utility Generator (Plugin Logic): The core plugin takes each utility class name (or @apply directive) and generates the corresponding CSS declarations based on the theme config. It supports responsive prefixes (e.g. md:), pseudo-classes (e.g. hover:), and even arbitrary values (bg-[#222]).

  • Output Generator: After PostCSS transforms (including preflight injection and @apply resolution), the plugin emits the final CSS. The CLI then writes this CSS to the output file (e.g. dist/output.css).

RelaxCSS Architecture

In this flow, the CLI initiates file scanning (with glob and chokidar) and combines CSS and @apply rules. The PostCSS parser parses the CSS, and the RelaxCSS plugin transforms it: injecting base styles (preflight), resolving @apply by inlining utility declarations, and wrapping rules in media/pseudo selectors. The theme/config drives which CSS values get used. Finally, the Output Generator writes out the CSS file.

Theme and Configuration System

RelaxCSS is highly configurable via a relaxcss.config.js (or via plugin options). The user config can define custom theme values (colors, spacing, fonts, etc.), responsive breakpoints, variants (e.g. which pseudo-classes to enable), plugins, and more. Internally, RelaxCSS starts with a defaultConfig (built-in) and merges the user’s config into it using lodash.merge:

const mergedConfig = merge(defaultConfig, opts);
Enter fullscreen mode Exit fullscreen mode

This merged config object, mergedConfig, contains all theme scales. For example, the default theme includes a screens section (for breakpoints) and a spacing section:

theme: {
  screens: { sm: "640px", md: "768px", lg: "1024px", xl: "1280px" },
  spacing: { px: "1px", 0: "0px", 1: "0.25rem", 2: "0.5rem",  },
  colors: { transparent:"transparent", white:"#fff", blue: {50:"#eff6ff", 100:"#dbeafe", },  },
  fontSize: { base:["1rem",{lineHeight:"1.5rem"}], xl:["1.25rem",{lineHeight:"1.75rem"}],  },
  // etc.
}
Enter fullscreen mode Exit fullscreen mode

Within the plugin code, CSS generation functions access these theme values. For example, when handling max-w-<key> utilities, the code does:

if (className.startsWith("max-w-")) {
  const value = get(config.theme.maxWidth, suffix);
  if (value !== undefined)
    declarations.push(new Declaration({ prop: "max-width", value }));
}
Enter fullscreen mode Exit fullscreen mode

Similarly, for font sizes (text-xl), it retrieves a tuple from config.theme.fontSize:

const fontSizeTuple = get(config.theme.fontSize, suffix);
if (Array.isArray(fontSizeTuple)) {
  declarations.push(new Declaration({ prop: "font-size", value: fontSizeTuple[0] }));
  if (fontSizeTuple[1]?.lineHeight) {
    declarations.push(new Declaration({ prop: "line-height", value: fontSizeTuple[1].lineHeight }));
  }
}
Enter fullscreen mode Exit fullscreen mode

In summary, RelaxCSS loads user settings (via opts or a relaxcss.config.js), merges them with defaults, and then all parts of the system read from this mergedConfig.theme. The variants section (e.g. which media queries and which pseudo-classes to support) also comes from the config and drives how the plugin wraps utility rules for sm: or hover: prefixes.

Utility Parser and Generator

At the heart of RelaxCSS is the utility parser, which takes a class name like p-4, bg-[#222], or md:hover:bg-blue-500 and translates it into one or more CSS declarations. This logic lives in the function generateUtilityCss(className, config) in index.ts. The class name is split into a prefix (e.g. p, bg, text, etc.) and a suffix (the part after the dash). A series of if/else and switch statements handle each utility:

  • Spacing utilities (p-4, mt-2, mx-auto, etc.): use helper functions that look up the numeric key in config.theme.spacing and emit corresponding padding or margin declarations. For example, m-4 becomes margin: 1rem if spacing[4] = "1rem".

  • Size limits (max-w-lg, max-h-full): look up in config.theme.maxWidth or maxHeight (from the merged config) and emit max-width or max-height. See above in .

  • Colors and fonts: for text-<color>-<shade> or bg-<color>-<shade>, a helper (getColor) checks the nested config.theme.colors. For text-xl, it instead looks in config.theme.fontSize as shown in .

Importantly, RelaxCSS also supports arbitrary values via a special parser function. A class like bg-[#222] or p-[4px] matches the regex /^([a-z-]+)-\[(.+)\]$/. This extracts a prefix and a raw value. The code parseArbitraryValue(className, config) (see below) directly maps certain prefixes to CSS properties:

function parseArbitraryValue(className: string, config: RelaxConfig): Declaration[] {
  // e.g. bg-[#222], p-[4px], text-[red], w-[100px], m-[1rem], etc.
  const arbitraryMatch = className.match(/^([a-z-]+)-\[(.+)\]$/i);
  if (!arbitraryMatch) return [];
  const prefix = arbitraryMatch[1];
  const value = arbitraryMatch[2];
  // Map prefix to CSS property
  switch (prefix) {
    case "bg":
      return [new Declaration({ prop: "background-color", value })];
    case "text":
      return [new Declaration({ prop: "color", value })];
    case "border":
      return [new Declaration({ prop: "border-color", value })];
    case "w":
      return [new Declaration({ prop: "width", value })];
    case "h":
      return [new Declaration({ prop: "height", value })];
    // ... handle px, py, m, etc. in a similar way ...
    default:
      // fallback: treat prefix as the property name
      return [new Declaration({ prop: prefix, value })];
  }
}
Enter fullscreen mode Exit fullscreen mode

When the PostCSS plugin encounters a utility class, it first tries parseArbitraryValue. If that returns declarations, it uses them directly (for example, bg-[#222] yields background-color: #222). Otherwise, it falls back to the standard generateUtilityCss logic for named utilities.

Responsive and pseudo variants: Class names can be prefixed with media queries or pseudo-classes, like sm:, md:, hover:, focus:, etc. In RelaxCSS this is handled in the plugin as well. The CLI/JIT will detect a class like md:hover:bg-blue-500 and enqueue it. The plugin code splits on ":" to see that md is a responsive variant and hover is a pseudo-variant, and then wraps the generated declarations accordingly. For example, md:hover:bg-blue-500 results in a rule inside a @media (min-width: 768px) block, with selector .md\:hover\:bg-blue-500:hover { background-color: ... }. (The plugin has helper code to do this, but the key point is that our design allows stacking variants in class names.)

For standard utilities (without variants), the code essentially turns a class into a CSS rule of the form .<class> { <declarations> }. For example, text-xl would lead to:

.text-xl {
  font-size: 1.25rem; /* from theme */
  line-height: 1.75rem; /* from theme */
}
Enter fullscreen mode Exit fullscreen mode

Likewise, .bg-blue-500 { background-color: #3b82f6; }. Arbitrary values work similarly: .bg-\[\#222\] { background-color: #222; }.

Plugin System

RelaxCSS provides a plugin API so users can register additional utilities. A plugin is simply a function receiving an API object, e.g.:

interface RelaxPlugin {
  (api: {
    addUtilities: (utils: Record<string, (config: RelaxConfig) => Declaration[]>) => void;
    config: RelaxConfig;
  }): void;
}
Enter fullscreen mode Exit fullscreen mode

Within this function, a plugin typically calls api.addUtilities(...) with new utility definitions. For example, the default config includes a built-in plugin that adds a fancy-border utility:

plugins: [
  function ({ addUtilities, config }) {
    addUtilities({
      "fancy-border": () => [
        // This utility generates: border:2px dashed magenta;
        new Declaration({ prop: "border", value: "2px dashed magenta" }),
      ],
    });
  },
],
Enter fullscreen mode Exit fullscreen mode

After loading the config, RelaxCSS iterates through any plugins array (if provided) and calls each plugin function. The addUtilities calls aggregate into a userUtilities map. Later, during CSS generation, these custom utilities are treated just like built-in ones: if a class matches "fancy-border", RelaxCSS will output the declarations given by that plugin function.

This plugin mechanism allows anyone to extend RelaxCSS with their own custom shortcut classes (e.g. new color utilities, or even complex components) without modifying the core engine.

JIT Compilation and File Scanning

The CLI tool (cli-watch.ts) implements a Just-In-Time (JIT) engine. Instead of generating all possible utility classes, it scans your source files on-the-fly for exactly the classes you use. Key points:

  • File watching: The CLI takes a glob pattern like src/**/*.html and uses glob.sync to find matching files. It then sets up a watcher with chokidar.watch(srcGlob) to re-run the scan whenever any matching file changes. This makes it incremental and efficient.

  • Class extraction: For each source file, the code reads its contents and uses a regex to find class names. For HTML/JSX/etc, it matches class="...", className="...", or Vue :class="...", etc. The regex used is:

    const classRegex = /(?:class|className|:class)\s*=\s*["'`\{[]([^"'`\}\]]+)["'`\}]/g;
    

    Then it splits the captured class string on whitespace. For example, in <div class="text-xl p-4 hover:bg-red-500">, it will extract ["text-xl","p-4","hover:bg-red-500"].

    CSS files are also scanned for @apply directives: another regex /@apply\s+([^;]+);/g pulls out any classes listed in an @apply statement.

  • Building CSS to apply: After scanning all files, the CLI builds a temporary CSS string where each found class is “applied” via @apply. For example, if it found text-lg and mt-4, it generates:

    .text-lg { @apply text-lg; }
    .mt-4 { @apply mt-4; }
    

    (It also handles variants by calling a helper generateTailwindVariantRule, but ultimately it creates @apply rules for each used class.) This combined CSS (plus any raw CSS content) is then fed into PostCSS with the RelaxCSS plugin.

  • Automatic rebuilds: Thanks to chokidar, any file change triggers the scan-and-build process again. This ensures the output CSS is always up-to-date with the classes in your source.

Key code excerpts from cli-watch.ts:

// Scan files for classes and @apply
const files = glob.sync(srcGlob);
console.log(`[RelaxCSS] Processing files matching: ${files}`);
files.forEach((file) => {
  const ext = path.extname(file).slice(1);
  const content = fs.readFileSync(file, "utf8");
  if (ext === "css") {
    combinedCss += content + "\n";
    extractApplyClasses(content).forEach(cls => foundClasses.add(cls));
  } else if (fileExtensions.includes(ext)) {
    extractClassNames(content).forEach(cls => {
      foundClasses.add(cls);
      classNameFoundClasses.add(cls);
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

The extractClassNames and extractApplyClasses functions use regexes to find classes:

// In cli-watch.ts:
function extractClassNames(content: string): string[] {
  // Match class="...", className="...", etc.
  const classRegex =
    /(?:class|className|:class)\s*=\s*["'`\{[]([^"'`\}\]]+)["'`\}]/g;
  let match;
  while ((match = classRegex.exec(content))) {
    matches.push(...match[1].split(/\s+/).filter(Boolean));
  }
  return matches.filter(cls => !cls.startsWith("...")); // ignore Spread syntax
}
Enter fullscreen mode Exit fullscreen mode

After extracting, the watcher rebuild logic looks like:

// Watch for changes and rebuild
const watcher = chokidar.watch(srcGlob, { ignoreInitial: true });
watcher.on("all", (event, filePath) => {
  console.log(`[RelaxCSS] Detected ${event} in ${filePath}. Rebuilding...`);
  processFiles();
});
Enter fullscreen mode Exit fullscreen mode

This JIT pipeline – scan source → extract classes → generate @apply CSS → run RelaxCSS plugin – ensures that only used utilities end up in the final CSS. We can diagram it as:

RelaxCSS JIT Compilation Pipeline

Preflight and CSS Output

By default, RelaxCSS injects a small preflight (base reset) at the top of the CSS. This is similar to Tailwind’s Preflight and is meant to normalize browser defaults. In the plugin code, you can see it defines sections like “box-sizing”, “margin-padding”, “list-style”, etc.:

const preflightSections: Record<string, string> = {
  "box-sizing": `*,*::before,*::after{box-sizing:border-box;}`,
  "margin-padding": `body,h1,h2,h3,h4,h5,h6,p,ul,ol,li,figure,figcaption,blockquote,dl,dd{margin:0;padding:0;}`,
  "list-style": `ul:not([class]),ol:not([class]){list-style:none;}`,
  "font-family": `body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Open Sans","Helvetica Neue",sans-serif;line-height:1.5;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;}`,
  "media": `img,picture,video,canvas,svg{display:block;max-width:100%;}`,
  "form": `button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0;}`,
  "table": `table{border-collapse:collapse;}th,td{padding:0;}`,
};
Enter fullscreen mode Exit fullscreen mode

These CSS snippets (with comments like /* RelaxCSS Preflight: box-sizing */) are prepended to the output. The preflight section of the config allows enabling/disabling parts or adding overrides, but by default all sections are included. For example, the first injected rule is *,*::before,*::after{box-sizing:border-box;} (ensuring consistent box-sizing).

After the plugin runs (resolving all @apply rules into concrete declarations and wrapping variants), the final CSS is emitted. In cli-watch.ts, this is done with PostCSS:

postcss([ relaxcss() ])
  .process(finalCss, { from: undefined })
  .then((result) => {
    fs.mkdirSync(path.dirname(outFile), { recursive: true });
    fs.writeFileSync(outFile, result.css, "utf8");
    console.log(`[RelaxCSS] Rebuilt: ${outFile}`);
  })
  .catch((err) => console.error("[RelaxCSS] Build error:", err));
Enter fullscreen mode Exit fullscreen mode

So the final output is a single CSS file that begins with the preflight rules, followed by your combined utilities. All @apply rules have been replaced by their actual CSS declarations. For instance, if you had:

@relax;
.container { @apply flex bg-[#222] md:hover:bg-[#333]; }
Enter fullscreen mode Exit fullscreen mode

the output might look like:

/* RelaxCSS Preflight: box-sizing */
*,*::before,*::after{box-sizing:border-box;}
/* RelaxCSS Preflight: margin-padding */
body,h1,...,dd{margin:0;padding:0;}
/* ... other preflight rules ... */

/* Utility classes */
.container {
  display: flex;
  background-color: #222;
}
@media (min-width:768px) {
  .md\:hover\:bg-\[\#333\]:hover {
    background-color: #333;
  }
}
Enter fullscreen mode Exit fullscreen mode

In the example above, .container got display:flex (from the flex utility) and background-color:#222 (from bg-[#222]). The md:hover:bg-[#333] utility generated a media query rule for screens >=768px with the selector .md:hover:bg-[#333]:hover.

Example Output and Live Usage

Suppose we have an input CSS file like src/input.css:

/* src/input.css */
@relax;

.container {
  @apply flex bg-[#222] md:hover:bg-[#333];
}

.my-box {
  @apply fancy-border;
}

.ravi {
  @apply text-2xl;
}

.kishan {
  @apply text-3xl;
}
Enter fullscreen mode Exit fullscreen mode

Here we use @relax; to trigger the plugin, and we apply several utilities:

  • flex, bg-[#222], and md:hover:bg-[#333] on .container.

  • fancy-border on .my-box (this was a custom utility from the default plugin).

  • text-2xl and text-3xl on .ravi and .kishan.

Running RelaxCSS on this (npx ts-node src/cli-watch.ts "src/**/*.{css,html,js,jsx,ts,tsx}" --out dist/output.css) will produce a dist/output.css roughly like:

/* RelaxCSS Preflight: box-sizing */
*,*::before,*::after{box-sizing:border-box;}
/* RelaxCSS Preflight: margin-padding */
body,h1,h2,h3,h4,h5,h6,p,ul,ol,li,figure,figcaption,blockquote,dl,dd{margin:0;padding:0;}
/* ... other preflight rules ... */

.container {
  display: flex;
  background-color: #222;
}
@media (min-width:768px) {
  .md\:hover\:bg-\[\#333\]:hover {
    background-color: #333;
  }
}
.my-box {
  border: 2px dashed magenta;
}
.ravi {
  font-size: 1.5rem;
  line-height: 2rem;
}
.kishan {
  font-size: 1.875rem;
  line-height: 2.25rem;
}
Enter fullscreen mode Exit fullscreen mode

For live usage, one might have an HTML file like:

<button class="m-4">
  <span class="hover:bg-blue-500 hover:text-white md:hover:bg-blue-700 bg-[#222]">
    Hover over me
  </span>
</button>
Enter fullscreen mode Exit fullscreen mode

When RelaxCSS processes this (in JIT mode), it will generate the equivalent CSS so that the button responds to hover and changes background/text color as expected.

Conclusion

RelaxCSS is a compact, flexible framework for utility-first styling, suitable for frontend developers and framework authors who want Tailwind-like productivity with a smaller footprint. It leverages a PostCSS plugin to parse utility class names (p-4, text-blue-500, hover:bg-red-200, etc.) and emit the corresponding CSS using values from a theme configuration. The CLI/JIT engine watches your files and includes only the utilities you actually use, keeping the CSS minimal. The plugin system lets you define your own utilities or copy Tailwind’s plugin approach. By supporting arbitrary values (bg-[#222]), responsive prefixes (md:) and variants (hover:), plus a built-in preflight reset, RelaxCSS covers most needs of modern utility-based development.

For advanced use, you can extend RelaxCSS by adding more functions to generateUtilityCss, writing plugins with addUtilities, or customizing the configuration. Because it’s just a PostCSS plugin and Node script, you could even integrate it into larger build tools or frameworks. Overall, RelaxCSS aims to be a learning-friendly, extensible alternative to Tailwind for projects where you want control over the utility engine’s internals. Users can build on this foundation – for example, adding support for more CSS features or improving performance – thanks to its clear architecture.

Sources: The above explanations and code snippets are drawn directly from the RelaxCSS source files (e.g. index.ts, cli-watch.ts, base.css).

GitHub logo Ravikisha / RelaxCSS

A next-generation, Tailwind-like JIT CSS/PostCSS plugin with a powerful plugin system, support for arbitrary values, variants (including dark mode and RTL), efficient watch mode, and CSS variable theme support. RelaxCSS can be used as a PostCSS plugin (recommended for most build pipelines) or as a standalone CLI tool.

RelaxCSS Logo

RelaxCSS

Typescript Logo JavaScript Logo PostCSS Logo Node.js Logo MIT License

A next-generation, Tailwind-like JIT CSS/PostCSS plugin with a powerful plugin system, support for arbitrary values, variants (including dark mode and RTL), efficient watch mode, and CSS variable theme support.

RelaxCSS can be used as a PostCSS plugin (recommended for most build pipelines) or as a standalone CLI tool.


PostCSS Plugin Usage

RelaxCSS is a drop-in PostCSS plugin. It works with PostCSS v8+ and integrates with any PostCSS-based build system (Webpack, Vite, Parcel, etc).

// postcss.config.js
const relaxcss = require('relaxcss');

module.exports = {
  plugins: [
    require('postcss-import'), // must be first
    relaxcss({
      // custom config here
    }),
    require('autoprefixer'),
    // ...
  ]
};
Enter fullscreen mode Exit fullscreen mode
  • Supports all RelaxCSS features: JIT utilities, plugins, variants, dark mode, RTL, CSS variables, and more.
  • Use your relaxcss.config.js for custom configuration.
  • Compatible with PostCSS v8+ and all major build…

relaxcss - npm

A Tailwind-like JIT CSS engine and PostCSS plugin with plugin system, variants, dark mode, RTL, and CSS variable support.. Latest version: 1.0.1, last published: 2 hours ago. Start using relaxcss in your project by running `npm i relaxcss`. There are no other projects in the npm registry using relaxcss.

favicon npmjs.com

Top comments (0)