Modern Industrial System is a production-grade React component library built around a thesis: a design system should have a strong opinion, not just a colour palette. Every component — from Button to Command Palette — is engineered to look like it came from one place, behave predictably under all input conditions, and remain accessible without exception. The industrial aesthetic is not decoration. It's the constraint that keeps the system honest.

The user of a component library is a developer. The UX decisions are about consistency, predictability, and the experience of building with it.
Most component libraries optimise for flexibility — pass any color, any size, any variant. Modern Industrial System optimises for consistency instead. The 5-token theme contract means any developer using the library gets the same visual language regardless of which component they reach for. There are no escape hatches. If you want something to look different, you change the token — and everything updates together.
The accessibility baseline is non-negotiable by design. Every interactive component ships with a distinct focus-visible state. This isn't a checklist item — it's enforced at the token level. The focus ring color derives from --accent, so it's always visible against the surface it sits on regardless of theme. You can't accidentally ship a component with an invisible focus state.
The decision to use Radix UI selectively — only for Dialog, Accordion, and Tooltip — was itself a design system decision. Components like Button, Input, Badge, and Checkbox are hand-rolled because delegating them to a library makes the system a wrapper, not a system. Understanding how a focus trap works in a Modal, or how a Checkbox manages indeterminate state, makes every future component better. The library teaches itself as it grows.
The runtime theme switch — industrial to punk in one attribute change — is a demonstration of the token contract working correctly. Five CSS custom properties, redefined under [data-theme="punk"], repaint the entire UI. No component knows which theme is active. That's the point.
Technical implementation follows ↓
Every architectural choice serves the system's primary constraint: it has to look like it came from one place.
| Framework | Next.js 16 App Router with full TypeScript throughout. Every component prop surface is typed — no implicit any, no escape hatches. The showcase page is a single client component; all 20 library components are fully server-renderable by default. |
| Styling | CSS Modules + Tailwind v4 A deliberate hybrid with a clear rule: Tailwind v4 handles layout and spacing utilities. CSS Modules own all animation and interaction code — shimmer keyframes, scan-line effects, custom slider thumbs — where scoping and performance matter. Tailwind for the Standard, CSS Modules for the Craft. |
| Tokens | 5-Token Theme Contract Every themeable component references exactly five semantic variables: --accent, --on-accent, --bg-surface, --border-subtle, and --radius. Switching themes means redefining those five values under a [data-theme] selector. The entire UI repaints in one attribute change. |
| Primitives | Radix UI (selective) Used only where the accessibility contract is genuinely complex: Dialog, Accordion, Tooltip. Simpler components are hand-rolled. A library that delegates everything to Radix teaches you nothing about how those patterns actually work. |
| Architecture | 4px Grid + Focus Contract Every spacing value is a multiple of 4px, defined as a CSS custom property. Every interactive element ships with a distinct focus-visible state. Both are enforced at the token level — breaking them requires actively fighting the system. |
Every .module.css file is a self-contained unit. That rule exists because of this bug.
CSS Modules create isolated scopes for class names — which is exactly what you want. What's less obvious is that @keyframes defined in a global stylesheet are not reliably inherited inside a .module.cssfile in Next.js with Turbopack. The shimmer animation on Skeleton, slide-up on Toast, spin on Button's loading state — all silently failed. The component rendered correctly. The class applied. The animation didn't run. No error, no warning.
The fix became a rule: if a file uses an animation name, the @keyframes block lives in that same file. No cross-file keyframe references, ever. Eight component stylesheets got local keyframe definitions — including a renamed buttonSpin (not spin) in Button to eliminate any possible collision with global scope.
/* Each .module.css defines its own keyframes — no globals dependency */
.spinnerRing {
animation: buttonSpin 0.7s linear infinite; /* local name */
}
@keyframes buttonSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}