Keyboard shortcut: Ctrl + K
Get started

Skeleton loader

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

Active 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>

API

Props

PropTypeDefault
shapeSkeletonLoaderShape'text' | 'control' | 'pill' | 'circle' | 'surface''text'
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.
sizeSkeletonLoaderSize'xs' | 'sm' | 'md' | 'lg' | 'xl''md'
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.
compactbooleanfalse
Takes 4px off the height, as everywhere else in the design system.
widthnumber | stringnone
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.
heightnumber | stringnone
The height, read the same way. It wins over the shape and the size.
linesnumber1
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.
animationSkeletonLoaderAnimation'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.
colorstringnone
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.
announcebooleanfalse
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.
labelstringnone
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.

CSS variables

TokenValue
--vectis-control-size-skeleton-surface6rem