Spinner
Indeterminate loading, for a short wait with no known duration.
<script setup lang="ts">
import SaSpinner from '@shared/components/common/SaSpinner.vue'
</script>
<template>
<SaSpinner label="Loading orders" />
</template>Usage
Use it for a wait you cannot put a number on. Where you can, Progress is the determinate case, and the skeletons are the content-shaped one: they keep the page from jumping when the data arrives.
The ring is a full border in the current color at low alpha with one side opaque. Rotating that reads as a traveling arc. It inherits currentColor, so it takes the color of whatever it sits in.
Examples
Sizes
size is xs, sm, md, lg or xl.
<script setup lang="ts">
import SaSpinner from '@shared/components/common/SaSpinner.vue'
</script>
<template>
<div class="flex items-center gap-6">
<SaSpinner size="xs" label="Extra small" />
<SaSpinner size="sm" label="Small" />
<SaSpinner size="md" label="Medium" />
<SaSpinner size="lg" label="Large" />
<SaSpinner size="xl" label="Extra large" />
</div>
</template>A visible label
showLabel prints the label beside the ring instead of only announcing it.
<script setup lang="ts">
import SaSpinner from '@shared/components/common/SaSpinner.vue'
</script>
<template>
<SaSpinner size="sm" label="Checking your card…" show-label />
</template>Inside a button
<script setup lang="ts">
import SaSpinner from '@shared/components/common/SaSpinner.vue'
import { Button } from '@shared/components/ui/button'
import { ref } from 'vue'
const saving = ref(false)
function save(): void {
saving.value = true
setTimeout(() => (saving.value = false), 1600)
}
</script>
<template>
<Button :disabled="saving" @click="save">
<SaSpinner v-if="saving" size="xs" label="Saving" />
{{ saving ? 'Saving…' : 'Save changes' }}
</Button>
</template>Skeletons
Where you know the shape of what is loading, a skeleton beats a spinner: it holds the space, so the page does not jump when the data lands. Five ship, under src/shared/components/feedback/skeletons/. They follow the active typography scale and density, and features.skeletonLoaders turns them off inside DataTable.
| Component | Props | Default | What it draws |
|---|---|---|---|
SkeletonText | lines | 3 | That many text lines, the last one shorter. |
SkeletonCard | — | — | A card: title, avatar and three lines. |
SkeletonStats | — | — | A stat card: title, value and a sparkline strip. |
SkeletonForm | fields | 4 | A label bar and an input bar per field. |
SkeletonTable | rows, columns | 5, 5 | A grid of plain elements, not table rows, so it can stand anywhere a table would. |
<script setup lang="ts">
import SkeletonCard from '@shared/components/feedback/skeletons/SkeletonCard.vue'
import SkeletonForm from '@shared/components/feedback/skeletons/SkeletonForm.vue'
import SkeletonStats from '@shared/components/feedback/skeletons/SkeletonStats.vue'
import SkeletonTable from '@shared/components/feedback/skeletons/SkeletonTable.vue'
import SkeletonText from '@shared/components/feedback/skeletons/SkeletonText.vue'
</script>
<template>
<div class="grid gap-6 sm:grid-cols-2">
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">
SkeletonText
</p>
<SkeletonText :lines="3" />
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">
SkeletonCard
</p>
<SkeletonCard />
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">
SkeletonStats
</p>
<SkeletonStats />
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">
SkeletonForm
</p>
<SkeletonForm :fields="2" />
</div>
<div class="space-y-2 sm:col-span-2">
<p class="text-xs font-medium text-muted-foreground">
SkeletonTable
</p>
<SkeletonTable :rows="3" :columns="4" />
</div>
</div>
</template>API
Source: src/shared/components/common/SaSpinner.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 12, 16, 24, 32 or 48 pixels, with the border weight to match. |
label | string | — | What is loading. Defaults to "Loading". |
showLabel | boolean | — | Prints the label beside the ring. |
Emits
None.
Slots
None.
Accessibility
- The root is
role="status", so the label is announced politely when the spinner appears. - The ring is
aria-hidden; the label carries the meaning. - The label is visually hidden unless
showLabel, so it is always there for a screen reader even when the design has no room for it. - Pass
label=""where something else already announces the wait, so the two do not speak over each other. - The rotation stops under
prefers-reduced-motion.