Skip to content

Upgrading ​

To 5.18.0 from 5.17.x ​

NbBoard can select several cards, off by default: selectable with v-model:selected, Cmd or Ctrl-click, Shift-click, X and Escape. Dragging a selected card emits the new move-many event, and a batch-actions slot shows a bar of actions for the selection. Nothing to do on upgrade.

To 5.17.0 from 5.16.x ​

One addition to NbModal, off by default: closeAppearance="button" draws the close control as a ghost icon NbButton, the same control that closes a panel or a drawer, instead of the modal's own square in the header corner. Nothing to do on upgrade.

To 5.16.0 from 5.15.x ​

One addition to NbSwitch, off by default: hideLabel keeps the label as the switch's accessible name but hides it from view. Nothing to do on upgrade.

To 5.15.1 from 5.15.0 ​

A visual fix to NbSelect. Look at your forms when you take it.

The selected value's text read a token that does not exist, so it inherited the surrounding font size, usually 16px, while text inputs and date pickers beside it used the field size (14px, 13px at size="sm"). Selects now match the other fields, so a select that was sitting in 16px text gets smaller. A layout that leaned on the larger text (a select used as a heading, or sized to line up with 16px copy) needs a look. Nothing else changes.

To 5.15.0 from 5.14.x ​

One addition to NbSelect, opt-in, plus a keyboard fix.

Options can be grouped under headings. Give an option a group and the consecutive options sharing it are drawn under one heading, inside a role="group" named by it. The list is never reordered: a new heading starts wherever the name changes. Two options may share a label, told apart by value. A heading is not an option, so it is never selectable or reachable by the arrow keys. If a product fakes headings with disabled options, replace them with group.

The arrow keys skip disabled options. They used to land on them, a keystroke that did nothing. ArrowUp with nothing highlighted now stays put rather than jumping to the first row.

IOptionGroup is deprecated. No component ever consumed it. It stays until the next major so an import does not break.

Nothing to do on upgrade. A list where no option has a group renders as it did, apart from the keyboard fix above.

To 5.13.0 from 5.12.x ​

One revert, no API changes.

NbTabs keep their size on a touch screen again. 5.11.2 made every tab at least 40px tall under pointer: coarse. That change answered a report about filter chips that turned out to be a product's own component, not NbTabs, so it is gone: tabs are 24px (small contained), 32px (small line), 40px and 48px on every pointer, as they were before 5.11.2. A screen tuned around the taller bars on touch will see them shrink back. The 5.11.2 drawer rows are unaffected.

To 5.11.2 from 5.11.x ​

Two touch-target fixes, no API changes. Both change what a user sees on a phone or tablet, so look over those screens. Nothing changes under a mouse on a desktop.

Navigation rows in the NbShell drawer are 44px. Below collapse-at, the NbSidebarMenuItem rows inside the drawer were about 30px, sized for a mouse. They are now at least 44px tall with 4px between them. The permanent column keeps its density. Two new tokens tune it: --nb-shell-drawer-row-height and --nb-shell-drawer-row-gap.

NbTabs are at least 40px on a touch screen. Under pointer: coarse, every tab grows to 40px, which takes a small contained bar from 24px to 40px and its frame from 30px to 46px. A toolbar holding one gets taller on touch devices. Reverted in 5.13.0.

To 5.9.0 from 5.8.x ​

One addition, opt-in, and nothing changes on a board that does not ask for it.

NbBoard can nest. Set nestable and a card can be dropped onto another card rather than only between cards, which emits a new nest event carrying the dragged id and the id it was dropped onto. The board changes nothing itself, the same contract move already had, because what nesting means is the product's business: a subtask, a child, a part.

A card's middle half nests and its top and bottom quarters still insert, so reordering keeps a target at both ends of every card. A card shorter than 44px goes back to two zones. The nest target is drawn as a filled, ringed card rather than an insertion line, and the two are never shown together. By keyboard, Shift with Space or Enter drops onto the card below the ghost.

Nothing to do on upgrade. nestable defaults to false, and with it off the drag behaviour, the classes and the events are what they were.

To 5.8.3 from 5.8.x ​

Two fixes, no API changes. Both change what a user sees, so look over the screens they touch.

NbTextInput now shows helper and warning text. It used to render only error: the other two were accepted and silently dropped. Every text input that passes either prop will now show the message under the field. That is the intended behaviour, but a screen that relied on it staying hidden, or that passes a placeholder string, will now show it.

The modal close button fills the header's corner. It was shorter than the title row and sat centred in it, which left a strip of surface above its highlight. It is now as tall as the row (56px, up from 40px), flush in the corner under the square appearance and evenly inset under rounded. The header's height does not change. This applies to NbModal and to everything built on it (NbConfirm, NbCommandPalette, NbNotificationCenter).

To 5.6.0 from 5.5.0 ​

Two changes to NbTree, plus new helpers. One of them undoes a silent behaviour change 5.5.0 introduced.

Dropping a node inside another. In a draggable NbTree, every node now accepts a drop in its middle, which makes the dragged node its child. That includes a node with no children, which is how a leaf becomes a parent.

In 5.5.0 a node only accepted one when it already rendered children, so a product that upgraded lost the ability to drag an item into a childless node. Nothing announced it: typecheck, tests and build all stayed green, and the drop quietly landed before or after the target instead.

To keep a node from taking children, opt out per node:

vue
<NbTreeNode id="leaf" label="Cannot take children" :droppable="false" />

A node can no longer be dropped into its own subtree. The tree used to allow it, which detaches the subtree from the root and loses everything under it. Rows inside the dragged node now show no drop indicator and fire no drop event. A product that guarded this itself can drop its guard, and one that did not is no longer exposed to it.

New move helpers, exported from @nubisco/ui: planTreeMove, moveTargets, isInvalidTarget, ancestorsOf, subtreeOf and nestByDepth. They work on any tree of { id, children } and answer what a drop or a "Move to" picker needs, so the move maths does not have to be written per product. See Working out the move. Nothing is required: existing drop handlers keep working.

NbAvatar takes a size in pixels and a background. size still accepts xs, sm, md and lg, and now a number as well, for the sizes a product shows people at that the scale does not carry. background and color paint the initials, for giving each person their own colour. Defaults are unchanged.

droppable and the caret are independent. expandable still decides whether a node shows a caret, so a node can accept children while showing no caret until it has some. See Tree.

To 5.5.0 from 5.4.x ​

Everything here is a fix or is additive, except one change to NbTreeNode, which 5.6.0 then reversed. Skip straight to 5.6.0 if you can.

What a tree node counts as a branch. A node used to be a branch whenever it was given a default slot, even one that rendered nothing. It is now a branch only when that slot actually renders a child, and the check runs on every render rather than once.

This is what a tree built from data wants: a row written as a v-for over page.children renders an empty slot for a page with no subpages, and used to show a caret that opened an empty group, toggled on click, and was announced as expandable. Those rows are now plain leaves. A node that gains its first child grows a caret without remounting, which it did not do before.

Two props follow from it. expandable forces the caret on, for children loaded when the node opens, or off. droppable decides whether a node accepts a drop inside it, independently of the caret.

In 5.5.0 only, a node whose slot rendered nothing also stopped accepting a drop inside it. If you are on 5.5.0 and your tree lets people drag an item into a childless node, either move to 5.6.0 or set droppable on those nodes.

Menus, hints and buttons. Submenu items became reachable with the keyboard and selectable with a mouse click, which never worked. NbInfoHint popovers are now centred on their trigger, moving by up to 2% of the popover's width. An icon-only NbButton with no accessible name logs a development warning, once per icon. Production builds are unaffected.

To 5.0.0 from 4.1.x ​

Three changes can alter how an existing application renders: the default field fill, the NbShell frame, and the NbPagination heights at sm and lg. Everything else in this release is a fix or is opt-in. An application that changes nothing keeps square corners and its current colour theme.

What changed ​

Field fills. The enabled fill was a mid grey, darker than every surface it sat on, which is what most interfaces use to mean "unavailable". Enabled fields are now lighter, and disabled and read-only fields get their own values instead of leaning on opacity alone. Geometry is unchanged: still a filled box with a single bottom rule, same height, same padding.

TokenTheme4.1.x5.0.0
--nb-c-field-bglight#cacbcc#f1f2f3
--nb-c-field-borderlight#646566#646566
--nb-c-field-bgdark#141516#1e1f20
--nb-c-field-borderdark#626364#818283

Three tokens are new: --nb-c-field-bg-disabled (which holds the old light fill, #cacbcc), --nb-c-field-border-disabled and --nb-c-field-border-readonly. Each falls back to the enabled value when unset, so a theme that overrides only --nb-c-field-bg and --nb-c-field-border still renders coherently.

To keep the 4.1.x fills, set an attribute. It is not an import: the rules already ship in @nubisco/ui/styles and do nothing until something asks for them.

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

It can be scoped to a subtree instead of the whole document. See Keeping the old fills.

NbShell frame. The topbar used to stop at the inspector's edge, and the inspector ran the full height of the window beside it. The topbar now spans the whole frame and the inspector starts underneath it:

text
4.1.x                                  5.0.0

┌─────┬─────────────────┬─────────┐    ┌─────┬───────────────────────────┐
│     │ topbar          │         │    │     │ topbar                    │
│     ├─────────────────┤         │    │     ├─────────────────┬─────────┤
│side │ main            │inspector│    │side │ main            │inspector│
│bar  │                 │         │    │bar  │                 │         │
│     ├─────────────────┤         │    │     ├─────────────────┤         │
│     │ bottom          │         │    │     │ bottom          │         │
└─────┴─────────────────┴─────────┘    └─────┴─────────────────┴─────────┘

The notification, menubar and topbar regions moved out of .nb-shell__body and now sit above .nb-shell__content-row, as siblings of it. The bottom panel stays where it was, beside the inspector. Slots, class names and props are all unchanged, so an application that only fills slots needs no code change: its topbar simply gets wider and its inspector gets shorter by the topbar's height.

NbPagination heights. sm and lg disagreed with NbDataTable by 8px in both directions, which showed whenever a table and its pager sat together. They now use the same numbers.

Size4.1.x5.0.0
sm40px, 12px text32px, 13px text
md48px48px
lg56px64px

Only applications passing size="sm" or size="lg" are affected.

NbButton. variant is typed as 'primary' | 'secondary' | 'ghost' | 'success' | 'info' | 'warning' | 'danger' instead of string, so an arbitrary value is now a type error rather than a silently unstyled button. At runtime, a button with no variant no longer carries a stray nb-button--undefined class, and a ghost button no longer draws a bottom border on hover on top of its hover fill.

NbModal traps focus. Tab and Shift+Tab stay inside an open dialog instead of walking onto the page behind the scrim, and closing it returns focus to whatever opened it. This is on by default. Pass :trap-focus="false" for a surface that should not hold the keyboard, and initial-focus to choose where focus lands on open (a selector, or a function returning an element).

Also fixed. Under the rounded appearance, a field's four edges now always resolve to the same colour; before, a page that turned dark on a wrapper rather than on <html> drew light sides with a dark bottom rule. NbColorStrip renders its selected tick again (it asked for an icon that does not exist) and no longer shows an empty "no colour" swatch on a read-only strip. The Vite plugin no longer warns about a duplicated stylesheet in a build that resolved no components through it.

What is new ​

Both of these are opt-in and change nothing until used.

Square or rounded. A second geometry, independent of light and dark. Rounded controls are capsules and nested surfaces are concentric, so an inset corner follows the one around it. Square stays the default and renders exactly as before.

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

configureAppearance({ defaultAppearance: 'rounded' })

useAppearance() reads and changes it at runtime. See Appearance.

Named colour themes. A theme is declared in SCSS against a public entry point that emits nothing when imported. Name base colours in $palette and each is expanded into the same seventeen-step ramp the library builds for its own colours, with an -a11y foreground for every step. Roles then point at steps rather than restating colours:

scss
@use '@nubisco/ui/styles/theme-api' as nb;

@include nb.theme(
  'ocean',
  $palette: (accent: #0f6f8c, neutral: #5b7683),
  $light: ('primary': 'accent-500', 'layer-0': #ffffff),
  $dark: ('primary': 'accent-350', 'layer-0': 'neutral-900')
);
ts
import './themes/ocean.scss'
import { configureNamedTheme } from '@nubisco/ui'

configureNamedTheme({ themes: ['ocean'], defaultTheme: 'ocean' })

A role that points at a step also carries that step's readable foreground, so text on a themed button stays legible without the pairing being maintained by hand.

What to check ​

  • Screenshots and visual baselines will differ wherever a field is visible. Either accept the new fills or set data-nb-preset="classic-fields" and move on your own schedule.
  • CSS written against the shell's DOM. Search for selectors that place the topbar, menubar or notification inside the body, such as .nb-shell__body .nb-shell__topbar or .nb-shell__body > .nb-shell__menubar. They no longer match. Selectors that use the region classes on their own are fine.
  • Anything that assumed the inspector reached the top of the window, such as an inspector header drawn to line up with the topbar, or code that measures the inspector's height. It is now shorter by the topbar's height.
  • NbPagination with size="sm" or size="lg", if the surrounding layout depended on its height.
  • NbButton variants from untyped sources, for example a value read from configuration. vue-tsc now rejects anything outside the union.
  • Modals that relied on focus leaving them, such as a dialog that expected the page behind to stay reachable by keyboard. Pass :trap-focus="false" to restore that.

Why ​

The field fill was the most visible defect: enabled fields looked disabled, and disabled fields differed from them only in brightness. The shell change makes a docked inspector sit under the application's header, the way a docked panel is expected to, rather than competing with it for the top of the window. The pagination heights were a plain mismatch with the table they are drawn beside.

All three are visible changes to a default, which is why this is a major version rather than a minor one.

To 4.1.x from 4.0.x ​

Nothing here is breaking. Two of these are worth reading anyway: one fixes links that were quietly not links, and one can change what your bundle contains.

NbButton, NbSidebarLink and NbSidebarMenuItem rendered an <a> with no href whenever a router was installed, for the to prop. Clicking still navigated, so it passed a smoke test, but the element was not a link: not crawlable, not role="link" for assistive technology, and dead to middle-click, open-in-new-tab and copy-link-address. Prerendered sites baked the missing href into shipped HTML.

This is not a 4.0 regression, it dates from 1.39.1. If you are on any release from 1.39.1 to 4.0.1 and you use to with vue-router, you want 4.0.2. There is nothing to change in your code.

NbBarChart honours orientation and stacked ​

Both were declared props that the render never read, so a chart passing orientation="horizontal" came out vertical with a green typecheck. They work now, which means a chart that was passing either prop will change appearance on upgrade. That is the fix, but it is a visual change rather than a silent one: check any chart that sets them.

Horizontal puts the categories on the vertical axis and sizes its left gutter to the longest label, up to 40% of the width. stacked scales the value axis to the category total rather than the tallest segment.

Charts can be clicked ​

NbBarChart and NbPieChart emit select, so a bar or a slice can be a way into the data behind it. The payload carries the datum rather than the rendered label, and the shared types are now exported from the package entry:

ts
import type {
  IChartSeriesSelection,
  IChartCategoricalSelection,
  TChartSelection,
  IChartSeries,
  IChartPoint,
} from '@nubisco/ui'

Interactivity is opt-in. With no listener bound, nothing about the chart changes: no pointer cursor, no focus ring, no button semantics. Bind one and each bar or slice becomes a focusable button. See Bar Chart for the payload and the accessibility behaviour.

The glyph plugin sees through more expressions ​

Three cases the compile-time resolver used to get wrong. All of them are fixes, and two of them change what gets linked:

  • A glyph forwarded through another component is now resolved.<NbButton :icon="copied ? 'check' : 'copy'"> links those two icons. It previously linked nothing at all, and worked only if some other chunk had loaded the catalogue first, which made it depend on navigation order.
  • An expression is resolved only when every value it can produce is linked artwork. block.icon || 'cube' was treated as fully resolved because it contained a literal and no quoted string survived the rewrite, even though it can just as easily produce block.icon. It now pulls the catalogue in, as it always should have. This can grow a bundle, in exactly the place where the icon was previously going to throw at runtime.
  • Binding an imported glyph module no longer drags a catalogue in.<NbIcon :name="GithubLogo" />, where the file imports GithubLogo from @nubisco/ui/icons/github-logo, is understood as already-linked artwork.

The plugin also warns, once per file, when a forwarded glyph is a value it cannot see through. If those bindings are deliberate pass-through props in your own wrapper components, turn it off:

ts
nubiscoUI({ glyphs: { warnUnresolved: false } })

To 4.0.0 from 3.6.0 or any 3.5.x ​

app.use(NubiscoUI) no longer registers components, and icons and flags are resolved at compile time. This is the change that makes the package installable once and still ship only what an app uses.

What changed ​

Components. The app plugin installs directives, the command palette and app-level configuration. It does not register the 86 components any more, because a static reference to all of them inside install() is reachable from every app and pinned the whole library into every bundle.

Add the bundler plugin instead. It resolves <NbButton> to an import of @nubisco/components/Button in the file that used it:

ts
// vite.config.ts
import { nubiscoUI } from '@nubisco/ui/vite'

export default defineConfig({ plugins: [vue(), ...nubiscoUI()] })

Nothing else in your templates changes. The plugin emits a components.d.ts, so editors and vue-tsc keep seeing the tags.

Per-component entry points. dist/components/ used to contain nothing but declaration files: the whole runtime was one barrel, and exports["./components/*"] pointed at src/, which was never published, so a deep import resolved its types and failed on its runtime. Every component is now a real entry point, importable with or without the plugin:

ts
import { NbButton } from '@nubisco/components/Button'

The package also declares sideEffects, so bundlers can drop what a barrel import does not reach.

Icons and flags. NbIcon and NbFlag used to resolve every name through a single module holding the entire catalogue: ~1,500 icons in six weights and 255 flags, about 1.5MB linked so that one glyph could render. They now resolve in three tiers.

A literal name is rewritten by the plugin into an import of that one glyph, so nothing about your templates changes:

vue
<NbIcon name="github-logo" />
<NbButton icon="plus">Add</NbButton>

A name your code only knows at runtime needs a declaration, and which one depends on whether the set of values is bounded:

ts
// Bounded: values from an API that can only be one of these.
import { registerIcons } from '@nubisco/ui'
import * as check from '@nubisco/ui/icons/check'

registerIcons({ check })
ts
// Open-ended: an icon picker, a CMS field. Import it in the one file that
// needs it; no other page pays for it.
import '@nubisco/ui/icons/all'

If neither is in place, NbIcon throws on first render with a message naming the options, rather than leaving an invisible hole in the page.

Stylesheets. dist/ui.css was one 214KB file containing the styles for all 86 components, so a page with a button downloaded the styles for a Gantt chart. The library now ships one stylesheet per chunk, and the bundler plugin imports the ones each page's components need. A page with a button and an icon loads 19KB instead of 214KB.

@nubisco/ui/css still exists and still contains everything, so nothing breaks if you keep importing it. But if you import it and run the plugin, you get both, so drop it:

ts
// main.ts
- import '@nubisco/ui/css'

Check this on 4.1.2 or later

Before 4.1.2, the per-component stylesheets were only linked for components the plugin auto-imported from your templates. An app that wrote its imports by hand, import { NbButton } from '@nubisco/ui', got no component CSS at all, so dropping this line shipped a completely unstyled app with nothing failing: the build, the typecheck and the tests all passed, and the CSS bundle still looked plausible because the app's own styles and the design tokens were still in it.

4.1.2 gives hand-written imports the same treatment, so the instruction above is safe whichever way you import. On an earlier 4.x, either upgrade or keep the @nubisco/ui/css import with nubiscoUI({ styles: false }).

Design tokens are unaffected: they were never in ui.css. They come from @use '@nubisco/ui/variables', as before.

One thing to check about the cascade. Splitting the stylesheet moves library rules around. Before, @nubisco/ui/css was imported once in your entry, so library CSS always came first and your own stylesheet always came later. Now a component's styles load with the component, which can put them after yours.

Almost every library rule is scoped to its component with a [data-v-…] attribute, so this cannot change which rule wins for those; the build refuses to publish if two stylesheets ever style the same unscoped selector. What it can change is a rule of yours that was beating an unscoped library rule only because of load order. The symptom is a control you had restyled coming back in the library's colours.

If that happens, either raise the specificity of your override, or keep the old arrangement:

ts
// vite.config.ts
nubiscoUI({ styles: false })
ts
// main.ts
import '@nubisco/ui/css'

Composables and utilities. exports["./composables/*"] pointed at ./src/, which files never published, so import { useTheme } from '@nubisco/composables/useTheme.composable' resolved its types and then failed on its runtime. Both subpaths are real entry points now. The specifiers are unchanged.

Removed. @nubisco/ui/plugins/icons and @nubisco/ui/plugins/flags, the Vite plugins that built the virtual:icons and virtual:flags modules, are gone: the artwork now ships as addressable modules and needs no virtual module to reach. Remove them from your Vite config.

What to check ​

  • Add nubiscoUI() to your Vite config, or switch to import NubiscoUI from '@nubisco/ui/all' if you cannot run a bundler plugin.

  • Search for <NbIcon :name="…"> and :icon="…" bindings whose value is not a literal, and give each one a registerIcons entry or a catalogue import.

  • Drop icons() / flags() from your Vite config.

  • If you registered library components by hand to work around resolveComponent('NbGrid') and friends, delete that: components import their own internals now, and resolveComponent("Nb…") appears nowhere in the built output.

  • Drop import '@nubisco/ui/css' if you run the plugin (on 4.1.2 or later, see the warning above), and re-check any place where your CSS overrides a library rule.

  • Load the icon catalogue in your test setup. Component tests do not run your bundler plugin, so a component that renders an icon has nothing to resolve it against and the test fails where the app is fine:

    ts
    // vitest.setup.ts
    import '@nubisco/ui/icons/all'
    import '@nubisco/ui/flags/all'

    Test bundles are not shipped, so the catalogue costs nothing here. The alternative, if you would rather keep tests honest about what the app links, is to run nubiscoUI() in your Vitest config the same way you run it in your build.

What ships in your bundle covers all of this from the consuming side, including how to see what the plugin linked and how to deliberately ship the whole collection.

Why ​

Measured on a consuming site's homepage, before and after: render-blocking payload went from 3,136 KB (801 KB gzipped) to 1,325 KB (318 KB gzipped), and the built site went from 9,502 files to 1,664. The published package went from 18,989 files to 2,755. On a page that renders one button and one icon, the whole payload is 91KB of JS and 19KB of CSS.

Registering components lazily with defineAsyncComponent was tried first and is not a substitute. An unresolved async component during hydration is treated as absent: Vue drops a placeholder over the prerendered markup and rebuilds the DOM when the import lands. That measured a cumulative layout shift of 1.37 to 2.16 against 0, and two e2e tests caught a form ignoring a click that arrived before its wrapper had hydrated. hydrateOnIdle() did not help. Compile-time resolution has none of those properties, because nothing is deferred to runtime.

To 3.3.0 from 3.1.0 ​

The 3.0.1 port rework is reverted. A port is once again three elements: the slot, the hit target and the pin.

What changed ​

Ports go back to the structure 3.0.0 shipped. .nb-blueprint-card__port is the pin again and carries [data-port], .nb-blueprint-card__port-hit is the button that takes the click, and the label is a sibling rather than a child. Everything added in 3.0.2, 3.0.3 and 3.1.0 is untouched.

Why ​

3.0.1 halved the port DOM and made panning measurably worse: on a 41-card session, pan p95 went from 32ms to 82ms and zoom p95 from 51ms to 87ms. The change optimised element count, and element count was never the cost. Ablation on that session put the entire regression in the merged pin.

It also shipped a defect. The new port label CSS was written above the <template> tag, outside every SFC block, where the compiler discards it silently. Inline port labels have rendered invisibly, underneath the pin, since 3.0.1. Reverting removes that with the rest of the change.

3.0.1, 3.0.2, 3.0.3 and 3.1.0 are deprecated on npm for these two reasons.

What to check ​

If you wrote CSS against 3.0.1's single-element port, it needs revisiting: the pin and the hit target are separate boxes again. If you never adopted 3.0.1, which was live for about a day, there is nothing to do.

The underlying question of why the merged pin was slower is not settled. Two hypotheses were tested and eliminated (the port column overlapping the card, and always-on CSS transitions); a third, that per-element --pin-level values moved from a cheap height into gradient stop positions and made 268 pin backgrounds unshareable, remains untested. Any future attempt to reduce port DOM should answer that first.

To 3.1.0 from 3.0.x ​

Five new components, all additive. Nothing was renamed or removed, so the upgrade itself is a version bump. The work is in what you can now delete.

What is new ​

ComponentReplaces the hand-rolled
NbAccordion + NbAccordionItemClick-to-reveal sections in page body. NbShellPanel is still the right thing for an inspector section; this is for everything else.
NbCardGrid + NbCardA responsive catalogue of cards, and the card surface itself.
NbDefinitionList + NbDefinitionListItemLabel-and-value fact blocks.
NbReorderListA vertical list whose rows drag to reorder, with a keyboard path.
NbEmptyStateThe centred block a view shows when it has nothing to show.

What to delete, and what to watch for ​

Each of these exists because the same thing had been written by hand more than once. When you replace a local copy, three of them have a detail worth knowing, because the local copies got them wrong.

A card grid is not an NbGrid. NbGrid's grid prop is a span within the 16-column page grid: it answers "how much of the page does this take". NbCardGrid's min is a column width: it answers "how many of these fit". If you hand-wrote repeat(auto-fill, minmax(260px, 1fr)), that is the prop you were reaching for.

A definition list puts its grid on each row, never on the <dl>. A grid on the list makes every descendant a grid item, so a heading, a divider or a form field placed inside gets pulled into the columns with the terms and values. That is not hypothetical: it is how a hand-rolled one shipped swallowing two form fields. If you keep a local copy for any reason, move the grid to the row.

An empty state has four kinds, and they are not decoration: empty, no-results, error and forbidden want different copy and different actions. Naming the case is what stops "No results" appearing over a request that failed, and stops a view offering to create something in a list that could not load.

A note on host CSS ​

Three of these components are deliberately over-specified against the page around them, and it is worth knowing why if you are reading the source:

  • The accordion's header is a role="heading" div, not an <h3>.
  • The reorder list's row margin is written as .nb-reorder-list .nb-reorder-list__row.
  • The empty state spaces its parts with gap and zeroes every margin.

A host rule like .some-container h3 { margin } or .some-container li { margin } has higher specificity than a component's own scoped class, so a component that styles a bare element cannot defend its own spacing. Each of those three was measurably wrong in a prose container before it was changed. If you are writing a component in this library, prefer a class or a role to an element selector for anything whose spacing matters.

To 3.0.0 from 2.9.x ​

The 'diamond' pin shape is removed. That is the whole release.

Breaking ​

TBlueprintPortShape is now 'pill' | 'square' | 'circle'. A TypeScript consumer passing shape: 'diamond' will fail to compile; at runtime such a pin falls back to the default pill rather than breaking.

2.9.0 already stopped deriving the diamond from dataType, so by this point the value only affected cards that asked for one by hand.

Why ​

Shape is the target a user aims a wire at. A pin that changes form is a pin they have to re-learn, and the diamond was the worst offender: it is the only shape whose visual bounds did not match its box, and a rotated square converges with a pill as soon as the canvas is zoomed out, which is where a node graph spends much of its time. It was carrying a distinction that signal now carries better, as a fill rather than a form.

What to use instead ​

  • To say what a port carries, use signal ('analog' | 'digital', which renders solid or striped) or an explicit color. Neither disturbs the outline.
  • To make one port stand out from its immediate neighbours on the same card, 'square' and 'circle' remain.

To 2.9.0 from 2.8.x ​

Ports gained an analog / digital distinction, and stopped changing shape by data type. Additive: no prop was removed and no port stops rendering, but MIDI and control pins look different, so look at a canvas after upgrading.

What changed ​

No pin defaults to a diamond or a square any more. midi, control, number, vector3, color and asset all rendered as diamonds or squares; every data type now renders as the same pill. A pin is the target a user aims a wire at, and a target whose shape changes with its type is one they have to re-learn per port. The shape was also the least robust way to carry the difference: a rotated square converges with a pill as soon as the canvas is zoomed out. shape is still honoured when set explicitly, so a consumer that wants a diamond keeps one.

signal: 'analog' | 'digital' replaces it, defaulted from dataType. Digital pins are striped and analog pins are solid. A stripe is a fill rather than a form, so it survives scaling, and it leaves the colour channel to dataType.

Activity now depends on the kind. An analog port fills in proportion to portLevels. A digital port has no meaningful level, so it pulses once when it enters activePorts instead. Pulsing is now digital-only; an analog port that goes live without a level still takes the steady fill and halo.

The pulse and the live halo take the pin's own colour. They were drawn in --nb-c-port-signal, a blue that belonged to neither the pin nor the node, so a firing port read as a third thing happening near it.

Where2.8.x2.9.0
midi pin shapeDiamondPill, striped
control pin shapeDiamondPill, striped
color / assetSquarePill, striped
Pulse colour--nb-c-port-signal (blue)The pin's own colour
Live halo colour--nb-c-port-signal (blue)The pin's own colour
Pulse applies toAny port entering activePortsDigital ports only

Removed ​

--nb-c-port-signal is gone. It was added in 2.8.0 for the pulse and the halo, both of which now use the pin's colour, so it had no remaining user. If you referenced it, use --pin-color inside a port rule or the port's own color.

What to check ​

  • Ports that relied on the derived diamond to mean something. If a card distinguished MIDI from audio purely by the shape it got for free, that now comes from the stripe instead. It still reads, but it reads differently, so it is worth a look rather than an assumption.
  • portLevels on digital ports. Harmless, but pointless: a level meter on an event stream is noise. Move those ports to activePorts and let them pulse.
  • Anything that pulsed and should not. Pulsing is digital-only now. An analog port that was relying on entering activePorts to flash will hold a steady fill instead, which is the intended behaviour but is a visible change.

To 2.8.0 from 2.7.x ​

NbBlueprint ports moved outside the card, and the wire layer now measures the pin rather than a box that also contained the pin's label. Everything else in this release follows from those two changes.

It ships in a minor, so a ^2 range picks it up on a routine update. No prop was removed or renamed and no event stopped firing, but graphs will look different and card bounding boxes changed size, so look at a canvas after upgrading rather than after the next deploy.

What changed ​

Wires land on pins. A wire endpoint is derived from the element carrying data-port. That element used to be a flex row containing the pin and, when showPortLabels was on, the inline label, so the endpoint sat at the centre of pin + gap + label, up to 11.5px away from the pin it was supposed to touch. The label is now a sibling, and the pin is the only thing in that box.

For the same reason, size: 'sm' and size: 'lg' are real dimensions instead of transform: scale(). A scaled pin looked larger while keeping a medium pin's box, so its endpoint and its hit area both stayed the wrong size.

Pins sit outside the card. They used to straddle the border, covering it and any left-edge decoration for their full height. Outside, the card's border stays continuous, the hit target never overlaps card content, and the pin is unambiguous to aim at. This is the change with a real cost, in the table below.

The port column is anchored to the top of the card. It used to centre in the card's height, which made a pin's Y a function of how tall the card happened to be: expanding a card or adding a parameter row moved every wire attached to it. Pins now start at --nb-blueprint-port-top and step by --nb-blueprint-port-pitch.

Drop targets exist. While a wire is dragged, compatible pins ring and incompatible ones dim. Nothing to wire up.

The card is keyboard-operable. It had no tabindex, no aria-* and no focus styles at all; selection and connection were mousedown-only.

Colour, type and radius come from tokens. The card carried around fourteen literal colours, including a rgba(255, 255, 255, 0.015) parameter-row fill that is invisible on the light ramp, and its own 10px corner radius. It now reads --nb-c-port-*, --nb-c-status-*, --nb-c-node-row-bg and --nb-radius-xs, and the node colour is spent once, on a rail down the card's left edge, instead of on the top bar, the category, every wired pin and the toggle at the same time.

Where2.7.x2.8.0
Pin positionStraddling the card borderFully outside, flush with the outer edge
Card bounding boxThe cardThe card + 24px of port gutter on each side
Wire endpointCentre of the port element's boxCentre of the pin
Port column originCentred in the card's height--nb-blueprint-port-top from the top
Port pitch6px gap, variable--nb-blueprint-port-pitch, 24px
Hit targetThe 6×14 pinA 24×24 button behind it
Card radius10pxvar(--nb-radius-xs)
Category colourThe node colour--nb-c-text-subtle
Toggle "on" colourThe node colour--nb-c-primary
Status indicator6px colour-only dot with a glow14px glyph, distinct per state
HovertranslateY(-1px) + drop shadowSurface steps to --nb-c-layer-hover-1
Active portInfinite 1s ring in #38e0ffLevel fill, or one ping per event

New API ​

All additive.

AdditionOnFor
densityNbBlueprint, NbBlueprintCard'default' or 'compact'. Set once on the canvas; cards inherit.
portLevelsNbBlueprintCardRecord<string, number>, 0 to 1. Listed pins render as meters.
dataTypeIBlueprintCardPortEventThe pin's declared type, so the other end of a drag can validate it.
dragOrigin, density, nudge, cancelPortDragIBlueprintCardContextWhat the card needs to draw drop targets and route keyboard nudges.
--nb-radius-noneThemeCompletes the existing radius scale.
--nb-c-port-bg, -port-border, -port-signalThemePort colours, previously literals in the component.
--nb-c-status-valid, -warning, -errorThemeStatus foregrounds, held to 3:1 on every surface a card paints.
--nb-c-node-row-bgThemeParameter row fill.

What to check ​

  • Card spacing in your layouts. This is the one that will bite. A card's visual box is now 24px wider on each side that has ports. If your positions were tuned so cards sat close together, adjacent cards' port gutters will now touch or overlap. Auto-layout inside the component already accounts for it; hand-authored coordinates and any spacing maths of your own do not.

  • Anything that measured a card element. getBoundingClientRect on .nb-blueprint-card is unchanged, the gutters are absolutely positioned outside it, but if you were measuring a wrapper, or hit-testing against a card rectangle to decide what the pointer is over, the ports now sit outside the rectangle you were testing.

  • CSS that targeted .nb-blueprint-card__port for layout or interaction. It is now the pin only, a pointer-events: none span inside a .nb-blueprint-card__port-hit button. Selectors that styled it still apply, but rules that positioned it, gave it a hover state, or bound a listener to it need to move to .nb-blueprint-card__port-hit.

    The one to check first is any rule that made ports inert, because it will now silently do nothing:

    css
    /* Before: disabled the port. After: disables a span that was already
       pointer-events: none, while the button behind it stays live. */
    .nb-blueprint-card__port {
      pointer-events: none;
    }
    
    /* After */
    .nb-blueprint-card__port-hit {
      pointer-events: none;
    }
  • Handlers typed against the old port-event shape. The payload gained an optional dataType. If you asserted on the whole object (toEqual in a test, an exhaustive destructure), it now has one more key.

  • --nb-card-glow. Still published on the card, so host CSS reading it keeps working, but the component no longer paints anything with it. If you were relying on the card's own radial wash, it is gone deliberately: it was invented to fake depth on a dark ground and it reads as a grey smudge on the light ramp.

  • Custom card headers. --nb-blueprint-port-top lines the first pin up with the stock header. If you replaced the header through the default slot, set it to your own header's centre.

  • Reduced motion. The card now honours prefers-reduced-motion. If your tests asserted on the port ping, it does not schedule under that preference.

Keeping the old look ​

There is no flag for it, and the geometry changes are not separable from the fixes. The nearest thing, if you need cards to occupy their old footprint while you retune positions, is to shrink the gutter:

css
.nb-blueprint-card {
  --nb-blueprint-port-hit: 8px; /* gutter matches the pin, as it used to */
}

That gives back the horizontal space at the cost of the comfortable hit target, so treat it as a migration aid rather than a setting.

To 2.6.0 from 2.5.x ​

The main region of NbShell is now the page ground. It paints layer 0, and the first surface a page puts into it paints layer 1 instead of layer 2. Nothing was renamed and no prop changed: this is a visual upgrade, and in most applications it is the one you were working around.

It ships in a minor, so a ^2 range picks it up on a routine update. Look at a couple of screens after upgrading rather than after the next deploy.

What changed ​

NbShell resolved to layer 1 and provided that as the context for every one of its slots, so a top-level NbPanel on a page painted layer 2, #e7e8e9 in the light theme. That is the darkest fill in the light ramp, sitting on a near-white body, and one level deeper than the model the layer documentation describes (ground 0, panel 1, nested section 2).

The chrome keeps its old depth. Topbar, menu strips, fixedbar and the shell body still resolve to layer 1 and are pixel-identical, and a surface dropped into a chrome slot still counts from there.

Where2.5.x2.6.0
Shell chrome (topbar, menu strips, body)layer 1layer 1
Shell main regionlayer 1layer 0
First NbPanel on a pagelayer 2layer 1
Panel inside that panellayer 3layer 2
Surface in a topbar-* slotlayer 2layer 2

In colours, a top-level panel goes from #e7e8e9 to #f9fafb in the light theme and from #2d2e2f to #1e1f20 in the dark one, and the page behind it from #f9fafb to #edeeef (light) and #1e1f20 to #0f1011 (dark).

What to check ​

  • Surfaces you painted by hand inside a page. If you wrote #fff (or any literal) on rows and cards to escape the too-dark panel, delete it and let the layer tokens do the work. That workaround now fights the ramp instead of rescuing it.
  • .nb-layer-N containers and :layer props inside main. A number written to compensate for the old off-by-one is now one too deep. Containers still win over derivation, so nothing breaks, it just stays wrong. Delete the annotation and let nesting derive it.
  • Any CSS in your page that reads var(--nb-c-surface) directly. Inside the main region that now resolves to layer 0, the ground. If the rule was painting a card, put it in an NbPanel or wrap it in <NbLayer>.

Keeping the old look ​

Pin the page back to the chrome depth. This is a migration aid, not a recommendation.

vue
<NbShell>
  <NbLayer :level="2">
    <!-- panels in here paint 2, as they did in 2.5.x -->
  </NbLayer>
</NbShell>

To 2.0.0 from 1.x ​

Every layer, text and field colour changed, in both themes, and surfaces now work out their own depth instead of being told it. No component was removed and no prop was renamed, so this upgrade is almost entirely visual: the things to check are your token overrides and any place you hand-annotated a layer.

If you never override --nb-c-* and never wrote .nb-layer-N, upgrading is just a version bump. Look at a couple of screens and move on.

Why it changed ​

We audited the layer system against WCAG contrast targets and decided to rebuild it: the ramp was not doing the job it existed to do.

  • Muted text scored 3.62:1 on dark layer 3, below WCAG AA, and 4.82:1 on light layers 0 and 2.
  • --nb-c-text-subtle was below AA on every layer in both themes, as low as 2.22:1, and had never been measured at all.
  • Text on hover surfaces was never measured. A hovered row is where muted text most often lives.
  • Dark layers separated by 6.70 / 5.20 / 5.58 dL*, flat and weakest in the middle, so nesting got harder to read as it got deeper.
  • Light layer 2 was byte-identical to layer 0, and layer 3 to layer 1, so past one level of nesting the fill carried no depth information.
  • --nb-c-field-bg was the same value as layer 0, so a field on the page had no fill boundary.
  • Surfaces emitted as fractional rgb(229.5, 229.5, 229.5), because they came from a lightness-stepped tint chain rather than chosen values.

Both text tiers now clear AAA (7:1) on all nine painted surfaces per theme: four layers, four hover states, and the field. --nb-c-text-subtle clears AA on the same nine. Run node scripts/audit-contrast.mjs in the repo to see the current numbers against a published reference set.

1. Token values moved ​

If you override any of these, your value was chosen against the old ramp and almost certainly no longer fits. Re-check each one.

Light

Token1.x2.0
--nb-c-layer-0rgb(229.5, 229.5, 229.5)#edeeef
--nb-c-layer-1#ffffff#f9fafb
--nb-c-layer-2rgb(229.5, 229.5, 229.5)#e7e8e9
--nb-c-layer-3#ffffff#f3f4f5
--nb-c-layer-border-0rgb(191.25, 191.25, 191.25)#a3a4a5
--nb-c-layer-border-1rgb(191.25, 191.25, 191.25)#9c9d9e
--nb-c-layer-border-2#a7a7a7#838485
--nb-c-layer-border-3#cccccc#818283
--nb-c-layer-hover-0#cccccc#dcddde
--nb-c-layer-hover-1rgb(216.75, 216.75, 216.75)#e2e3e4
--nb-c-layer-hover-2#cccccc#d7d8d9
--nb-c-layer-hover-3rgb(216.75, 216.75, 216.75)#e1e2e3
--nb-c-text#000000#242526
--nb-c-text-mutedrgb(92.9, 99.0, 111.1)#38393a
--nb-c-text-subtle#6b7280#545556
--nb-c-field-bgrgb(229.5, 229.5, 229.5)#cacbcc
--nb-c-field-borderrgb(127.5, 127.5, 127.5)#646566

Dark

Token1.x2.0
--nb-c-layer-0rgb(23.2, 24.7, 27.8)#0f1011
--nb-c-layer-1rgb(38.25, 38.25, 38.25)#1e1f20
--nb-c-layer-2rgb(46.4, 49.5, 55.6)#2d2e2f
--nb-c-layer-3rgb(58.1, 61.9, 69.4)#3d3e3f
--nb-c-layer-border-0rgb(58.1, 61.9, 69.4)#434445
--nb-c-layer-border-1rgb(69.7, 74.2, 83.3)#4d4e4f
--nb-c-layer-border-2rgb(81.3, 86.6, 97.2)#595a5b
--nb-c-layer-border-3rgb(92.9, 99.0, 111.1)#696a6b
--nb-c-layer-hover-0rgb(46.4, 49.5, 55.6)#1a1b1c
--nb-c-layer-hover-1rgb(58.1, 61.9, 69.4)#292a2b
--nb-c-layer-hover-2rgb(69.7, 74.2, 83.3)#38393a
--nb-c-layer-hover-3rgb(81.3, 86.6, 97.2)#313233
--nb-c-textrgb(216.75, 216.75, 216.75)#eaebec
--nb-c-text-mutedrgb(143.9, 150.0, 162.1)#d0d1d2
--nb-c-text-subtle#6b7280#a7a8aa
--nb-c-field-bg#333333#141516
--nb-c-field-borderrgb(92.9, 99.0, 111.1)#626364

Two shape changes worth knowing when you re-pick values:

  • Dark hover reverses at layer 3. Levels 0 to 2 lighten, level 3 darkens. There is no headroom above layer 3, and a hover lighter than it forces muted text toward white and collapses the gap between the two text tiers. Established dark ramps reverse at the top of the stack for the same reason.
  • Light alternates rather than descending. Grey ground, lighter panel, darker nested section, lighter popover, so the theme stays light as surfaces stack. No two surfaces repeat, so every level of nesting still carries depth.

2. --nb-c-bg and --nb-c-bg-soft now follow the ramp ​

They used to carry independent values. They are now aliases:

css
--nb-c-bg: var(--nb-c-layer-0);
--nb-c-bg-soft: var(--nb-c-layer-1);

If you override --nb-c-layer-0 or --nb-c-layer-1, the background tiers move with it. If you were relying on them differing from the layer ramp, set them explicitly.

3. Nested surfaces now deepen on their own ​

This is the behaviour change most likely to show up on screen.

In 1.x, --nb-c-surface defaulted to layer 1 everywhere, so a NbPanel inside a NbPanel painted the same colour, and a NbModal on a plain page also painted layer 1. The only fix was to annotate every nesting site by hand.

In 2.0 a surface derives its level from how deeply it is nested. Concretely:

  • NbPanel inside NbPanel now paints layer 1 then layer 2, not layer 1 twice.
  • NbModal now paints layer 3 rather than layer 1.
  • The same applies to NbBoard, NbCalendar, NbDataTable, NbFileUploader and NbShellPanel.

Your existing .nb-layer-N markup still works and still means the same thing. A surface directly inside .nb-layer-2 still paints exactly layer 2. What changed is what happens below that: a second surface nested inside the first now goes one deeper instead of repeating.

So if you hand-annotated deep trees to work around the old behaviour, you now have annotations fighting a mechanism that would have done it for you. Delete the inner ones and keep the outermost:

vue
<!-- 1.x: every level annotated because nothing was automatic -->
<div class="nb-layer-0">
  <NbPanel class="nb-layer-1">
    <NbPanel class="nb-layer-2">
      <NbPanel class="nb-layer-3">…</NbPanel>
    </NbPanel>
  </NbPanel>
</div>

<!-- 2.0: state the ground, let nesting do the rest -->
<div class="nb-layer-0">
  <NbPanel>
    <NbPanel>
      <NbPanel>…</NbPanel>
    </NbPanel>
  </NbPanel>
</div>

Both render identically. The second one keeps working when someone adds or removes a level.

4. Setting a layer deliberately ​

Three ways, strongest first:

vue
<!-- a prop, on components that paint a surface -->
<NbPanel :layer="2" />

<!-- a component that sets or resets context for its subtree -->
<NbLayer :level="0">
  <NbPanel />
</NbLayer>

<!-- the class, unchanged from 1.x -->
<div class="nb-layer-2"><NbPanel /></div>

And for your own surface-painting components:

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

const { layerProps } = useSurfaceLayer()
// bind layerProps on the element that paints the background

Pass { overlay: true } for anything teleported (a popover, a floating menu) so it pins to the top layer instead of inheriting the depth of whatever the teleport target happens to sit in. useLayer() reads the current level without claiming one.

5. --nb-c-text-subtle is an AA role now ​

It is held to AA (4.5:1), not AAA, and that is deliberate. 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 it only for genuinely non-essential text. If you used --nb-c-text-subtle for anything someone has to read to operate your interface, move that to --nb-c-text-muted, which is AAA on every surface.

  1. Bump the version and run your app. Nothing should crash: no API changed.
  2. grep -rn "nb-c-layer-\|nb-c-text\|nb-c-field-\|nb-c-bg" src/ in your app. Every hit is an override written against the old ramp. Re-check each.
  3. grep -rn "nb-layer-" src/ in your app. Keep the outermost annotation on each tree, delete the ones below it, and confirm the tree still reads.
  4. Look at your modals and popovers specifically. They moved from layer 1 to layer 3, which is the largest single visual change.
  5. Check anything using --nb-c-text-subtle for essential text.

If you need more time ​

Every value is a CSS custom property, so you can pin the old ramp on :root and upgrade the rest of the release now, then move to the new values on your own schedule:

css
:root {
  /* the 1.x values verbatim, fractional channels and all */
  --nb-c-layer-0: rgb(229.5, 229.5, 229.5);
  --nb-c-layer-1: #ffffff;
  --nb-c-layer-2: rgb(229.5, 229.5, 229.5);
  --nb-c-layer-3: #ffffff;
  /* and the rest from the tables above */
}

This is an escape hatch, not a destination: the old values are the ones that failed WCAG AA on muted text in both themes, and pinning them keeps those failures. The contextual nesting in section 3 still applies, because that is behaviour rather than colour.

To 1.56.1 from 1.53.1-1.56.0 ​

If you already upgraded to 1.53.1-1.56.0 and your type checker stayed green, it was not telling you the truth, and this release is when the errors actually arrive. Work through the 1.56.0 section below: it applies to you now.

Those releases restored the type declarations, but only for types you import by name. Components registered globally by the plugin are never imported, so they resolve through a different path: Vue's template checker looks up the interface GlobalComponents in module vue, by that exact name.

We emitted the augmentation as IGlobalComponents, to satisfy an internal lint rule requiring an I prefix on interfaces. That is a perfectly valid module augmentation of an interface nothing reads. The result was that every <Nb*> tag in every consumer template still resolved to any, and no prop on any globally-registered component was checked at all.

So if you consume the components globally (app.use(NubiscoUI)) rather than importing them one by one, none of the prop checking described below was ever in effect for you. Expect the burst of errors on this upgrade instead.

Nothing about the components changed, at runtime or in their API. This release only corrects the name of the emitted interface, and adds a build gate (verify:consumer-globals) that type-checks a consumer template against the built output so the name cannot drift again.

To 1.56.0 from anything below 1.53.1 ​

If you are coming from 1.52.x or earlier, upgrading will surface TypeScript errors that look like new breakage but are not. Nothing was removed from the API. Types that were silently unenforced started being enforced, and two of them were then corrected to match the components' real behaviour.

Read this section before you start deleting size props.

Why errors appear ​

Every release up to and including 1.53.0 shipped a broken type surface. The build never emitted the hand-written declaration modules (DataTable.d.ts, Size.d.ts, Props.d.ts and 54 others), because TypeScript treats a .d.ts input as already-emitted output and skips it. dist/main.d.ts re-exported those modules anyway, so they resolved to nothing.

Under the usual skipLibCheck: true this failed silently. Imports still worked, but every affected type degraded to any, and any accepts anything. On 1.53.0 the number 42 was an acceptable IDataTableColumn.

1.53.1 restored the missing declarations. Your types started being checked for the first time, so pre-existing incorrect usage became visible all at once. That is the source of nearly every error you will see.

What to change ​

1. size="xs" on components that never supported it ​

ESizeShort is sm | md | lg and always has been. It was never enforced, so size="xs" was accepted on components that have no xs style at all.

NbModal is the common case. Its sizes come from a map of sm | md | lg only. size="xs" matched no rule, leaving the dialog on its base width: 100% with no max-width, so those modals have been rendering full width rather than "extra small". Pick a real size:

vue
<!-- before: no xs rule exists, renders full width -->
<NbModal size="xs" />
<!-- after -->
<NbModal size="sm" />

The same applies to NbDataTable and NbPagination (sm | md | lg).

2. size="xs" on NbButton, which is now valid ​

NbButton is the opposite case. Its stylesheet has always implemented seven sizes, but the prop was typed with the three-value scale, so four valid sizes were rejected. Since 1.55.0 the type matches the CSS and the full scale is accepted:

vue
<NbButton size="xxs" />
<NbButton size="xs" />
<NbButton size="sm" />
<NbButton size="md" />
<NbButton size="lg" />
<NbButton size="xl" />
<NbButton size="xxl" />

If your buttons already used size="xs" and looked correct, they were correct. Keep them.

3. String literals on props typed with enums ​

A template attribute is a string literal, and a string literal is not assignable to a string enum even when the values match, so size="sm" failed against size?: ESwitchSize despite ESwitchSize.Small === 'sm'.

1.56.0 fixed this across the library. Every template-facing prop now accepts its plain string value. No change is needed on your side beyond upgrading, and existing enum-member call sites keep working:

vue
<!-- now valid -->
<NbSwitch size="sm" />

<!-- still valid -->
<NbSwitch :size="ESwitchSize.Small" />

Props fixed in 1.56.0:

ComponentProps
NbAiLabelvariant, size
NbBadgevariant, size
NbButtontype
NbCheckboxGroupdirection
NbFileUploaderstatus, variant
NbIconweight, animationMode, animation
NbLabelsize
NbModalsize
NbProgressBarsize, status
NbRadiodirection
NbSwitchvariant, size

NbButton's type covers the native HTML button types, so a form submit button works without a passthrough prop:

vue
<NbButton type="submit">Save</NbButton>
  1. Upgrade to the latest version and run your type checker.
  2. Expect a burst of errors. They are pre-existing incorrect usage becoming visible, not new regressions.
  3. Fix size values against the table below. Do not silence errors by removing the prop, because that changes the rendering.
  4. Re-run. Anything left is likely a genuine bug the old any types were hiding.

Valid size values ​

ComponentAccepted sizes
NbButtonxxs xs sm md lg xl xxl
NbSwitchsm md lg
NbModalsm md lg
NbDataTablesm md lg
NbPaginationsm md lg
NbProgressBarsm md
NbBadgesm md
NbLabelsm md
NbAiLabelsm md lg

Known gap ​

The enums themselves (ESwitchSize, EButtonType, …) are not re-exported from the package root, so import { ESwitchSize } from '@nubisco/ui' does not resolve. Use the plain string values, which is the supported form, or reach an enum through its component path (@nubisco/components/Switch).