← Back to blog

Building Nyx: A Tailwind CSS Formatter & Linter

tailwindcsstoolingtypescriptopen-source

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/oxide and @tailwindcss/node internals — always in sync with your Tailwind version
  • Supports 24+ file formats, smart caching, and shorthand collapsing (mt-2 mr-2 mb-2 ml-2m-2)
  • Install with npm install -g @rielj/nyx and run nyx 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 utility h-18 exists
  • Bracket syntax like [display:_flex] when you could just write flex

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:

CategoryFormats
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/nyx

Then 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.

Related Posts