The silhouette of content that has not arrived yet. It is pure CSS, and it is decorative by default: what announces the wait is the container around it, not a dozen competing silhouettes.
Usage
vue
<script setup lang="ts">
import { VSkeletonLoader } from 'vectis-ui'
</script>
<template>
<!-- The component has no width of its own: it fills what it is given, so a
container is what decides how wide the silhouette is. -->
<div class="demo">
<VSkeletonLoader :lines="3" />
</div>
</template>
<style scoped>
.demo {
inline-size: 22rem;
}
</style>
Examples
Shapes
shape sets a corner radius and a way of being sized at once: text follows the typography around it, control takes the height of a control, pill that height with fully rounded ends, circle that height in both dimensions, surface a card with a height of its own. width and height name their own, a number being read as pixels and anything else as a CSS length.
vue
text
control
pill
circle
surface
width
height
<script setup lang="ts">
import { VSkeletonLoader, type SkeletonLoaderShape } from 'vectis-ui'
const shapes: SkeletonLoaderShape[] = ['text', 'control', 'pill', 'circle', 'surface']
</script>
<template>
<div class="demo">
<!-- Each shape sets a corner radius and a way of being sized: text follows the
typography around it, control, pill and circle read the control scale, and a
surface has a height of its own. -->
<div v-for="shape in shapes" :key="shape" class="row">
<p class="caption">{{ shape }}</p>
<VSkeletonLoader :shape="shape" />
</div>
<!-- A number is read as pixels, anything else as a CSS length of your own. Left
out, the silhouette takes all the width available, which is why a container
has to give it one. -->
<div class="row">
<p class="caption">width</p>
<VSkeletonLoader shape="control" :width="180" />
<VSkeletonLoader shape="control" width="60%" />
</div>
<!-- `height` wins over the shape and the size, for a silhouette no shape
describes. -->
<div class="row">
<p class="caption">height</p>
<VSkeletonLoader shape="surface" :height="48" />
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
gap: var(--vectis-space-5);
inline-size: 22rem;
}
.row {
display: grid;
gap: var(--vectis-space-2);
}
.caption {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
Sizes
size takes the scale every control shares, 24 to 56 pixels, and compact takes 4px off it. It means nothing for text or for surface, which are sized otherwise.
vue
circle
control
md, then md compact
<script setup lang="ts">
import { VSkeletonLoader, type SkeletonLoaderSize } from 'vectis-ui'
const sizes: SkeletonLoaderSize[] = ['xs', 'sm', 'md', 'lg', 'xl']
</script>
<template>
<div class="demo">
<!-- The control scale, 24 to 56 pixels: an md skeleton is exactly the height of
an md button, which is what lets a silhouette hold the place of the control
it stands in for. -->
<div class="row">
<p class="caption">circle</p>
<div class="inline">
<VSkeletonLoader v-for="size in sizes" :key="size" shape="circle" :size="size" />
</div>
</div>
<div class="row">
<p class="caption">control</p>
<VSkeletonLoader v-for="size in sizes" :key="size" shape="control" :size="size" />
</div>
<!-- compact takes 4px off the height, as everywhere else in the design system. -->
<div class="row">
<p class="caption">md, then md compact</p>
<VSkeletonLoader shape="control" size="md" />
<VSkeletonLoader shape="control" size="md" compact />
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
gap: var(--vectis-space-5);
inline-size: 18rem;
}
.row {
display: grid;
gap: var(--vectis-space-2);
}
.inline {
display: flex;
align-items: start;
gap: var(--vectis-space-3);
}
.caption {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
Paragraphs of text
lines draws that many silhouettes, each one em tall with the leading as the gutter, so they occupy exactly that many lines of the typography around them. The last one is drawn shorter than the others.
vue
body-sm, 5 lines
heading-2, 2 lines
<script setup lang="ts">
import { VSkeletonLoader } from 'vectis-ui'
</script>
<template>
<div class="demo">
<!-- In the text shape the height is one em and the gutter is the leading, so N
silhouettes occupy exactly N lines of the typography around them: swapping
them for the real text shifts nothing. The last line is drawn shorter, and
that single detail is what reads as a paragraph rather than as a table. -->
<div class="body">
<p class="caption">body-sm, 5 lines</p>
<VSkeletonLoader :lines="5" />
</div>
<div class="heading">
<p class="caption">heading-2, 2 lines</p>
<VSkeletonLoader :lines="2" />
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
gap: var(--vectis-space-6);
inline-size: 24rem;
}
.body {
font-size: var(--vectis-text-body-sm-size);
line-height: var(--vectis-text-body-sm-leading);
}
.heading {
font-size: var(--vectis-text-heading-2-size);
line-height: var(--vectis-text-heading-2-leading);
}
.caption {
margin: 0 0 var(--vectis-space-2);
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
line-height: var(--vectis-text-caption-leading);
}
</style>
The silhouette of a real component
Nothing here is measured: a silhouette that matches the component it stands in for is one you declared, shape by shape.
vue
Component
Skeleton
ALActive filter
Sales analysis
The quarter closes on a 12% rise in revenue.
<script setup lang="ts">
import { VAvatar, VButton, VChip, VInput, VSkeletonLoader, VTypography } from 'vectis-ui'
</script>
<template>
<div class="grid">
<VTypography variant="overline" tone="muted" as="p">Component</VTypography>
<VTypography variant="overline" tone="muted" as="p">Skeleton</VTypography>
<!-- A control silhouette at the same size as the button, given the width the
label would have taken. -->
<VButton size="md">Save</VButton>
<VSkeletonLoader shape="control" size="md" :width="128" />
<VAvatar size="lg" name="Ada Lovelace" />
<VSkeletonLoader shape="circle" size="lg" />
<VChip size="xs">Active filter</VChip>
<VSkeletonLoader shape="pill" size="xs" :width="88" />
<!-- A field is two silhouettes: the label is a line of text, the box a control. -->
<VInput size="md" label="Name" model-value="Ada Lovelace" />
<div class="field">
<VSkeletonLoader width="40%" />
<VSkeletonLoader shape="control" size="md" />
</div>
<div class="card">
<VTypography variant="heading-4">Sales analysis</VTypography>
<VTypography variant="body-sm" tone="muted">
The quarter closes on a 12% rise in revenue.
</VTypography>
</div>
<div class="card">
<VSkeletonLoader shape="control" size="sm" width="60%" />
<VSkeletonLoader :lines="3" />
</div>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--vectis-space-6) var(--vectis-space-8);
align-items: start;
inline-size: 100%;
}
.field,
.card {
display: grid;
gap: var(--vectis-space-3);
}
.card {
padding: var(--vectis-space-4);
border: 1px solid var(--vectis-color-border);
border-radius: var(--vectis-radius-surface);
}
</style>
Animations
animation sends the highlight across the silhouette as a wave, or raises and lowers it in place as a pulse, and none freezes it. Under prefers-reduced-motion the wave falls back to a slowed pulse.
vue
wave
pulse
none
<script setup lang="ts">
import { VSkeletonLoader, type SkeletonLoaderAnimation } from 'vectis-ui'
const animations: SkeletonLoaderAnimation[] = ['wave', 'pulse', 'none']
</script>
<template>
<div class="demo">
<!-- Both animations lighten the silhouette with the same highlight, derived from
its own background: the wave sends it across, the pulse raises and lowers it
in place. Neither fades the silhouette towards the page, which would lighten
it in one theme and darken it in the other. `none` freezes it, for printing,
a screenshot, or a parent that is already animating. -->
<div v-for="animation in animations" :key="animation" class="row">
<p class="caption">{{ animation }}</p>
<VSkeletonLoader shape="surface" :animation="animation" />
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
gap: var(--vectis-space-5);
inline-size: 22rem;
}
.row {
display: grid;
gap: var(--vectis-space-2);
}
.caption {
margin: 0;
color: var(--vectis-color-text-muted);
font-size: var(--vectis-text-caption-size);
}
</style>
A colour of your own
color replaces the shipped grey, the highlight both animations use being derived from it by a lightness delta rather than mixed towards a target.
vue
<script setup lang="ts">
import { VSkeletonLoader } from 'vectis-ui'
</script>
<template>
<div class="demo">
<!-- `color` replaces the background token. The highlight both animations use is
derived from it by a lightness delta, so it stays right on an unusual surface
and in either theme, with nothing else to set. -->
<VSkeletonLoader shape="surface" color="oklch(55% 0.14 265)" />
<!-- The case it is for: a card that paints its own ground, where the shipped grey
would read as a hole. -->
<div class="inverse">
<VSkeletonLoader shape="control" size="sm" width="60%" color="oklch(38% 0.01 260)" />
<VSkeletonLoader :lines="3" color="oklch(38% 0.01 260)" />
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
gap: var(--vectis-space-5);
inline-size: 22rem;
}
.inverse {
display: grid;
gap: var(--vectis-space-3);
padding: var(--vectis-space-4);
border-radius: var(--vectis-radius-surface);
background: var(--vectis-color-surface-inverse);
}
</style>
Replacing the skeleton
There is no wrapper mode: the idiom is a plain v-if, with aria-busy on the container, which is what announces the wait for the whole zone.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VSkeletonLoader, VTypography } from 'vectis-ui'
const pending = ref(true)
</script>
<template>
<div class="demo">
<VButton size="sm" variant="outline" tone="neutral" @click="pending = !pending">
{{ pending ? 'Load' : 'Reload' }}
</VButton>
<!-- There is no wrapper mode: the component never measures what it replaces, so
the shape is declared rather than guessed. The idiom is a plain v-if, with
`aria-busy` on the container, which is what announces the wait for the whole
zone instead of each silhouette announcing its own. -->
<div class="zone" :aria-busy="pending || undefined">
<template v-if="pending">
<VSkeletonLoader shape="control" size="sm" width="55%" />
<VSkeletonLoader :lines="3" />
</template>
<template v-else>
<VTypography variant="heading-4">Sales analysis</VTypography>
<VTypography variant="body-sm" tone="muted">
The quarter closes on a 12% rise in revenue, driven by annual subscriptions.
</VTypography>
</template>
</div>
</div>
</template>
<style scoped>
.demo {
display: grid;
justify-items: start;
gap: var(--vectis-space-4);
inline-size: 24rem;
}
.zone {
display: grid;
gap: var(--vectis-space-3);
inline-size: 100%;
}
</style>
Announcing the wait
A skeleton is decorative and hidden from the accessibility tree by default. announce makes one instance speak, and label says what is announced and turns the announcement on by being given.
vue
Loading the results
<script setup lang="ts">
import { VSkeletonLoader } from 'vectis-ui'
</script>
<template>
<div class="demo">
<!-- A skeleton is silent by default: a page holds a dozen of them and a dozen
competing announcements are unreadable. One instance per zone speaks, and
`label` is what it says, which is also what turns the announcement on. Prefer
something situated: a generic word is the reason the default is silence. -->
<VSkeletonLoader shape="control" size="sm" label="Loading the results" />
<!-- The rest stay decorative, hidden from the accessibility tree. -->
<VSkeletonLoader v-for="n in 7" :key="n" shape="control" size="sm" />
</div>
</template>
<style scoped>
.demo {
display: grid;
gap: var(--vectis-space-2);
inline-size: 20rem;
}
</style>
What the silhouette stands for. Each value sets both a corner radius and a way of being sized: text follows the typography around it, control takes the height of a control of the given size, pill is that height with fully rounded ends, circle is that height in both dimensions, and surface is a card or an image with a height of its own.
The size on the scale shared by every control. It only means something for the shapes sized like a control: text follows the typography around it, and a surface has its own height.
compact
boolean
false
Takes 4px off the height, as everywhere else in the design system.
width
number | string
none
The width: a number is read as pixels, and anything else as a CSS length of your own, '100%' or '12ch'. Left out, the silhouette takes all the width available.
height
number | string
none
The height, read the same way. It wins over the shape and the size.
lines
number
1
How many silhouettes to stack. In the text shape the last one is drawn shorter than the others, and that single detail is what reads as a paragraph rather than as a table.
animation
SkeletonLoaderAnimation'wave' | 'pulse' | 'none'
'wave'
How the silhouette shows that something is happening. Turning it off freezes it, which suits printing, a screenshot, or a parent that is already animating.
color
string
none
A background colour of your own, replacing the token. The wave's highlight is derived from it, so it stays correct with nothing else to set.
announce
boolean
false
Announces the loading to screen readers. It is off by default, because a skeleton is decorative: a page holds a dozen of them, and a dozen competing announcements are unreadable. What should announce the wait is the container around them, marked as busy.
label
string
none
What is announced, which also implies announcing. Prefer something situated, "Loading the results", since a generic word is the reason the default is silence. It falls back to the design system dictionary.