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
The skeleton root carries an inline style of its own — width: 100%; min-width: 12rem — so by
default it fills whatever you place it in, and the bars inside it are sized relative to that. The
two axes are therefore changed differently:
{{-- Width: the style attribute. Your declaration is merged AFTER the
component's own, so it wins. --}}
<x-wirekit::skeleton style="width: 12rem;" />
{{-- Or size the element around it and let the skeleton fill it. --}}
<div class="w-48">
<x-wirekit::skeleton />
</div>
{{-- Height and everything else: Tailwind classes on the root. --}}
<x-wirekit::skeleton class="h-4" />
A width utility such as w-48 on the skeleton itself does not take effect: an inline
declaration beats a class whatever its specificity, and the component sets width inline. Use
the style attribute or a wrapper. type="text" skeletons keep their own bar heights, so a
height class on the root changes the box rather than the bars — vary those with :lines or
build the shape yourself with Custom Skeleton, which is also the route to a
bare circle.
How the Shimmer Works
WireKit ships a .wk-skeleton utility in dist/ 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, so long lists cost effectively zero per frame below the fold. This is a progressive enhancement, behind@supports: it first shipped in Safari 18.0, above WireKit's Safari 16.4 floor. Nothing depends on it — a browser without it renders every skeleton the ordinary way.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 as soon asaria-busy="false"sits on the skeleton or on any element above it — set it when loading completes and you get a zero-cost stop signal for shimmer and pulse alike. The skeleton emits noaria-busyof its own, so the attribute is yours to place: it is the container whose content is still missing that is busy, not the placeholder standing in for it.
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.
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 once aria-busy="false" is set on the skeleton or an
ancestor.
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-labelplus ansr-onlytext node provide redundant announcements. Both run through the translation catalog (wirekit::andLoading wirekit::), so they follow the application locale rather than announcing EnglishLoading content - The container deliberately carries no
aria-busy. On a live region that attribute means "withhold this until I say otherwise", and a skeleton never says otherwise — it is replaced by the real content rather than marked finished, so the flag would suppress the announcement permanently. Putaria-busyon the region the skeleton stands in for instead; the pause rule above reads it from there just as well. - The shimmer animation is disabled when the user's OS has
prefers-reduced-motion: reduceset (seedist/). The bars remain visible as solid color placeholderswirekit. css - 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 swap is yours to make and the component never removes itself: put the skeleton behind
@if($loading)/wire:loadingand the real content behind the@else/wire:loading.branch, as Conditional Rendering shows. Mark the region the skeleton stands in for withremove aria-busywhile it waits, per the Accessibility notes above.
Design Tokens
| Element | Token |
|---|---|
| Shimmer base color | --color-wk-bg-skeleton |
| 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/:
'components' => [
'skeleton' => [
// Applies to every member of the family — the bare skeleton and the
// text / avatar / card / table variants all read this one key.
'animation' => 'shimmer', // shimmer | pulse | none
],
],