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
).
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);
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.
}
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 }));
}
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 }));
}
}
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 inconfig.theme.spacing
and emit correspondingpadding
ormargin
declarations. For example,m-4
becomesmargin: 1rem
ifspacing[4] = "1rem"
.Size limits (
max-w-lg
,max-h-full
): look up inconfig.theme.maxWidth
ormaxHeight
(from the merged config) and emitmax-width
ormax-height
. See above in .Colors and fonts: for
text-<color>-<shade>
orbg-<color>-<shade>
, a helper (getColor
) checks the nestedconfig.theme.colors
. Fortext-xl
, it instead looks inconfig.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 })];
}
}
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 */
}
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;
}
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" }),
],
});
},
],
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 usesglob.sync
to find matching files. It then sets up a watcher withchokidar.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 foundtext-lg
andmt-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);
});
}
});
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
}
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();
});
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:
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;}`,
};
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));
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]; }
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;
}
}
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;
}
Here we use @relax;
to trigger the plugin, and we apply several utilities:
flex
,bg-[#222]
, andmd:hover:bg-[#333]
on.container
.fancy-border
on.my-box
(this was a custom utility from the default plugin).text-2xl
andtext-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;
}
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>
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
).
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
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'),
// ...
]
};
- 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…
Top comments (0)