La silhouette d'un contenu qui n'est pas encore arrivé. Elle est en CSS pur, et décorative par défaut : ce qui annonce l'attente est le conteneur autour d'elle, pas une douzaine de silhouettes concurrentes.
Utilisation
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>
Exemples
Formes
shape pose à la fois un rayon d'angle et une façon d'être dimensionné : text suit la typographie environnante, control prend la hauteur d'un contrôle, pill cette hauteur avec des extrémités complètement arrondies, circle cette hauteur dans les deux dimensions, surface une carte ayant sa propre hauteur. width et height en nomment une à eux, un nombre étant lu en pixels et tout le reste en longueur CSS.
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>
Tailles
size reprend l'échelle partagée par tous les contrôles, de 24 à 56 pixels, et compact lui retire 4px. Elle ne veut rien dire pour text ni pour surface, dimensionnés autrement.
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>
Paragraphes de texte
lines dessine autant de silhouettes, hautes d'un em avec l'interligne pour gouttière, si bien qu'elles occupent exactement ce nombre de lignes de la typographie environnante. La dernière est dessinée plus courte que les autres.
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>
La silhouette d'un vrai composant
Rien n'est mesuré ici : une silhouette qui correspond au composant qu'elle remplace est une silhouette que vous avez déclarée, forme par forme.
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 envoie la lumière en vague à travers la silhouette, ou la lève et la baisse sur place en pulsation, et none la fige. Sous prefers-reduced-motion, la vague se rabat sur une pulsation ralentie.
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>
Une couleur à vous
color remplace le gris livré, la lumière qu'emploient les deux animations en étant dérivée par un écart de clarté plutôt que mélangée vers une cible.
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>
Remplacer le squelette
Il n'y a pas de mode enveloppe : l'idiome est un simple v-if, avec aria-busy sur le conteneur, qui est ce qui annonce l'attente pour toute la 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>
Annoncer l'attente
Une silhouette est décorative et masquée de l'arbre d'accessibilité par défaut. announce fait parler une instance, et label dit ce qui est annoncé et active l'annonce du seul fait d'être donné.
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>
Ce que la silhouette représente. Chaque valeur fixe à la fois un rayon de coin et une façon d'être dimensionnée : text suit la typographie alentour, control prend la hauteur d'un contrôle de la taille donnée, pill est cette hauteur aux extrémités entièrement arrondies, circle est cette hauteur dans les deux dimensions, et surface est une carte ou une image avec sa propre hauteur.
La taille sur l'échelle partagée par tous les contrôles. Elle ne signifie quelque chose que pour les formes dimensionnées comme un contrôle : le texte suit la typographie alentour, et une surface a sa hauteur propre.
compact
boolean
false
Retire 4px à la hauteur, comme partout ailleurs dans le design system.
width
number | string
aucune
La largeur : un nombre est lu en pixels, et tout le reste comme une longueur CSS à vous, '100%' ou '12ch'. Sans elle, la silhouette prend toute la largeur disponible.
height
number | string
aucune
La hauteur, lue de la même façon. Elle l'emporte sur la forme et sur la taille.
lines
number
1
Combien de silhouettes empiler. Dans la forme texte, la dernière est dessinée plus courte que les autres, et ce seul détail est ce qui se lit comme un paragraphe plutôt que comme un tableau.
animation
SkeletonLoaderAnimation'wave' | 'pulse' | 'none'
'wave'
Comment la silhouette montre qu'il se passe quelque chose. La couper la fige, ce qui convient à une impression, une capture d'écran, ou un parent qui anime déjà.
color
string
aucune
Une couleur de fond à vous, qui remplace le token. Le reflet de la vague en est dérivé, donc il reste juste sans rien d'autre à régler.
announce
boolean
false
Annonce le chargement aux lecteurs d'écran. Désactivé par défaut, parce qu'un squelette est décoratif : une page en contient une douzaine, et une douzaine d'annonces concurrentes est illisible. Ce qui doit annoncer l'attente est le conteneur autour d'eux, marqué occupé.
label
string
aucune
Ce qui est annoncé, ce qui implique aussi de l'annoncer. Préférez quelque chose de situé, « Chargement des résultats », puisqu'un mot générique est la raison pour laquelle le silence est la valeur par défaut. Il retombe sur le dictionnaire du design system.