React
NibCSS in a React app built with Vite, webpack or Rspack: build-time classes, useNib for dynamic values, SSR and the full API.
@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.
<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#
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev # http://localhost:51732. Install#
npm i @nibcss/react @nibcss/runtime
npm i -D @nibcss/unplugin3. Add the plugin#
// 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#
// 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 | |
|---|---|---|
| the build: the class is compiled into the markup and the CSS into the stylesheet | zero JavaScript, styled on first paint |
| 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#
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#
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#
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 |
|---|---|
| starts (or adopts) the page's runtime and provides it |
| a class string for an attribute, memoised per render |
| the non-hook variant, for event handlers and helpers |
| the runtime itself |
| the collected CSS as a |
| the same CSS as a string |
| 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 whoseclassNameis already an expression — React rejectsclassbesideclassName. Those elements keep their attribute and the runtime styles them, which costs a frame but is always correct. - JSX must live in a
.jsx/.tsxfile. A.tsfile 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 |
|---|---|
| the stylesheet, imported as |
| the resolved config, as a module and as data |
| a real package re-exporting it, so |
| webpack/Rspack: the runtime entry appended to the client bundle |
| dev only, with |
Everything lives inside node_modules, so there is nothing to add to .gitignore.
Common pitfalls#
className="btn"does nothing — a recipe only exists throughnib="btn",useNib("btn")orruntime.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, ornew NibRuntime()in a component) means two stylesheets and duplicate rules.