A Tailwind v4 theme for interfaces that stay out of their own way. Neutral carries the structure; colour is spent on state, identity and data, and nowhere else.
It ships as one CSS file. Add it to an existing shadcn project and the components you already have pick it up without being edited, because Minima re-points Tailwind's own shadow, easing and colour scales at its rungs rather than inventing a parallel set of names. (Radius is the one scale shadcn keeps — see docs/install.md for why, and the one-time edit that hands it over.)
Same markup, same components, same class names. The only thing that changes is which values sit behind shadcn's semantic names — which is the whole claim, and the reason the theme is one file rather than a component library.
| Stock shadcn | With Minima |
|---|---|
|
|
|
Screens from the development lab, not something Minima ships — it ships tokens and six components, and the page above is ordinary shadcn markup using them.
npx shadcn@latest add phugadev/minima/themeThen one line in your global stylesheet, after the Tailwind import:
@import "tailwindcss";
@import "../styles/minima.css";That is the whole adoption cost. The full walkthrough, including what to check first and what a failed install looks like, is in docs/install.md.
Components are separate and optional. Take them when you want them:
npx shadcn@latest add phugadev/minima/button
npx shadcn@latest add phugadev/minima/tabsThey land in your repo as your code, the way shadcn intends. Nothing in the theme reaches into a component you own.
| Colour | Ten OKLCH ramps generated on Radix step semantics, each step named for its role. Alpha rungs solved so a tint reproduces its opaque twin. |
| Type | Three registers — reading, signal, figure — plus a prose layer with its own measure and rhythm. |
| Space | A 2x ladder (inset, gutter, stack, section) and three density modes. |
| Depth | Four rungs, carried by shadow in light and by surface in dark. |
| Motion | Four durations, three curves, and reduced motion enforced globally. |
| State | Focus, hover, pressed and disabled, with a ring that clears 3:1 on every surface — on every element that takes focus, contenteditable included. |
| Syntax | A code palette generated from the same ramps, for Prism, highlight.js or Shiki. |
Dark is not an inversion of light. Both ramps are generated against their own mode, which is why step 9 is a legible text colour on either and why the two sets are not mirror images of each other.
A container is not a mark. Only a mark carrying meaning alone owes the page 3:1. Holding a tinted tile to that floor drags warm hues down the ramp until amber is gold — vivid and labelled beats muddy and technically compliant.
Ask for a role, never a step number. Every step has a name (fill,
border, solid, text). Renumbering the scale once left four files quietly
wrong; a name survives that, an ordinal does not.
Text goes on a fill, never a tint. A tint reproduces its step over paper or ink only. Seven hue-and-ground pairs land between 4.20 and 4.48 on other grounds — under the floor, but only just, which is what makes it dangerous.
Each mode runs out of room at the opposite end. Light cannot lighten a surface past white; dark cannot darken a shadow past a page already at 10/255. So light spends its range on the shadow and dark spends it on the surface.
The type scale is type-*, not text-*. Tailwind's --text-* namespace
would be the obvious choice and it is a trap: cn() carries a hardcoded list
of font sizes, so a custom text-body is filed as a colour, collides with
text-muted-foreground, and is silently dropped.
The components ship a configured cn. The same hardcoded table has a
second failure mode that naming cannot fix: an unrecognised utility gets its
own private group, so it never collapses with the stock class it replaces and
both survive — leaving the stylesheet order to decide instead of your
className. rounded-lg rounded-control-xs rendered at 10px instead of 8, and
<Button className="h-control-lg" /> did nothing at all.
Every rule ships with the thing that proves it. npm run audit runs thirteen
checks over the built CSS, the registry and its history:
ramps every contrast floor holds across 110 pairings
alpha every tint reproduces its opaque step within one 8-bit step
depth two-layer cast shadows on surfaces that carry text
motion curves face the right way, exits beat entrances
state the focus ring clears 3:1 on every surface a control can sit on
prose measure is readable, rhythm groups headings, links are not colour alone
native the browser knows the scheme, every target clears 24px
syntax every token is legible on the code ground and none are confusable
cascade no dangling var(), and nothing unlayered outranks a utility
components every component is expressed in tokens, no literals
merge every utility is registered and displaces its stock counterpart
registry every item resolves, every dependency is an address, every item is documented
changelog every commit that changes what ships also records it
Three more need a real browser, because they are about what a consumer's build actually produces rather than about a value:
URL=http://localhost:3000 npm run audit:live # reduced motion, focus rings
URL=http://localhost:3000 PAGES=/,/blog npm run audit:pages # contrast as rendered, overflow, root
APP=../some-install npm run coverage # generate the page…
cd ../some-install && npm run build && npx next start -p 3210
URL=http://localhost:3210 APP=../some-install npm run audit:installaudit:install is the one that closes the longest-running hole here. Tailwind
only emits a utility it can see used in scanned source, so measuring one that
nothing asked for reads exactly like a broken token — a false alarm that cost a
detour six separate times. The coverage page is generated from the theme file,
so every token appears in scanned source by construction and a missing utility
can only mean the theme is wrong. It then separates the three bugs that share
that symptom: the token is undefined, the utility was not emitted, or it points
somewhere else.
And one reads the projects that install Minima, because a system that only checks itself is the failure this project is built against:
npm run audit:consumers # every installed item current and imported, per consumerIt prints which items, modes and kinds of project real consumers have proven.
Those are the gates in docs/1.0.md, which is what 1.0 means here.
They all held at one commit on 27 September 2026, and that commit is v1.0.0;
what changes after it, and which number each change moves, is in
CHANGELOG.md.
Each of those exists because something broke. The registry check exists because
bare registryDependencies resolve to shadcn's items rather than ours, and an
earlier version of this project shipped that way.
npm run build # ramps, syntax themes, then the bundled theme
npm run audit # everything above
npm run audit:upstream # has shadcn moved structurally since we forked?Sources live in src/. registry/minima.css is generated — do not edit it.
Development happens against a private sibling checkout — a Next app holding the
same CSS, the same components and the same cn, as a page you can look at. It
is not published, and nothing here depends on it; scripts/sources.mjs is the
only file that knows a tree can be laid out either way, so every runner body is
identical in both and any app that installs the theme can host them.
The arrangement is worth describing even though the other half is private,
because it is the reason audit-sync.mjs exists. The runners used to be edited
copies, and copies drift toward whoever is looking: eleven had fallen behind,
one still reading a path that no longer existed, and a focus-ring fix went into
the lab's globals.css instead of into the theme — so the ring stayed broken
for every consumer while the check that would have caught it was pointed at the
one page where it could not happen. audit-sync compares every shared file and
fails if one has moved.
Components are forked from shadcn, and .upstream/ records the exact source
they were forked from. audit-upstream compares structurally, blanking every
className and cva() call, so it reports what they changed rather than
what we did.
MIT