Skeleton
Animated placeholders shown while content loads. Skeletons reduce perceived loading time by giving users a preview of the layout they'll see once data arrives.
Basic Usage
Text Skeleton
Render N lines of varying width:
Avatar Skeleton
A circular avatar placeholder with two short text lines next to it — matches the common "avatar + name + role" row layout:
Card Skeleton
Image area + title + body text — matches a typical content card:
Custom Skeleton
Build your own shape from scratch using the shimmer class wk-skeleton:
Animation Modes
Choose how the placeholder animates with the animation prop: shimmer (the default gradient sweep), pulse (a lighter opacity fade — half the GPU layers), or none (a static block, no motion). All modes respect prefers-reduced-motion: reduce.
Conditional Rendering
Use skeletons as a placeholder until data is loaded:
@if($loading)
<x-wirekit::skeleton type="card" />
@else
<x-wirekit::card>
{{-- real content --}}
</x-wirekit::card>
@endif
With Livewire, pair with wire:loading:
<div wire:loading wire:target="loadUsers">
<x-wirekit::skeleton type="avatar" />
<x-wirekit::skeleton type="avatar" />
<x-wirekit::skeleton type="avatar" />
</div>
<div wire:loading.remove wire:target="loadUsers">
@foreach($users as $user)
{{-- real user card --}}
@endforeach
</div>
Width & Layout
Skeleton dimensions are controlled via width and height props or Tailwind classes:
{{-- Fixed dimensions via props --}}
<x-wirekit::skeleton width="200px" height="1rem" />
{{-- Tailwind classes --}}
<x-wirekit::skeleton class="w-48 h-4" />
{{-- Full-width bar --}}
<x-wirekit::skeleton class="w-full h-3" />
The variant="circular" variant uses equal width and height for a perfect circle.
How the Shimmer Works
WireKit ships a .wk-skeleton utility in dist/wirekit.css that applies a horizontal gradient via ::after and animates it with @keyframes wk-shimmer. The shimmer is themed via the --color-wk-bg-elevated token, so it automatically blends with both light and dark modes.
You can apply this class to any element to turn it into a skeleton placeholder:
<div class="wk-skeleton bg-[var(--color-wk-bg-muted)] rounded h-4 w-40"></div>
Performance characteristics
Each animation mode has a different cost profile — pick by skeleton density:
shimmer(default) animatestransform: translateXon a dedicated::afterpseudo-element — a compositor-only property, GPU-accelerated, no main-thread paint or layout cost. It is the richest effect and carries one extra compositor layer per skeleton (the::after), so it is the most expensive of the three at very high density.pulseremoves the::afterlayer entirely and runs a single opacity keyframe on the base element instead. Opacity is equally compositor-friendly, but with half the layers per skeleton — the better default for density-bound layouts like data tables of skeleton rows.noneremoves the pseudo-element and the animation: a static placeholder with zero per-frame work. Only the initial paint costs anything — use it when hundreds of placeholders are visible at once, or when the loading window is too short for motion to register.
Three optimizations apply across the modes:
content-visibility: autoon every skeleton wrapper lets the browser skip rendering work for off-screen instances entirely — in all three modes. Long lists of skeletons cost effectively zero per frame for every instance below the fold.will-change: transformon the shimmer's::afterhints the compositor to promote the layer eagerly with the most optimal paint path (shimmer only — pulse and none have no::after).animation-play-state: pausedkicks in automatically when the wrapper'saria-busyflips tofalse(or a parent's does) — developers who toggle aria-busy when loading completes get a free zero-cost stop signal for shimmer and pulse alike.
Under prefers-reduced-motion: reduce, shimmer and pulse both collapse to a static placeholder — every mode behaves like none for users with motion sensitivity, at the matching cost.
Measured impact of the optimizations on the shimmer mode (Playwright Chromium headless, 4 s sample window):
| Density × Variant | Before optimizations | After optimizations | Speedup |
|---|---|---|---|
| 100 × card | 59 fps | 60 fps | ≈1.0× |
| 300 × card | 16 fps | 60 fps | 3.75× |
| 100 × table | 25 fps | 27 fps | 1.1× |
| 100 × mixed | 53 fps | 56 fps | 1.1× |
| 1000 × mixed | 5 fps | 45 fps | 9.0× |
Rule of thumb: shimmer for hero surfaces and ordinary pages, animation="pulse" once skeleton counts reach the hundreds (half the layers), animation="none" when even a pulse would be visual noise — the optimizations above keep all three cheap, but only none is free.
Best Practices
- Match layout — the skeleton should mirror the eventual content's shape and proportions, so the transition feels seamless
- Avoid layout shift — ensure the skeleton's total height matches the real content's height to prevent content jumping on load
- Don't over-use — skeletons are for initial loads, not for every interaction. For quick state updates, use a spinner or nothing at all
Props
| Prop | Type | Default | Description |
|---|---|---|---|
type |
string | 'text' |
'text', 'avatar', 'card', 'custom' |
lines |
int | 3 |
Number of text lines (only for type="text") |
animation |
string | 'shimmer' |
'shimmer' (gradient sweep), 'pulse' (opacity fade, lighter on the GPU), or 'none' (static) |
shimmer |
bool | true |
Legacy flag — false is equivalent to animation="pulse" (kept for back-compat; prefer animation) |
scope |
string|null | null |
Scoped personalization name |
Pulse mode (animation="pulse")
For dense lists of skeletons (50+ on screen at once), the default
sweeping gradient costs one GPU layer per .wk-skeleton element.
Opt in to pulse mode for a lighter alternative — a single opacity
animation on the base element with no ::after pseudo-layer:
<x-wirekit::skeleton type="card" animation="pulse" />
(The legacy :shimmer="false" flag is equivalent and keeps working;
prefer the animation prop, which also offers none for a fully
static placeholder.)
Visually, pulse mode fades each skeleton between full opacity and
--reading-skeleton-pulse-opacity (default 0.5) on a 1.6 s cycle.
All modes honor prefers-reduced-motion: reduce, and the animated
modes pause when the wrapper flips to aria-busy="false".
Sub-Components
Dedicated sub-components are available for common patterns. They work identically to the type prop variants but allow for clearer, more discoverable markup:
skeleton.text
skeleton.avatar
skeleton.card
skeleton.table
Render a table placeholder with configurable rows and columns:
Sub-Component Props
| Sub-component | Prop | Type | Default | Description |
|---|---|---|---|---|
skeleton.text |
lines |
int | 3 |
Number of text lines |
skeleton.table |
rows |
int | 5 |
Number of body rows |
skeleton.table |
cols |
int | 4 |
Number of columns |
All sub-components accept the scope prop for personalization.
Accessibility
- The skeleton container carries
role="status"+aria-live="polite"— screen readers announce the loading state without interrupting - An
aria-label="Loading"+sr-only"Loading content" text provide redundant announcements - The shimmer animation is disabled when the user's OS has
prefers-reduced-motion: reduceset (seedist/wirekit.css). The bars remain visible as solid color placeholders - Avoid skeletons for operations that complete in under ~200ms — they can feel like flickering to the user. Use skeletons only for loads expected to take 300ms+
Keyboard Interaction
This component is purely presentational and does not respond to keyboard input.
Pitfalls
- Don't leave a skeleton on the page after content arrives. WCAG 2.2.2 (Pause, Stop, Hide) — perpetual animation distracts. The component's
:loaded="$loaded"prop swaps in real content automatically; bind it.
Design Tokens
| Element | Token |
|---|---|
| Shimmer base color | --color-wk-bg-muted |
| Shimmer highlight | --color-wk-bg-elevated (60% mix) |
| Bar radius | --radius-wk-md |
| Avatar radius | --radius-wk-full |
| Animation duration | 1.6s (fixed) |
Customization
Override defaults without publishing views via config/wirekit.php:
'components' => [
'skeleton' => [],
],