NbSpinner says one thing: work is happening and we cannot say how much is left. It carries no measurement, so if you can count bytes, files or records, you are holding the wrong component.
<template>
<NbSpinner label="Loading invoices" />
</template>Which loading component
| Situation | Use |
|---|---|
| You know the total (files, bytes, steps) | NbProgressBar with value |
| You know nothing but the operation blocks a region or the page | NbSpinner |
| A named action is running and its result must be confirmed | NbInlineLoading |
| A page or list is filling in for the first time and the shape is known | NbSkeleton |
The overlap that matters is with NbProgressBar. It accepts no value and sweeps indeterminately, which looks like it competes with this component. It does not: use the bar when the operation has a measurable total that you are about to learn (an upload negotiating its size), and the spinner when there will never be a number. A bar that sweeps forever is a promise of a percentage that never arrives.
Sequencing a whole page load
The three components are rarely a choice made once. A real screen loads in stages, and the stage decides the indicator. The audit found one app rendering the literal string "Loading" under eight different class names, largely because nobody had written down what a second and a third wait are supposed to look like.
First paint. Show the shell you already know: the page title, the empty table frame, the sidebar. Fill the unknown parts with NbSkeleton shaped like what is coming. Do not cover a shell you have already drawn with a scrim, and do not spinner-block a page whose header is ready to read.
Regions that arrive late. A chart or a panel that resolves after the rest gets its own overlay="container" spinner, scoped to that region. The rest of the page stays live. This is the stage most often over-blocked: a full page scrim for one slow widget tells the user the whole application is unavailable when it is not.
Refresh in place. Content is already on screen and is being replaced. Never swap it back to skeletons: the page collapses and rebuilds, the scroll position moves and the user loses their place. Keep the stale content, dim it with overlay="container", and let the new data replace it underneath.
Load more. Appending a page of results is not a page load. Put a small inline spinner in the footer row of the list, or reuse the trigger button's own loading state, and leave everything above it untouched. A skeleton row at the bottom is also fine, since it predicts exactly what is about to appear there. What is never fine is a scrim over results the user is still reading.
A named action the user started. Saving, publishing, revoking. That is NbInlineLoading, because the load is only half the story and the outcome is the half that four apps currently omit entirely.
Sizes
Diameter is derived from the field height scale, at exactly half the height of the control of the same size step. A spinner dropped into an md button is optically centred with no per-call-site nudging, and stays that way if the field scale is ever retuned.
| Size | Diameter | Use with |
|---|---|---|
xs | calc(var(--nb-field-height-xs) / 2) | Toolbars, table cells |
sm | calc(var(--nb-field-height-sm) / 2) | Buttons, rows, chips |
md | calc(var(--nb-field-height-md) / 2) | Panels and cards |
lg | calc(var(--nb-field-height-lg) / 2) | Page and region overlays |
Tone
primary paints the arc in var(--nb-c-primary). Inside a coloured surface, switch to inherit so the arc takes the surrounding color, whatever the consuming product themed it to. Never restate a brand colour at the call site: that is how a white-label build ends up with a purple ring.
<template>
<span class="row-status">
<NbSpinner size="sm" tone="inherit" decorative />
Revoking access
</span>
</template>Buttons already have one
Do not nest a spinner inside a button to show that the button's own action is running. NbButton has a loading prop that swaps its decoration for a ring, blocks pointer events and sets disabled, all of which a nested spinner would leave to you.
<template>
<NbButton variant="primary" :loading="saving" @click="save">Save</NbButton>
</template>The button draws that ring itself with a border trick rather than mounting an NbSpinner, and the two now agree about reduced motion: under prefers-reduced-motion: reduce the button's ring also closes (border-top-color: currentcolor) and breathes instead of rotating, so a product cannot show one indicator spinning and another one not.
They are not identical, and the difference is worth knowing if you are comparing them side by side. The button answers the preference with a media query only, so it picks up a change to the OS setting on the next load; the spinner also mirrors the preference onto a class read from matchMedia, so it reacts with the page already open. The button breathes opacity between 0.35 and 1, the spinner breathes stroke-opacity between 0.25 and 1. Both read as alive; neither freezes.
Delay
A spinner that appears and disappears inside 100ms reads as a glitch, and it is the reason so many screens flicker on a warm cache. delay gates rendering: during the delay nothing is in the DOM at all, so a request that resolves in 80ms draws nothing and announces nothing.
<template>
<!-- Local, usually instant: only show a spinner if it actually stalls -->
<NbSpinner v-if="loading" :delay="300" label="Searching" />
</template>Rule of thumb: 0 for anything that crosses a network under load, 300 for cached or local work, and never above 1000, past which the screen looks frozen.
delay is reactive while the spinner is still hidden: raising it pushes the reveal out, and dropping it to 0 shows the ring at once. It deliberately does not re-hide a spinner that is already visible. Taking an indicator back out of the accessibility tree after its live region has already spoken is worse than leaving it where it is.
Overlay
overlay="container" covers the nearest positioned ancestor with a scrim and centres the spinner. If nothing above it is positioned, it escapes to the next one that is, so set position: relative on the region you mean to cover.
It blocks that region rather than only shading it. While the spinner is up, its DOM parent is marked aria-busy="true" and every other child of that parent is marked inert, so the controls under the scrim leave the tab order and the hit testing along with it. This is the difference between a container overlay and a grey rectangle: a scrim that only stops the mouse lets a keyboard user tab straight into a form that looks unavailable and submit it. Both attributes are put back exactly as they were found, including an aria-busy the application had already set for its own reasons.
The parent that gets contained is the spinner's DOM parent, which is why the positioned element and the spinner have to be siblings-of-content in the same box. If you wrap the spinner in an extra <div>, that <div> is the region, and it contains nothing.
Getting the positioning wrong splits the two halves apart: the containment still scopes itself to the DOM parent, while the scrim escapes upwards to whatever is positioned and shades that instead. In development the component says so in the console rather than leaving you to report it as a styling bug, because the wrong thing on screen looks nothing like the actual cause.
<template>
<section style="position: relative">
<ContactTable :rows="rows" />
<NbSpinner
v-if="refreshing"
overlay="container"
size="lg"
show-label
label="Refreshing contacts"
/>
</section>
</template>overlay="page" blocks for real
overlay="page" is the same scrim pinned to the viewport, and it is the only mode that changes the state of the document. Reserve it for work that genuinely disables the whole application, such as a tenant switch that invalidates every view. If the rest of the page is still usable, covering it is a lie about what is blocked.
Everything it does follows from one rule, and it is worth stating before the list because it is the only thing you have to remember:
A page overlay blocks the application as it stood when it went up. Anything that arrives afterwards arrived on top of it, and stays usable.
That is the same arbitration NbModal already uses to decide which of a stack of dialogs answers Escape: the last one in document order wins. Here it decides both halves at once.
- It teleports to
<body>.position: fixedresolves against the nearest transformed, filtered or contained ancestor, not the viewport, so a sidebar with atransformtransition on it is enough to trap a "page" overlay inside the sidebar. Leaving the tree removes the question, and it is why a caller'sclassstill lands on the ring: the component rebinds$attrsby hand, since a<Teleport>root has no element to fall attributes through to. - The application is marked
inert. Every child of<body>that existed when the overlay claimed gets the attribute, so nothing behind the scrim is clickable, focusable or reachable by a screen reader's virtual cursor.inertis the containment: there is no focus trap here, no listener that pulls focus back, and nothing that swallowsTab. A trap has to decide, on every focus change, whether the new holder outranks the overlay, and a widget that refocuses itself when it loses focus turns that decision into an unbounded recursion. The attribute has no such conversation to have. - Focus moves onto the scrim, which is why it is the one element in the subtree with a
tabindex. When the overlay leaves, focus goes back to whatever had it before. If the work destroyed that control, which is normal for a delete, focus lands on the nearest thing near it that was already focusable. It never lands on a container we made focusable: writingtabindex="-1"onto an element of yours would outlive the spinner and turn an ordinary<div>into a target for every later.focus()in your application. If nothing focusable survived, focus is left where the browser put it, and the fix is atabindex="-1"on your own region, chosen by you. - Body scroll is locked, through the same counted lock
NbModalandNbCommandPalettehold a share of. Counting is the whole point: with two uncounted owners, a spinner released while a dialog is still open hands the page back its scrollbar mid-decision, and the dialog then restores thehiddenit saved and leaves the page unscrollable forever with nothing on screen. The originaloverflowis read once, when the first owner claims, and restored verbatim when the last one leaves.
What happens when a dialog opens on top
This is the ordering that breaks hand-rolled versions, and it is not exotic: the blocked work itself raises a confirmation, or the session expires and an NbModal teleports to <body> while the scrim is up.
Under the rule, that dialog arrived after the claim, so it is not inert and is fully usable, and it paints above the scrim because the page overlay sits at exactly var(--nb-zindex-modal) and teleported siblings at equal z-index paint in document order. Paint and focus agree because they are the same rule. When the spinner releases, it does not yank the user back out of the dialog.
The reverse ordering is the same rule read the other way: a spinner raised while a dialog is already open covers that dialog and inerts it, because the dialog was part of the application at claim time. That is usually what you want, since the dialog is what started the work.
Two page overlays at once (a save still blocking when a tenant switch fires) stack without double-darkening: only the first paints the scrim, and if it leaves first the survivor takes over the paint rather than blocking invisibly. Both hold a share of every attribute they set, so the first one to release cannot unblock half the application underneath a scrim that is still up.
What it does not block
Toasts (--nb-zindex-toast, fifty layers above --nb-zindex-modal) and tooltips paint above the page overlay by token, on purpose: a message about what just happened is worth more than an unbroken scrim.
Painting above it is not enough. inert removes a subtree from hit testing, from the tab order and from the accessibility tree, so a live region caught by the snapshot is drawn above the scrim and silent: "Session expired" on screen, unspoken, for exactly the user who cannot see it. That is the same paint-and-focus disagreement the rule above exists to make impossible, arrived at from the other side.
So the rule has one exception, and it is one attribute:
<div data-nb-overlay-exempt role="status" aria-live="polite"></div>A host carrying data-nb-overlay-exempt is never inerted by overlay="page" or by overlay="container". The library's own announcers are exempt already: the shared completion announcer behind completeLabel carries the attribute, and NbToaster's live regions and stack are recognised by class name as well, so a toast raised while the page is blocked stays both audible and operable (its action button is reachable under the scrim) even against a build of the toaster that predates the attribute.
Set it on a live region you own and mount at the top of the document. For a region created from script, there is a helper:
import { markOverlayExempt } from '@nubisco/ui'
const region = document.createElement('div')
region.setAttribute('role', 'status')
document.body.appendChild(region)
const undo = markOverlayExempt(region)Two limits worth knowing:
- If an exempt host sits inside a wrapper of yours (a toaster teleported to your own
#toastselement), the wrapper is not blocked whole. The question is asked again of each of its children, so the announcer stays live and its siblings are still blocked. The walk stops after eight levels: anything exempt buried deeper than that is treated as part of the page, because the alternative is an unbounded query over your whole tree on every claim. - The attribute is read at claim time. Marking a region while an overlay is already up does not retroactively free it.
Carry it only on hosts that exist to tell the user something: an announcer, or a toast stack whose single action is the way out of the block. A form that opts itself out of blocking is a form the user can submit while the page says it cannot be used, which is worse than never blocking at all.
Long waits, and a way out
An indeterminate spinner stops informing after about ten seconds. It says the same thing at second one and at second forty, and by then the honest reading is that something is stuck. A page overlay makes that worse than a stuck screen: a request with no timeout behind an overlay="page" is an application the user cannot leave, and closing the tab is the only exit left.
Three answers, in order of preference:
- Learn a number and switch to
NbProgressBar. - Give the block a way out. The default slot renders below the ring, inside the one subtree a page overlay does not inert, so a control there is genuinely reachable and focusable while everything behind the scrim is not.
- Stop blocking. Let the work continue in the background, tell the user when it lands with a toast, and give them the page back.
<template>
<NbSpinner
v-if="switching"
overlay="page"
size="lg"
show-label
label="Switching tenant"
>
<NbButton variant="secondary" @click="abort">Cancel</NbButton>
</NbSpinner>
</template>Whatever you choose, put a timeout on the request itself. The overlay's lifetime is your v-if, and nothing in the component will ever take it down for you.
Saying it finished
A spinner announces the start of a load and then vanishes. Sighted users see the content arrive; a screen reader user gets silence, because the live region that was doing the talking is removed at the same moment the content lands, and newly inserted content is not itself an update anything is watching. Completion is the half of the load that goes missing everywhere.
completeLabel closes it. The message is written into a shared, permanently mounted live region owned by the library, so it survives the unmount that triggers it.
<template>
<NbSpinner
v-if="loading"
label="Loading invoices"
complete-label="Invoices loaded"
/>
</template>Two rules keep it from becoming noise:
- Set it only where the user is waiting rather than watching. A background refresh of a widget nobody asked about should stay silent.
- Set it once per load. If a region has a skeleton and a spinner, one of them carries
completeLabeland the other does not.
If the load ends by moving focus somewhere (a detail panel opening, a wizard advancing) then the focus move is the announcement, and adding completeLabel on top of it says the same thing twice. Pick one.
Do and don't
- Do name what is loading.
label="Loading invoices"beats"Loading", which is what a screen reader user hears from every spinner on the fleet. - Do use
showLabelon overlays. A scrim with an unlabelled ring gives a sighted user no more information than a frozen screen does. - Don't put a spinner where the content shape is already known. A list that is about to render twenty rows should render twenty
NbSkeletonrows. - Don't confirm success with a spinner disappearing. Silence is not confirmation. Use
NbInlineLoading. - Don't stack two spinners for one operation. If an ancestor already announces the load, mark the inner one
decorative.