NbSkeleton stands in for content that is about to arrive, in the shape and at the size the real content will occupy. That last part is the whole component: a placeholder that is not the right height is worse than no placeholder, because the page settles twice, once when the skeleton appears and again when it is replaced.
<template>
<NbSkeleton v-if="loading" :lines="3" label="Loading description" />
<p v-else>{{ description }}</p>
</template>Why the sizing rules matter
A text skeleton does not guess a height. The line box is font-size x line-height read straight off a type set through the --nb-type-* custom properties, so a body-md skeleton line is exactly as tall as the body-md paragraph that replaces it. The grey bar inside is shorter than the line box, the way ink is shorter than a line of text, which is why it reads as text rather than as a wall.
That is also why type is a closed list. An unknown name would resolve to an empty custom property, the line would collapse to zero height, and the page would shift on load: exactly the bug this component prevents, made invisible by a typo. An unrecognised value falls back to body-md.
The list is every type set that content arrives in: label-sm/md/lg, body-sm/md/lg, code-sm/md/lg and heading-01 through heading-06. The code sets are there because monospaced blocks are the worst offenders: a JSON viewer placeholder measured with body-md is a different height from the mono text that lands in it. display-01 and display-02 are deliberately absent, since hero type does not arrive asynchronously.
<template>
<!-- Matches a heading-03 title, to the pixel -->
<NbSkeleton variant="heading" type="heading-03" />
</template>| Variant | Sized by | Use for |
|---|---|---|
text | type (default body-md), lines | Paragraphs, cell values, list items |
heading | type (default heading-02) | Titles and section headers |
block | width and height you pass | Cards, images, charts, map panes |
circle | One of width or height | Avatars, round icon buttons |
Text and paragraphs
lines renders that many line boxes. From two lines up, the last one is shortened to lastLineWidth (60% by default) so a paragraph does not read as a solid rectangle. A single line keeps its full width, because one short line reads as a truncated value rather than as flowing text.
<template>
<NbSkeleton :lines="4" last-line-width="35%" />
</template>Match the line count to what usually arrives. Three lines standing in for one line of text is its own layout shift, just in the other direction.
Blocks and circles
width and height accept a number (pixels) or any CSS length, including a token reference. Pass the measurement the real element already has rather than a round number that looks about right.
<template>
<!-- The chart canvas is 240px tall, so the placeholder is too -->
<NbSkeleton variant="block" :height="240" radius="md" />
<!-- The avatar is one field height, so the placeholder tracks the scale -->
<NbSkeleton variant="circle" height="var(--nb-field-height-md)" />
</template>A circle takes one measurement and applies it to both axes, so an avatar placeholder cannot come out oval. radius is ignored for circles and otherwise takes a step from the radius scale: none, xs, sm (default), md, lg, pill.
Composing a loading region
Skeletons are laid out by you, in the same container as the real content, using the same gaps. Reuse the real component's wrapper so the two states cannot disagree.
<template>
<NbGrid gap="md" align="center">
<NbSkeleton variant="circle" :width="40" label="Loading profile" />
<NbGrid dir="col" gap="xs" grow>
<NbSkeleton variant="heading" type="heading-01" width="45%" />
<NbSkeleton :lines="2" />
</NbGrid>
</NbGrid>
</template>Only one skeleton in the region carries a label. The rest are silent, so a table of forty placeholders announces itself once rather than forty times.
When not to use one
- The shape is unknown. A skeleton is a promise about the layout. If the response might be empty, or a table might have any number of columns, use
NbSpinnerand do not promise. - A control that could simply be disabled. A form waiting for its values is a form with disabled fields, not a stack of grey slabs.
NbTextInput,NbSelectandNbButtonall carrydisabled, and a disabled control keeps its label, its help text and its place in the tab order, all of which a placeholder throws away. See Disabled and read-only. - A load with a measurable total. That is
NbProgressBar, which can say how far along it is. A skeleton says nothing about time. - A refresh of content already on screen. Replacing rendered rows with grey bars destroys what the user was reading. Leave the rows and put a
NbSpinnerwithoverlay="container"over them. - Loads that finish instantly. A skeleton that flashes is a flicker. Gate it behind the same delay you would give a spinner.
When the fetch fails
A skeleton is not an empty state and not an error state. Swap it for NbEmptyState when the response is legitimately empty, and for a message or banner when the request failed. One product in the audit renders a single "no contacts yet" element for loading, empty and failed alike, so a broken API reads as a user with no data.
If you set completeLabel, the failure path has one more line to write. The skeleton announces that message when it is unmounted, and a failed fetch unmounts it just as surely as a successful one does, so without this a screen reader user is told "Description loaded" at the exact moment the screen filled with an error they cannot see. Say so where the failure is known:
import { suppressLoadingAnnouncement } from '@nubisco/ui'
try {
description.value = await api.load()
} catch (reason) {
// The skeleton is about to be replaced by a banner. Nothing completed.
suppressLoadingAnnouncement()
error.value = reason
} finally {
loading.value = false
}The call covers anything queued for the rest of the current task, so it holds across the render that removes the skeleton, and then lifts by itself: the next load is a new load and announces its own ending normally.
It has to be a call rather than a :failed prop, and the reason is a Vue detail worth knowing. Props are not updated on the render that unmounts a component, so a flag flipped in the same tick as v-if="loading" is never seen by the teardown handler that does the announcing. The half of the code that knows the load failed is the catch, so that is the half that speaks.
If the request is driven through useInlineLoading, this is already done for you: its run() suppresses the announcement when the action rejects.