Building Nyx: A Tailwind CSS Formatter & Linter
TL;DR
- Nyx is a CLI that canonicalizes non-standard Tailwind CSS classes and sorts them using Tailwind's official ordering
- Powered by
@tailwindcss/oxideand@tailwindcss/nodeinternals — always in sync with your Tailwind version - Supports 24+ file formats, smart caching, and shorthand collapsing (
mt-2 mr-2 mb-2 ml-2→m-2) - Install with
npm install -g @rielj/nyxand runnyx check --fix src/
Requirements: Node.js 18+, Tailwind CSS v4
If you've used Tailwind CSS for any non-trivial project, you've probably encountered a few annoyances:
- Classes written in inconsistent order across files
- Arbitrary values like
h-[72px]when a standard utilityh-18exists - Bracket syntax like
[display:_flex]when you could just writeflex
I built Nyx to fix all of this — a CLI that canonicalizes non-standard class names and sorts them according to Tailwind's official ordering convention. It supports 24+ file formats and plugs directly into Tailwind v4's internals.
The Problem
Tailwind CSS is incredibly flexible. You can express the same thing in multiple ways:
/* All equivalent */
h-[72px] /* arbitrary value */
h-18 /* standard utility */
[display:_flex] /* arbitrary property */
flex /* standard utility */
tw-bg-red-500 /* prefixed (non-standard) */
bg-red-500 /* standard */Across a team, this leads to inconsistent code. PR reviews devolve into "use the standard class" comments. And class order? Complete chaos.
Existing tools like Prettier's Tailwind plugin handle sorting, but none handle canonicalization — converting non-standard classes to their canonical form.
How Nyx Works
The core pipeline is straightforward:
Input Files → Scanner → Design System → Diagnostics → Reporter → Fixer
1. Scanning
Nyx uses @tailwindcss/oxide's Scanner to extract class candidates with precise byte positions from source files. This is the same scanner Tailwind uses internally, so it handles every syntax variation out of the box.
import { Scanner } from "@tailwindcss/oxide";
const scanner = new Scanner({ detectSources: { base } });
const candidates = scanner.getCandidatesWithPositions({
content,
extension,
});The scanner returns each class candidate with its exact offset in the file, which we convert to line/column numbers for diagnostic reporting.
2. Canonicalization
This is the part that makes Nyx different. We load Tailwind's design system using @tailwindcss/node and call canonicalizeCandidates() — an internal API that resolves any class to its canonical form:
import { __unstable__loadDesignSystem } from "@tailwindcss/node";
const designSystem = await __unstable__loadDesignSystem(css, { base });
// Returns the canonical version if one exists
const [canonical] = designSystem.candidateParser.canonicalizeCandidates(
[candidate],
{ remValue: config.rem }
);When the canonical form differs from what was written, Nyx generates a diagnostic:
src/components/Button.tsx:12:5
✗ h-[72px] is not canonical
→ Use h-18 instead
3. Sorting
For class sorting, Nyx extracts class and className attributes from the source, then sorts using one of two strategies:
Tailwind strategy (default) — uses the design system's getClassOrder() which returns bigint ordering values matching Tailwind's official convention:
function tailwindSort(classes: string[], designSystem: DesignSystem) {
const order = designSystem.getClassOrder();
const known: [string, bigint][] = [];
const unknown: string[] = [];
for (const cls of classes) {
const match = order.find(([c]) => c === cls);
match ? known.push(match) : unknown.push(cls);
}
known.sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
return [...unknown, ...known.map(([cls]) => cls)];
}Alphabetical strategy — simple localeCompare() sorting. Deterministic and easy to reason about.
Unknown classes (custom utilities not in the design system) are preserved at the start of the list.
4. Smart Caching
For large codebases, Nyx implements file-level caching stored at node_modules/.cache/nyx/cache.json. Each entry tracks:
- File mtime and size
- A SHA256 hash of the config + CSS content
If nothing changed, the file is skipped entirely. Cache auto-invalidates when you update your Tailwind config, change your CSS entry point, or upgrade Nyx.
File Format Support
One thing I wanted to get right was broad file format support. Nyx works with:
| Category | Formats |
|---|---|
| Web | .html, .jsx, .tsx, .vue, .svelte, .astro |
| Server Templates | .erb, .ejs, .hbs, .php, .blade.php, .twig, .njk, .liquid |
| Templating | .pug, .slim, .haml |
| Documents | .mdx, .md |
| Other | .rs, .ex, .heex, .clj, .cljs |
This is possible because @tailwindcss/oxide's scanner already handles class extraction across all these formats — Nyx just needs to pass the correct file extension.
Usage
Install globally or as a dev dependency:
npm install -g @rielj/nyxThen run it:
# Scaffold config
nyx init
# Check for issues (lint + format)
nyx check src/
# Auto-fix everything
nyx check --fix src/
# Just lint (canonicalization only)
nyx lint src/
# Just format (sorting only)
nyx format --fix src/Configuration lives in nyx.config.json:
{
"$schema": "./node_modules/@rielj/nyx/schema.json",
"css": "src/styles/main.css",
"rem": 16,
"strategy": "tailwind",
"collapse": false,
"cache": true
}The --collapse flag is one of my favorites — it converts longhand utilities to shorthand:
mt-2 mr-2 mb-2 ml-2 → m-2
Technical Decisions
Why Tailwind v4 internals? I could have reimplemented class parsing and ordering from scratch, but that would be fragile and immediately outdated whenever Tailwind ships new utilities. By using @tailwindcss/oxide and @tailwindcss/node directly, Nyx is always in sync with whatever Tailwind version you're running.
Why not a Prettier plugin? Prettier plugins run inside Prettier's AST pipeline, which limits what you can do. Canonicalization requires resolving classes against the full Tailwind design system — something that doesn't fit naturally into a formatting pass. A standalone CLI gives more control and better error reporting.
Why citty for the CLI? Lightweight, typed, and supports subcommands cleanly. No need for heavy frameworks like commander or yargs for a tool this focused.
What's Next
- IDE integration — VS Code extension with inline diagnostics
- Watch mode — auto-fix on save
- Custom rules — let users define their own canonical mappings
Check it out on GitHub and let me know what you think.