Skip to content

NubiscoUI ships with a complete SCSS token system. Every visual decision, color, spacing, type scale, z-index, field heights, is a CSS custom property derived from typed SCSS maps. You do not need to write SCSS to use it.

How it works

The pipeline has three stages:

  1. SCSS maps (src/styles/variables/) define every raw value, color names, type scales, spacing ratios, z-index stacks.
  2. _theme.scss iterates those maps and emits a CSS custom property for each entry onto :root.
  3. Your browser resolves the properties at runtime. No rebuild needed to change a value.

Overriding tokens as a consumer

You don't need to touch SCSS. Override CSS custom properties in your own stylesheet:

css
/* main.css or App.vue <style> */
:root {
  /* Rebrand primary color */
  --nb-c-primary: #1a56db;
  --nb-c-primary-hover: #1e429f;
  --nb-c-primary-active: #1c3fa0;

  /* Change the base spacing unit (all gaps, field heights scale with it) */
  --nb-base-unit: 10px; /* default is 8px */
}

That is all. Every component that references --nb-c-primary or --nb-base-unit inherits the change automatically.

Semantic vs. palette tokens

NubiscoUI separates two layers:

Palette tokens: raw color values tied to a named color. Rarely override these directly.

css
--nb-c-grape-hyacinth-500: #5c35c4 /* auto-generated tint */
  --nb-c-grape-hyacinth-600: #4d2aa8 /* darker tint */;

Semantic tokens: intent-level tokens that components consume. Override these to rebrand.

css
--nb-c-primary: var(--nb-c-grape-hyacinth-500) /* references palette */
  --nb-c-primary-hover: var(--nb-c-grape-hyacinth-600);

Override semantic tokens when you want to change the brand. Override palette tokens when you want to add new colors to the system.

Customising in SCSS

If you import @nubisco/ui/styles directly, you can also override the SCSS maps before the library is compiled:

scss
// Override the $colors map to inject your brand palette
@use '@nubisco/ui/variables' with (
  $colors: (
    grape-hyacinth: #5c35c4,
    // keep the base
    your-brand: #1a56db,
    // add yours
  )
);

This gives you the full shade ramp (--nb-c-your-brand-100 through --nb-c-your-brand-900) plus a11y contrast variants: generated automatically from a single hex value.

Layers

Four visual depth levels, 0 to 3, that keep stacked surfaces distinct in both themes. Depth is derived from nesting, not declared. A component that paints a surface takes the current layer and moves everything inside it one level deeper, clamped at 3 so deep trees repeat the top layer instead of running out.

Nesting decides depth

Nothing in this example names a layer. Each panel works out its own depth from where it sits.

vue
<NbPanel>
  <NbPanel>
    <NbPanel>
      <NbPanel />
    </NbPanel>
  </NbPanel>
</NbPanel>

Nesting is counted through ordinary markup, so wrappers, grids and slots in between do not break the chain.

Where a layer comes from

A surface resolves its level in this order, strongest first.

#SourceExample
1An explicit level prop<NbPanel :layer="0" />
2An explicit class on the component<NbPanel class="nb-layer-3" />
3The nearest layer authority in the DOMa hand-written <div class="nb-layer-2"> above it
4The enclosing surface's contexta panel inside a panel
5Level 1the :root default of --nb-c-surface

Rules 3 and 4 always agree in a tree built only from library components. Rule 3 exists so that a .nb-layer-N container written by an application still wins, which the component tree cannot see on its own.

Overlays that leave the document flow (Modal, the DatePicker calendar, and the teleported popovers) pin to layer 3 instead of inheriting the depth of whatever their teleport target happens to sit in.

Which components participate

These take a layer from context and deepen what they contain.

ComponentSurface it owns
NbPanelits own background
NbShellPanelits own background
NbDataTablethe table body behind toolbar, header and rows
NbCalendarthe calendar frame
NbBoardits column headers and cards
NbFileUploaderthe file rows it lists
NbShellthe application frame. Its chrome (topbar, menu strips, body) resolves to layer 1 at the top of a tree, exactly what it already painted; its main region is the page ground and paints layer 0, so the first surface a page puts in it lands on layer 1
NbModalpinned to layer 3, teleported
NbDatePickerits calendar dialog, pinned to layer 3, teleported
NbInfoHintits hint popover, pinned to layer 3, teleported
NbWalkthroughits coach-mark popover, pinned to layer 3, teleported

These deliberately do not, and inherit the surface of whatever they sit in.

ComponentWhy not
NbMenuBar, NbPaginationchrome strips, not containers. Their background exists to match the surface they are attached to (a pagination bar is the footer of its table), and they hold controls rather than a new ground for content.
NbJsonTree, NbImageCropperthey use the surface token for an inline editing highlight, a drag handle and a thumbnail frame. That is a fill, not a depth.
NbMenu, NbCommandPalette, NbUserMenualready floating at the top of the stack: they read --nb-c-layer-3 directly, so no context can pull them off it.
NbSelect, NbSubmenu, sidebar flyoutspainted from field and shell tokens, not from the layer ramp.

The shell body is the page ground

NbShell owns two depths, not one. The chrome around a page (topbar, menu strips, fixedbar, the body behind them) is layer 1. The main region is the page ground and paints layer 0, which is what makes a page written inside a shell count exactly like the standalone example above: first panel 1, panel in a panel 2, and so on.

vue
<NbShell>
  <!-- main: layer 0 -->
  <NbPanel>
    <!-- layer 1 -->
    <NbPanel />
    <!-- layer 2 -->
  </NbPanel>
</NbShell>

The chrome slots keep counting from the chrome, because they are not the page: a surface dropped into #topbar-right sits on layer-1 chrome and paints layer 2.

This mirrors Carbon, where the page background is $background and the first <Layer> on it is layer-01. The shell is chrome with its own tokens; it does not spend a level on itself.

Taking explicit control

.nb-layer-{0-3} still pins a subtree to an absolute layer and restarts the contextual count from there. The nearest container wins.

vue
<!-- derived: 1, then 2 -->
<NbPanel>
  <NbPanel />
</NbPanel>

<!-- pinned on the component -->
<NbPanel :layer="0" />
<NbPanel class="nb-layer-3" />

<!-- pinned on a container: everything inside starts counting from 2 -->
<NbLayer :level="2">
  <NbPanel />
  <!-- layer 2 -->
</NbLayer>

<!-- one step deeper without naming a number -->
<NbLayer>
  <NbPanel />
</NbLayer>

When to name a layer, and when not to

Name one when:

  • you are painting your own surface in application CSS and the runtime cannot help you: .nb-layer-2 on the wrapper gives your var(--nb-c-surface) rule the right value;
  • you are resetting, not counting. A region that should read as a fresh ground regardless of how deep it happens to be mounted (a document canvas, a preview pane, an embedded editor) is exactly what .nb-layer-0 is for;
  • a surface is created by markup the library never sees, for example content rendered by a third-party widget.

Do not name one when you are only counting depth you could have derived. A .nb-layer-2 written because "this panel is two deep" is a fact the component already knows, and it becomes wrong the first time someone moves the markup. If you find yourself computing a number, delete it and let nesting do the work.

Prefer <NbLayer :level="N"> over the raw class inside Vue: it sets the class and the context, so components inside it agree with plain CSS inside it. The raw class is the right tool in a stylesheet or in markup that no Vue component owns.

The ramp

Values are defined outright in src/styles/variables/_layers.scss, not derived from a tint chain, because every one of them is chosen to clear a measured threshold. Both ramps are a single neutral hue family and every emitted value is integer-channel hex.

Light surfaces alternate: a grey ground, a lighter panel on it, a darker nested section, a lighter popover again. CIE L* 94.05 / 98.22 / 91.95 / 96.14, adjacent separation 4.17 / 6.28 / 4.19. The point of alternating is that the theme stays light as surfaces stack: the deepest surface is still L* 96, where a descending ramp would have it near L* 86.

LevelSurfaceBorderHover
0#edeeef#a3a4a5#dcddde
1#f9fafb#9c9d9e#e2e3e4
2#e7e8e9#838485#d7d8d9
3#f3f4f5#818283#e1e2e3

Text #242526, muted text #38393a, subtle text #545556, field background #cacbcc, field border #646566.

Two things separate this from a plain alternating set. All four surfaces are distinct, worst pair 2.08 dL*, so nesting past one level still carries a fill cue. And because an alternating fill cannot express depth as an order, the borders do it instead: 2.15 / 2.60 / 3.05 / 3.50 against their own surface, escalating with depth, capped short of reading as a wireframe.

No light surface goes above L* 98.5. Glare over a long session is set by the brightest surface the same way it is set by the darkest one in dark mode, so a fill pinned against pure white is the light-theme version of a near-black ground.

Dark surfaces rise from a near-black page. CIE L* 4.63 / 11.69 / 18.87 / 26.15, adjacent steps 7.06 / 7.18 / 7.27.

LevelSurfaceBorderHover
0#0f1011#434445#1a1b1c
1#1e1f20#4d4e4f#292a2b
2#2d2e2f#595a5b#38393a
3#3d3e3f#696a6b#313233

Text #eaebec, muted text #d0d1d2, field background #141516, field border #626364.

Adjacent separation never decreases with depth in either theme, so nesting reads as a position in an order rather than as a set of local boundaries.

Dark hover reverses direction at layer 3: levels 0 to 2 lighten by about 5.1 dL*, level 3 darkens by 5.42. There is no headroom above layer 3, and a hover surface lighter than it would push muted text toward white and collapse the gap between the two text tiers.

Measured contrast

Every ratio below is WCAG 2.x, measured on the compiled stylesheet by node scripts/audit-contrast.mjs and enforced on every commit by tests/layerContrast.test.ts. The bar is AAA (7:1) for --nb-c-text and --nb-c-text-muted on every surface text can land on, resting and hovered, plus the field background. --nb-c-text-subtle is held to AA on the same nine surfaces, for the reason given under Custom Properties below.

Tokens outside the layer ramp (selected, active and accent fills, and the status colours) are not covered by these figures or by the guard.

Light

SurfacePrimary textMuted text
layer-013.229.96
layer-114.6911.07
layer-212.529.43
layer-313.9410.51
hover-011.298.51
hover-111.959.01
hover-210.768.11
hover-311.848.92
field-bg10.237.80

Dark

SurfacePrimary textMuted text
layer-015.9612.46
layer-113.8310.80
layer-211.408.90
layer-38.987.01
hover-014.4511.28
hover-112.059.40
hover-29.707.57
hover-310.768.40
field-bg15.3211.96

Borders read 2.05 to 2.64 against their own surface in light and 1.95 to 1.98 in dark; the field border, the one strong-edge role, reads 3.29 (light) and 3.04 (dark) against the field it encloses.

Breaking change

Both ramps changed. If you set --nb-c-layer-* yourself, or built screens against the old values, re-check them.

Upgrading from 1.x? Upgrading has the full old-to-new value tables for both themes, what to do about markup that annotated every layer by hand, and a way to pin the old values if you need more time.

  • Every light value moved, though the direction is the one it always was: a grey ground with lighter panels on it. The old values were #e5e5e5 and #ffffff repeating, so layer 2 was byte-identical to layer 0 and nesting past one level had no fill cue at all. The new set alternates without repeating and keeps every surface off pure white.
  • Every dark value moved. The old dark ramp mixed two hue families (layer 1 came from a pure neutral, its neighbours from a blue-ish grey) and separated by 6.70 / 5.20 / 5.58 dL*, weakest in the middle. It is now one neutral family, separating 7.06 / 7.18 / 7.27.
  • Muted text was failing. 4.82:1 on light layers 0 and 2, and 3.62:1 on dark layer 3, which is below AA. The floor is now 7.01:1 across all nine painted surfaces in both themes, 7.12:1 in light.
  • --nb-c-text-subtle was failing AA everywhere. It measured 3.32:1 on light layer 3 and 2.22:1 on dark layer 3 against the old values, and was never measured. It now clears AA on all nine surfaces in both themes.
  • Surfaces were fractional. The old tint chain emitted rgb(229.5, 229.5, 229.5). Every token is now integer-channel hex.
  • --nb-c-field-bg was the same value as layer 0, so a field on the page had no fill boundary. It is now a distinct value in both themes.
  • NbModal now paints layer 3 rather than layer 1, and a surface nested in another surface now deepens instead of matching it. That is the defect being fixed, and it is the one difference visible in markup that never used .nb-layer-N.

Markup that annotated every nesting site by hand renders exactly as it did before; that case is covered by tests.

API

useSurfaceLayer()

For component authors. Resolves the level and returns the attributes to bind on the element that paints the surface.

ts
import { useSurfaceLayer } from '@nubisco/ui'

const { level, layerClass, layerProps } = useSurfaceLayer()
vue
<template>
  <div class="my-card" v-bind="layerProps">
    <slot />
  </div>
</template>

layerProps carries the nb-layer-N class and a data-nb-layer marker. The marker is what tells a painted surface (children go one deeper) apart from a bare context class (children paint exactly N).

OptionMeaning
levelforce an absolute level, ignoring context
overlaythe surface is teleported or floating: pin to layer 3, skip the DOM probe
proberun the mount-time DOM probe. Defaults on, off for overlays
elthe element that paints, when it is not the component root
provideContextpublish context to descendants. Defaults on

useLayer()

Read-only. Reports the level the next surface below this point would paint, which is what a component needs to decide, for example, which token to tint against.

ts
const { level } = useLayer()

<NbLayer>

A context node, not a painted surface. With level it is exactly .nb-layer-N. Without one it pushes its subtree one step deeper. as picks the element it renders.

vue
<NbLayer as="section" :level="0">
  <NbPanel />
  <!-- layer 0 -->
</NbLayer>

Limitations

Stated plainly, because they are real.

  • The DOM probe is a mount-time snapshot. A surface reads its surroundings once, on mount, and corrects itself on the next tick. Moving a mounted subtree into a container at a different depth does not re-derive its layer. Remount it, or pin it.
  • All overlays share the ceiling. Every teleported surface pins to layer 3, so a popover opened from inside a modal cannot separate itself from the modal by fill alone. It separates by border and shadow instead.
  • Nesting past 3 repeats. Depth 4 and beyond paint layer 3. If your tree is that deep, the fill is no longer carrying the hierarchy and something else should.
  • CSS-only consumers get the utility classes, not the derivation. Depth counting needs a runtime that can see the component tree. Without Vue, .nb-layer-N behaves exactly as it always has, and it is the whole mechanism available. This was measured rather than assumed: three CSS-only designs (a descendant chain, the same chain at zero specificity, and single-block token rotation) were prototyped and each failed on subtree resets or on teleported nodes. @scope handles both directions but its support floor is too high for this library to depend on.
  • The layer classes only carry three tokens. --nb-c-surface, --nb-c-border and --nb-c-surface-hover. Anything else you paint stays where it was.

Layer-aware tokens

.nb-layer-{N}, and every component that resolves a layer, reassign exactly these three.

Semantic tokenWhat it controls
--nb-c-surfacethe layer's background fill
--nb-c-borderthe layer's border colour
--nb-c-surface-hoverthe layer's hover fill

Text tokens are set once per theme, not per layer. That is the point of the ramp: --nb-c-text and --nb-c-text-muted clear AAA on all four layers, all four hover states and the field, so text does not have to change when depth does.

--nb-c-text-subtle is the exception and is held to AA (4.5:1), not AAA, on those same nine surfaces. That is arithmetic, not laziness: muted text already sits at 9.43:1 on the darkest light surface, so a third tier lighter than muted and still above 7:1 has almost no room to exist and would stop reading as a separate tier. That is the usual call for a helper-text tier. Use --nb-c-text-subtle only for genuinely non-essential text, never for anything someone has to read to operate the interface.

Components that tint with color-mix() against var(--nb-c-surface) (Badge, for example) follow the current layer automatically.

Tokens

TokenDescription
--nb-c-layer-{N}surface background for layer N
--nb-c-layer-border-{N}border colour for layer N
--nb-c-layer-hover-{N}hover background for layer N

Override them to match your brand:

css
:root {
  --nb-c-layer-0: #0d1117;
  --nb-c-layer-1: #161b22;
  --nb-c-layer-2: #21262d;
  --nb-c-layer-3: #30363d;
}

If you do, measure the result. node scripts/audit-contrast.mjs prints the same report the library holds itself to, including a published reference set for comparison.

Authoring layer-aware components

Paint with the semantic tokens, never with palette colours, and bind layerProps on whichever element carries the background.

vue
<template>
  <div class="my-card" v-bind="layerProps">
    <slot />
  </div>
</template>

<script setup lang="ts">
import { useSurfaceLayer } from '@nubisco/ui'

const { layerProps } = useSurfaceLayer()
</script>

<style lang="scss" scoped>
.my-card {
  background: var(--nb-c-surface);
  border: 1px solid var(--nb-c-border);
  color: var(--nb-c-text);

  &:hover {
    background: var(--nb-c-surface-hover);
  }
}
</style>

For tinted backgrounds, mix against var(--nb-c-surface) so the tint follows the layer:

scss
.my-status {
  background: color-mix(in srgb, var(--nb-c-info) 12%, var(--nb-c-surface));
  color: var(--nb-c-info);
}

Color system

Colors are defined in src/styles/variables/_colors.scss as a $colors map. Each entry is a name–hex pair. The system generates 17 tints per color (100–900) plus an accessible contrast variant (-a11y) for each tint.

Adding a color

scss
// src/styles/variables/_colors.scss
$colors: (
  grape-hyacinth: #5c35c4,
  plain-white: #ffffff,
  plain-black: #000000,
  // Add your brand color:
  ocean-drive: #0ea5e9,
);

This automatically generates:

css
--nb-c-ocean-drive-100: /* lightest tint */ --nb-c-ocean-drive-200...
  --nb-c-ocean-drive-500: #0ea5e9 /* original */...
  --nb-c-ocean-drive-900: /* darkest tint */
  /* + a11y variants: auto-computed contrast color (black or white) */
  --nb-c-ocean-drive-500-a11y: #ffffff;

Naming colors

Use the color naming tool below to generate a descriptive name for any hex value. Color names in NubiscoUI follow a "what it looks like" convention. Not "what it is used for" (that's what semantic tokens are for).

100
150
200
250
300
350
400
450
500
550
600
650
700
750
800
850
900
#214DA6Click to change color

Built-in palette

The default palette includes:

NameValueUse
grape-hyacinth#5c35c4Primary brand color
emerald-reflection#4acf7bSuccess states
the-blues-brothers#214da6Info states
phoenix-flames#f59e0bWarning states
chicken-comb#dc2626Danger/error states
nouveau-gray#6b7280Neutral UI chrome
french-gray#a7a7a7Borders, field backgrounds
plain-white#ffffffSurfaces, backgrounds
plain-black#000000Text, contrast surfaces

Wiring a color to a semantic role

After adding a palette color, point a semantic token at it:

css
:root {
  --nb-c-primary: var(--nb-c-ocean-drive-500);
  --nb-c-primary-hover: var(--nb-c-ocean-drive-600);
  --nb-c-primary-active: var(--nb-c-ocean-drive-700);
  --nb-c-primary-a11y: var(--nb-c-ocean-drive-500-a11y);
}

Typography system

NubiscoUI ships with two typefaces via the fonts Vite plugin, and a SCSS-driven type scale that generates utility classes and CSS custom properties.

Typefaces

Plus Jakarta Sans (--nb-font-family-sans), the primary typeface for all UI text. Used for labels, body copy, headings, and display type.

Fira Code (--nb-font-family-mono), monospace, used exclusively for code, JSON viewers, inline code, and keyboard shortcuts. Ligatures enabled by default.

Both are bundled in the library and served via the fonts plugin. No CDN dependency.

Type sets

Components always use named type sets, never raw font sizes. A type set bundles size, weight, line-height, letter-spacing, and font-family into a single named role.

Apply a type set in one utility class:

html
<p class="type-body-md">Readable paragraph text</p>
<span class="type-label-md">Form label</span>
<h2 class="type-heading-03">Section title</h2>
<code class="type-code-md">const x = 1</code>

Or via CSS custom properties in your own component:

scss
.my-title {
  font-size: var(--nb-type-heading-03-size);
  font-weight: var(--nb-type-heading-03-weight);
  line-height: var(--nb-type-heading-03-line-height);
  letter-spacing: var(--nb-type-heading-03-letter-spacing);
}

Adding a type set

Edit $typeSets in src/styles/variables/_type.scss:

scss
$typeSets: (
  // ...existing sets...
  caption: (
      size: 11,
      weight: regular,
      line-height: 1.4,
      letter-spacing: 0.01em,
    )
) !default;

This automatically generates --nb-type-caption-* CSS variables and a .type-caption utility class.

Full type set reference

See the Typography principles page for the complete type scale, all named sets, and usage guidelines.

CSS custom property reference

All tokens are emitted on :root. Override any of these in your own stylesheet to customise the system.

Base unit

TokenDefaultDescription
--nb-base-unit8pxThe geometric base for all spacing. All gaps, field heights, and padding are multiples of this value.

Semantic color tokens

These are the tokens you override to rebrand the library. Each semantic token has a base, hover, and active state, plus an -a11y variant for text rendered on that color.

TokenDefaultDescription
--nb-c-primarygrape-hyacinth-500Primary brand color
--nb-c-primary-hovergrape-hyacinth-600Hover state
--nb-c-primary-activegrape-hyacinth-700Active/pressed state
--nb-c-secondaryplain-black-700Secondary actions
--nb-c-successemerald-reflection-600Success / confirm
--nb-c-infothe-blues-brothers-500Informational
--nb-c-warningphoenix-flames-500Warning / caution
--nb-c-dangerchicken-comb-500Error / destructive

Each token above also has -a11y (auto-computed contrast text color), -hover, -hover-a11y, -active, -active-a11y variants.

Layers

TokenDescription
--nb-c-layer-0App/page background surface
--nb-c-layer-1Panels, cards surface
--nb-c-layer-2Nested panels, inspector sections
--nb-c-layer-3Overlays, modals, popovers
--nb-c-layer-border-{0-3}Border color for each layer
--nb-c-layer-hover-{0-3}Hover background for each layer

See the Layers tab for usage details.

Surface & text

TokenDescription
--nb-c-surfaceCurrent surface background (set by layer context)
--nb-c-surface-raisedBackground below the current surface (page level)
--nb-c-borderCurrent border color (set by layer context)
--nb-c-surface-hoverHover background for current layer
--nb-c-contrastMaximum contrast on surface (near-black in light)
--nb-c-documentPage/document background
--nb-c-whiteAbsolute white, not theme-aware
--nb-c-blackAbsolute black, not theme-aware
--nb-c-textPrimary body text
--nb-c-text-mutedSupporting text, captions, secondary labels
--nb-c-text-subtlePlaceholder text, disabled labels
--nb-c-bgPage background tier
--nb-c-bg-softElevated/inset background tier

Form field tokens

TokenDefaultDescription
--nb-c-field-bgfrench-gray-100Input background
--nb-c-field-borderfrench-gray-500Input border
--nb-field-height-sm4 × base-unit (32px)Small field height
--nb-field-height-md5 × base-unit (40px)Medium field height
--nb-field-height-lg6 × base-unit (48px)Large field height
--nb-field-padding-h2 × base-unit (16px)Horizontal field padding
--nb-field-font-size--nb-font-size-14Font size for all inputs
--nb-field-disabled-opacity0.45Opacity for disabled fields

Grid

TokenDefaultDescription
--nb-grid-max-width1440pxMaximum content width
--nb-grid-columns16Number of grid columns
--nb-grid-gutter16pxGrid gutter size

Gap scale

TokenValueDescription
--nb-gap-xxs2px¼ base unit
--nb-gap-xs4px½ base unit
--nb-gap-sm8px1× base unit
--nb-gap-md16px2× base unit
--nb-gap-lg24px3× base unit
--nb-gap-xl32px4× base unit
--nb-gap-xxl48px6× base unit

Z-index stack

TokenDescription
--nb-zindex-rootBase layer (0)
--nb-zindex-tableheaderSticky table headers
--nb-zindex-selectSelect dropdowns
--nb-zindex-dropdownDropdown menus
--nb-zindex-pageheaderSticky page headers
--nb-zindex-titlebarApp title bar
--nb-zindex-navigationTop navigation
--nb-zindex-backdropModal backdrop
--nb-zindex-modalModal dialogs
--nb-zindex-toastToast notifications
--nb-zindex-tooltipTooltips (highest)

See Z-Index principles for the full rationale and values.