DEV Community

Cover image for NuvoUI 2.0: The SCSS Foundation for Design Systems
NuvoUI
NuvoUI

Posted on

NuvoUI 2.0: The SCSS Foundation for Design Systems

In April 2025 we introduced NuvoUI, an SCSS framework built around readable class names and an apply() mixin that takes any value. Version 2.0 keeps both and rebuilds core as the foundation layer of a design system. @nuvoui/core 2.0.1 is now on npm.

This post covers what changed, why, and how to start.

What NuvoUI is

NuvoUI core is a human-readable, utility-first SCSS framework and the foundation layer of a design system. It gives you design tokens as CSS custom properties, utility classes with names in plain words (between, x-center), and SCSS mixins through @include apply(...) that output the same tokens as the classes. It ships SCSS and CSS only: no JavaScript, no runtime, no PostCSS plugin. It requires Dart Sass 1.79.0 or later, below 2.0, because the OKLCH shade generator uses the colour space API added in Sass 1.79.

What changed in 2.0, and why

In 1.x, colour mixins output fixed colour values, and the custom properties had unprefixed names such as --primary and --text-color. A design system needs one set of decisions that every class, mixin and theme reads from. So 2.0 moves everything onto one token model, and light and dark themes are built from it.

Every scale is a --nu- token. Colour, space, radius, border width, font size, weight, line height, shadow, opacity, z-index, duration and easing are all emitted as custom properties: --nu-space-4, --nu-radius-md, --nu-font-size-lg, --nu-duration-200.

A fixed palette, and roles that flip. Palette shades such as --nu-red-500 hold the same value in every theme and are declared on :root only. Theme and role tokens (--nu-bg-surface, --nu-text-muted, --nu-brand, --nu-danger-subtle) change per theme: the dark theme points them at other stops of the same scale.

Bare role names. The roles are brand, success, danger, warning and info. The fill is the bare name, --nu-brand, with -hover, -active, -subtle and -border variants plus --nu-text-brand and --nu-text-on-brand. primary is now brand, and secondary is gone.

OKLCH scales. Every role, palette hue and custom colour gets a generated scale in stops 50, 100 to 900, and 950.

Configuration happens once, in the first @use. Theme maps hold overrides only, and a value can reference a scale stop:

@use "@nuvoui/core" as * with (
  $brand: #4361ee,
  $light-theme: ("brand": brand 800),
  $dark-theme: ("bg-base": #121212),
  $generate-utility-classes: true
);
Enter fullscreen mode Exit fullscreen mode

An excerpt of what that compiles to:

:root {
  --nu-brand-800: #20309d;
  --nu-space-4: 1rem;
}
:root, [data-theme=light] {
  --nu-brand: var(--nu-brand-800);
}
[data-theme=dark], [data-theme=inverse] {
  --nu-bg-base: #121212;
  --nu-brand: var(--nu-brand-400);
}
Enter fullscreen mode Exit fullscreen mode

Themes

Themes are selected with attributes, not separate stylesheets:

  • data-theme="light" or data-theme="dark" on any element themes that element and its subtree, so sections can be nested.
  • Without a data-theme attribute on the root, the page follows the operating system through prefers-color-scheme.
  • data-theme="inverse" gives a section the opposite theme of the page.
  • data-contrast="high" applies your $high-contrast-light and $high-contrast-dark maps. Both are empty by default, so nothing is emitted until you set values.
<html data-theme="dark">
  <body>
    <section data-theme="light">A light section inside a dark page</section>
    <section data-theme="inverse">The opposite of the page (light here)</section>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The class names do not change between themes. Only the attributes do.

Readable classes

Utility classes are opt-in through $generate-utility-classes: true, as in 1.x. Tokens and base styles are emitted either way. Colour classes use theme words and roles, so markup stays theme-aware without extra work:

<article class="bg-surface border border-regular rounded-md p-4 p-6@md">
  <h2 class="text-regular font-bold">Invoice 1042</h2>
  <p class="text-muted ms-4">Due in 3 days</p>
  <div class="bg-danger-subtle text-danger rounded-md p-2">Payment failed</div>
  <button class="bg-brand text-on-brand px-4 py-2">Pay now</button>
</article>

<div class="flex between x-center wrap gap-4">
  <span>Left</span>
  <span>Right</span>
</div>
Enter fullscreen mode Exit fullscreen mode

A breakpoint is an @ suffix and applies from that width up (p-6@md). A state is a prefix (hover:bg-hover). Flex modifiers such as between and x-center go on the same element as .flex, and flex child classes such as grow and fill-6 work only on direct children of a .flex parent.

Colour classes have no responsive variants. There is no bg-brand@md. For a colour that changes at a breakpoint, use the mixin.

apply() with any value

apply() is where NuvoUI differs most from class-only tools. Every utility is also a mixin, and modifiers come after the call: state first, then breakpoint.

@use "@nuvoui/core" as *;

.mail-row {
  @include apply(flex, x-center, gap(3), px(4), py(1.125rem), bg(hover) hover);
}

.hero {
  @include apply(bg(surface), p(4), p(8) lg, bg(brand-subtle) lg);
}
Enter fullscreen mode Exit fullscreen mode

Compiled:

.mail-row {
  display: flex;
  align-items: center;
  gap: var(--nu-space-3);
}
.mail-row > * {
  --nu-flex-gap: var(--nu-space-3);
}
.mail-row {
  padding-inline: var(--nu-space-4);
  padding-block: 1.125rem;
}
.mail-row:hover {
  background-color: var(--nu-bg-hover);
}
.hero {
  background-color: var(--nu-bg-surface);
  padding: var(--nu-space-4);
}
@media (min-width: 1024px) {
  .hero {
    padding: var(--nu-space-8);
  }
}
@media (min-width: 1024px) {
  .hero {
    background-color: var(--nu-brand-subtle);
  }
}
Enter fullscreen mode Exit fullscreen mode

Two rules make this predictable. A unitless number is a configuration key: p(4) gives var(--nu-space-4), the same value as the .p-4 class. (In 1.x, p(4) gave 4rem.) A value with a unit passes through as written: py(1.125rem) is exactly 1.125rem. Colour mixins output tokens, so bg(brand-subtle) follows the theme: --nu-brand-subtle points at brand-50 in the light theme and brand-950 in the dark theme.

For hand-written properties, token() returns the custom property for a name: token(bg-raised) gives var(--nu-bg-raised).

Errors that name the fix

In 2.0, unknown keys, modifiers, colour names and theme tokens stop the compile, and the message tells you what to write instead. Drop the unit from the example above (py(1.125)) and Dart Sass prints:

Error: "py: 1.125 is not a configured key. Valid keys: 0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 40, 48, 56, 64. Write a unit for a custom value (1.125px, 1.125rem)."
Enter fullscreen mode Exit fullscreen mode

A misspelt state:

Error: "apply(bg(hover) hovr): unknown modifier hovr. Use a breakpoint (xs, sm, md, lg, xl, 2xl) or a state (hover, focus, focus-visible, focus-within, active, visited, disabled, enabled, checked, required, invalid, valid, placeholder-shown, read-only, first-child, last-child, only-child, first-of-type, last-of-type, empty, target, group-hover)."
Enter fullscreen mode Exit fullscreen mode

A token typo, and a typo in a theme map:

Error: "token(text-mute): unknown token. Close matches: text-muted."
Enter fullscreen mode Exit fullscreen mode
Error: "$light-theme has unknown tokens: bg-surfce. Fix the typo, or add a custom token to both $light-theme and $dark-theme."
Enter fullscreen mode Exit fullscreen mode

A 1.x name carried over:

Error: "bg(brand-default): renamed in 2.0, use bg(brand)."
Enter fullscreen mode Exit fullscreen mode

The compiler also warns when a computed text-on-{role} colour reaches less than 4.5:1 contrast on its fill, and names the theme map that fixes it.

This matters more now that a lot of UI code is written by AI assistants. With a framework that accepts anything, a hallucinated class or value ships silently and you find it in the browser. With NuvoUI, wrong code does not compile, and the error message is the instruction the agent needs for the next attempt.

To make the first attempt more likely to be right, we publish llms.txt with the rules that are easy to get wrong, and a full usage guide at nuvoui.io/llms-full.txt. The same guide ships inside the npm package at node_modules/@nuvoui/core/docs/llms-full.txt, next to the token reference and migration guide, so it matches the version you installed.

Right to left

2.0 adds logical utilities and mixins alongside the physical ones: ms-*, me-*, ps-*, pe-*, start-*, end-*, border-s, border-e, the rounded-s* and rounded-e* families, text-start and text-end. They follow the text direction under dir="rtl":

.ms-4 {
  margin-inline-start: var(--nu-space-4);
}
.ml-4 {
  margin-left: var(--nu-space-4);
}
Enter fullscreen mode Exit fullscreen mode

The physical classes (ml-*, pr-*, text-left) remain for content that keeps its side in both directions.

Compile-time performance

In 1.5.7, apply() cached every parsed item and copied the whole cache on each insert, so compile time grew quadratically with the number of unique raw values. A stylesheet of 4,000 rules with three unique values each took 33 seconds. In 2.0 the caches are bounded and the cost per call does not grow with the rule count; a performance guard in the test suite checks it against the previous release.

Try it in the playground

The playground runs Dart Sass in your browser. Write SCSS and HTML, see the compiled CSS, and preview the result in light, dark, an inverse section, high contrast and right to left. Compile errors show in a console panel, and you can copy a link to share what you wrote.

Upgrading from 1.x

2.0 is a breaking release, and there is no compatibility layer for 1.x names. Every custom property is renamed to the --nu- model, primary becomes brand, secondary is removed, mixin numbers become configuration keys, shade values change because they are now generated in OKLCH, and validation is stricter throughout.

The migration guide lists every rename with an upgrade checklist, and the token reference lists every token with its light and dark value. Many leftovers are caught for you: as shown above, the compiler points old names at their 2.0 replacements. Projects that cannot migrate now can stay on 1.x.

Install and first steps

npm install -D sass @nuvoui/core
Enter fullscreen mode Exit fullscreen mode

Configure once in your main stylesheet:

@use "@nuvoui/core" as * with (
  $brand: #4361ee,
  $generate-utility-classes: true
);
Enter fullscreen mode Exit fullscreen mode

That form resolves through the package sass export in Vite and webpack sass-loader. With the Dart Sass command line, use the Node package importer:

@use "pkg:@nuvoui/core" as *;
Enter fullscreen mode Exit fullscreen mode
sass --pkg-importer=node src/app.scss dist/app.css
Enter fullscreen mode Exit fullscreen mode

Each compiled entry that loads the package emits the tokens, reset, base layer and tooltips (about 42 KB compressed, under 6 KB gzipped), so compile one entry that imports your partials rather than one stylesheet per component. If you want the default configuration with no build step, the package also ships dist/nuvoui.min.css.

Core styles plain HTML buttons and form fields and includes CSS-only tooltips. It is a foundation layer, not a component library. If you are weighing it against other tools, the comparison pages set out the differences with Tailwind CSS, Bootstrap and Ant Design, with sources.

Links

NuvoUI is MIT licensed. Bug reports and questions are welcome on GitHub Issues.

Top comments (0)