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.

Appearance ​

Square or rounded geometry, for the whole library, independently of light and dark. Square is the default and is what every existing application already renders, so upgrading and calling nothing changes no pixels. Rounded is an explicit opt-in.

Rounding is not a modernisation and is not a default in waiting. The square treatment is part of what makes this library recognisable and it stays supported, unchanged, forever.

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

configureAppearance({ defaultAppearance: 'rounded' })

At runtime, without a rebuild or a reload:

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

const { appearance, isRounded, setAppearance, toggle } = useAppearance()
</script>

It writes data-nb-appearance to <html>, which is where the dark class lives too, so teleported overlays (modals, menus, select lists) inherit it rather than being left behind on the other scale.

Independence from colour mode ​

The two settings never touch each other. Appearance writes an attribute, the theme writes a class; switching one cannot reset the other, and neither shares storage with the other. A theme exported by the theme builder carries colours only, never a baked-in corner.

SettingWritten toStored under
Colour modeclass="dark" on <html>nubisco.theme
Appearancedata-nb-appearancenubisco.appearance

Pass persist: false when the application owns the setting from its own backend, so a stale stored value cannot override what it just asked for.

The radius scale ​

Rounded is not square with the corners knocked off. It is a different design language, and the reference is Apple's: controls are capsules, and every container carries a generous corner that its contents are concentric with.

RoleSquareRoundedUsed by
--nb-radius-control0capsuleButtons, single-line fields, tags
--nb-radius-control-sm08pxCheckboxes, sidebar rows, step markers
--nb-radius-popover012pxMenus, select lists, tooltips
--nb-radius-panel014pxCards, panels, tables
--nb-radius-modal020pxModals, drawers

"Capsule" is a radius larger than half the height, which CSS clamps, so both ends are semicircles at any control size.

Two control roles exist because one cannot serve both. A checkbox at capsule radius is a circle, which is a radio button; a sidebar row at capsule radius is a lozenge fighting the rows above and below it. Those take control-sm.

The container values are deliberately generous. Concentric geometry is only visible when the outer radius exceeds the inset, and at the previous 8px against 16px of padding an inset surface always collapsed to square: correct arithmetic, wasted mechanism.

Fields change shape, not idiom ​

In square, a field is what it has always been: a filled box with a single bottom rule, no side borders, no corners. That treatment is untouched.

In rounded, a field is a capsule, and a capsule cannot be described by an underline, so the appearance setting supplies the outline the shape needs. A textarea steps down to the panel corner instead: a pill around several lines of text reads as a speech bubble.

One pixel

A rounded field is 1px taller than a square one, because rounded adds the side borders that square does not draw. Drawing a transparent border in square would make the two pixel-identical and would also make every square field 1px taller than it is today, which is a change to what every existing application renders. Square compatibility won.

Which components participate ​

RoleComponents
controlButton, single-line fields, Banner and Toast close buttons
control-smCheckbox, SidebarLink, SidebarMenuItem, StepperStep, InlineEdit, BottomPanel
popoverMenu, Select's list, CommandPalette, Message, InfoHint, Walkthrough, BlueprintMinimap
panelCard, Panel, DataTable, ShellPanel, EmptyState, Tabs, Board
modalModal, and Confirm through it

NbSlider and NbFileUploader follow panel.

Intentional exceptions ​

These do not follow the setting, and that is deliberate:

  • Circular by nature — avatars, NbRadio, NbUserMenu, status dots on NbBadge and NbNotificationCenterItem. A square radio is a checkbox.
  • Pills — NbBadge's pill shape, NbSwitch's track, NbSkeleton. A switch track is a track at any appearance.
  • NbToast and NbBanner — square by design. Both carry a straight status accent bar on one edge, and a corner fights it.
  • Blueprint canvas — graphical primitives, not chrome. The minimap frame participates because it is chrome around the canvas; the canvas does not.
  • Attached surfaces — a table's toolbar, sticky header and footer run to the shell's edges and are clipped by its corner rather than taking one of their own. Two rounded boxes meeting at a shared edge leave a visible notch.

Geometry that never changes ​

--nb-radius-circle (50%) and --nb-radius-pill are not part of the appearance switch. Avatars and radios are round because of what they are, a switch track is a track, and squaring them would not look more geometric, it would look broken. Charts and canvas content are untouched.

Concentric nesting ​

Nested surfaces derive their corner from the surface enclosing them:

inner = max(0, outer - inset)

where inset is the real border-box-to-border-box distance, padding and border. A surface publishes its corner as --nb-radius-context; an inset surface reads it. Nothing is measured at runtime.

scss
@use '@nubisco/ui/styles/logic/radius' as radius;

.my-card {
  padding: 16px;
  border: 1px solid;
  @include radius.surface(panel);
}
.my-card__media {
  @include radius.inset(17px); // 16 padding + 1 border
}
.my-card__body {
  @include radius.publish-inner(); // so a third level can derive too
}

Standalone controls do not derive. A button in the middle of a panel does not run parallel to the panel's edges, so it keeps the control radius wherever it sits; use radius.standalone(control). DOM nesting alone does not establish a geometric relationship.

Insets larger than the outer radius floor at zero, which is correct: with the library's own 16-20px padding and 8-12px corners, an inset panel is genuinely square inside a rounded one.

For an inset that differs per axis, radius.inset-xy($x, $y) emits the two-axis form.

Field presets ​

Fields are a filled box with a single bottom rule. That idiom is deliberate: no side borders, no corner rounding, and the treatment below changes none of it. Corner geometry is a separate, global concern, covered in Appearance.

What did change is the fill. The enabled fill used to be #cacbcc in the light theme: a mid grey, darker than every surface it sat on. A grey box with no outline is what most interfaces use to mean "unavailable", so the enabled state was wearing the disabled state's clothes, and disabled on top of it was only a 0.45 opacity. The two differed in brightness rather than in kind, and the enabled field read as the heavier of the two.

The default now lightens the enabled fill and gives disabled and read-only their own boundary contrast:

StateBeforeNow
EnabledMid grey fill, bottom ruleLighter fill, same bottom rule
DisabledSame fill, 0.45 opacityKeeps the historic grey fill, rule dims
Read-onlyNo fill, bottom ruleNo fill, quieter rule

Geometry is identical in every column: filled box, one bottom rule, square corners, same height, padding and alignment.

The measured numbers, because a control boundary is held to 3:1 by WCAG 1.4.11:

PairLightDark
Enabled rule against its own fill5.21:14.29:1
Disabled rule against its own fill1.85:11.95:1

The disabled figures are deliberately below 3:1. That threshold applies to controls the user can operate, and this one cannot be.

In the dark theme the fill cannot carry the distinction at all: every surface in the dark ramp sits within 1.21:1 of every other, so no choice of fill separates enabled from disabled there. The bottom rule does that work instead, which is why the boundary contrast moved and not only the background.

Keeping the old fills ​

An application with calibrated screenshots or visual-regression baselines can opt out and move on its own schedule:

html
<html data-nb-preset="classic-fields"></html>

It can also be scoped to a subtree. It restores fills and boundary contrast only, so it composes with the appearance setting exactly as the default does.

The tokens ​

TokenMeaning
--nb-c-field-bgFill for an editable field
--nb-c-field-borderBottom rule for an editable field
--nb-c-field-bg-disabledFill for a disabled field
--nb-c-field-border-disabledBottom rule for a disabled field
--nb-c-field-border-readonlyBottom rule for a read-only field

The disabled and read-only tokens fall back to the enabled ones when unset, so a theme that defines only the first two still renders coherently.

Applies to NbTextInput and NbSelect.

Light and dark ​

Both ramps ship in the CSS. The dark one is emitted under a .dark class on <html>, so switching themes is a matter of putting that class there and taking it away.

useTheme does that, and the rest of what a real product needs around it: a three-state preference (light, dark, system, with system as the default), persistence, and following the OS when it changes mid-session.

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

configureTheme({ storageKey: 'analytics.theme' })
vue
const { theme, resolved, setTheme, toggle } = useTheme()

Nothing else in the library needs to know: every component paints from the tokens below, and those tokens are what the class swaps.

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.