React

NibCSS in a React app built with Vite, webpack or Rspack: build-time classes, useNib for dynamic values, SSR and the full API.

5 min readv1

@nibcss/react gives React three things: the nib prop typed on every element, a provider that owns one runtime for the page, and hooks for values the build cannot see.

It works with any bundler React is built with — Vite, webpack, Rspack, Rollup. For Next.js use @nibcss/next, which wires all of this in one line. For Create React App, which exposes no bundler config, use the CLI.

TSX
<article nib="card; mxw=420px">
  <h2 nib="m=0; fts=18px; ftw=700">Title</h2>
  <button nib="btn" onClick={save}>Save</button>
</article>

1. Create a project#

Terminal
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev          # http://localhost:5173

2. Install#

Terminal
npm i @nibcss/react @nibcss/runtime
npm i -D @nibcss/unplugin

3. Add the plugin#

TypeScript
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import nib from "@nibcss/unplugin/vite";

export default defineConfig({
    plugins: [react(), nib()],
});

nib() needs no options. It writes the stylesheet, publishes the resolved config as @nibcss/config, and compiles static attributes into class names — keeping the attribute on the dev server (so a config edit can still be applied to the open page) and replacing it in a build.

Start the app once and nibcss.config.ts is created for you at the project root, fully commented.

4. Wire the runtime#

TSX
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import "@nibcss/styles.css";            // the generated stylesheet
import nibConfig from "@nibcss/config";  // the resolved nibcss.config.ts
import { NibProvider } from "@nibcss/react";
import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <NibProvider {...nibConfig}>
      <App />
    </NibProvider>
  </React.StrictMode>,
);

One runtime for the page: it resolves every value the build could not see, and it is what useNibRuntime() hands to your components. With the bundler plugin the provider is technically optional — the plugin can start the runtime itself — but passing the config explicitly is the documented path, and it is what makes the hooks resolve.

Static or dynamic — the one rule to carry#

Where it is resolved

Cost

nib="p=16px" — a plain string

the build: the class is compiled into the markup and the CSS into the stylesheet

zero JavaScript, styled on first paint

nib={\p=${pad}px`}` — anything computed

the browser runtime: resolved and inserted before the next frame

one small runtime, no flash in practice

You never choose between them by hand. Write the attribute; whatever the build can read, it takes.

5. Static styles — just write nib#

TSX
export function Card({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <article nib="d=flex; flxd=column; g=$space.md; p=$space.lg; bdrd=$radius.lg; bgc=$color.surface; bd=1px solid $color.border; dark:bgc=$color.surface-dark; dark:bd=1px solid $color.border-dark">
      <h3 nib="m=0; fts=18px; ftw=700">{title}</h3>
      <div nib="cl=$color.ink-soft">{children}</div>
    </article>
  );
}

6. Dynamic values — useNib#

TSX
import { useState } from "react";
import { useNib, useNibRuntime } from "@nibcss/react";

export default function App() {
  const runtime = useNibRuntime();
  const [pad, setPad] = useState(16);
  const [active, setActive] = useState(false);

  // a reactive class string — the build cannot see this value
  const box = useNib(`p=${pad}px; bd=1px solid $color.border; bdrd=$radius.md`);

  return (
    <main nib="mxw=720px; mi=auto; p=$space.xl $space.md; d=flex; flxd=column; g=$space.lg">
      <header nib="d=flex; ali=center; jsc=space-between; g=$space.md">
        <h1 nib="m=0; fts=28px; ftw=800; lts=-.02em">Dashboard</h1>
        <button nib="btn" onClick={() => runtime.toggleDarkMode()}>
          {runtime.darkMode.isDark() ? "Light" : "Dark"}
        </button>
      </header>

      <input type="range" min="4" max="48" value={pad}
             onChange={(e) => setPad(+e.target.value)}
             nib="w=100%; acc=$color.brand" />
      <div className={box}>padding: {pad}px</div>

      {/* a class string assembled from state */}
      <button
        className={runtime.classesFor(`btn; ${active ? "bgc=#15803d" : "bgc=$color.ink-soft"}`)}
        onClick={() => setActive(!active)}
      >
        {active ? "Active" : "Idle"}
      </button>
    </main>
  );
}

7. Server-side rendering#

TSX
import { renderToString } from "react-dom/server";
import { NibRuntime } from "@nibcss/runtime";
import { NibProvider, collectNibStyles } from "@nibcss/react";
import config from "@nibcss/config";

const rt = new NibRuntime({ watch: false, ...config });
const body = renderToString(
  <NibProvider runtime={rt}>
    <App />
  </NibProvider>,
);

const html = `<!doctype html><html><head>
  <link rel="stylesheet" href="/assets/index.css">
  <style id="nib">${collectNibStyles(rt)}</style>
</head><body><div id="root">${body}</div></body></html>`;

Render first, collect second: the CSS only exists once the components that need it have rendered. On the client the runtime adopts that <style id="nib"> element instead of creating a second one, so nothing is re-inserted and nothing flashes. <NibStyles /> is the same thing as a component — render it after your content.

8. The API#

Export

What it is

<NibProvider {...config}>

starts (or adopts) the page's runtime and provides it

useNib(attr)

a class string for an attribute, memoised per render

nib(runtime, attr)

the non-hook variant, for event handlers and helpers

useNibRuntime()

the runtime itself

<NibStyles />

the collected CSS as a <style>, for a custom SSR setup

collectNibStyles(runtime)

the same CSS as a string

NibRuntime

the class, for a deliberately separate instance

The runtime: classesFor(attr), toggleDarkMode(), darkMode.isDark(), configure(partial), engine.config, engine.stats(), flush().

9. JSX details that matter#

  • The build writes className, and skips an element whose className is already an expression — React rejects class beside className. Those elements keep their attribute and the runtime styles them, which costs a frame but is always correct.
  • JSX must live in a .jsx/.tsx file. A .ts file containing JSX fails to compile, which reads like a NibCSS error but is not one.
  • nib={cond ? a : b} is dynamic by definition: the build leaves it alone, the runtime resolves it, and the production bundle keeps that one attribute.

What the build generates#

Path

What it is

node_modules/.nib/styles.css

the stylesheet, imported as @nibcss/styles.css

node_modules/.nib/config.mjs, config.json

the resolved config, as a module and as data

node_modules/@nibcss/config

a real package re-exporting it, so import config from "@nibcss/config" type-checks with no paths entry

node_modules/.nib/boot.mjs

webpack/Rspack: the runtime entry appended to the client bundle

node_modules/.nib/dev.mjs

dev only, with debug: true: the overlay and the current warnings

Everything lives inside node_modules, so there is nothing to add to .gitignore.

Common pitfalls#

  • className="btn" does nothing — a recipe only exists through nib="btn", useNib("btn") or runtime.classesFor("btn").
  • Forgetting import "@nibcss/styles.css" — static attributes paint unstyled until the runtime catches up.
  • Building a new string every animation frame creates a rule every frame; animate with CSS variables instead.
  • Two runtimes (a stray second NibProvider, or new NibRuntime() in a component) means two stylesheets and duplicate rules.

Next steps#