---
title: Skeleton
description: Loading placeholder skeleton
visibility: guest
draft: false
---

# 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:

:::preview{title="Text skeleton — 3 lines"}
<x-wirekit::skeleton type="text" :lines="3" />
:::

### Avatar Skeleton

A circular avatar placeholder with two short text lines next to it — matches the common "avatar + name + role" row layout:

:::preview{title="Avatar skeleton"}
<x-wirekit::skeleton type="avatar" />
:::

### Card Skeleton

Image area + title + body text — matches a typical content card:

:::preview{title="Card skeleton"}
<x-wirekit::skeleton type="card" />
:::

### Custom Skeleton

Build your own shape from scratch using the shimmer class `wk-skeleton`:

:::preview{title="Custom shape"}
<x-wirekit::skeleton type="custom">
    <div class="wk-skeleton" style="background: var(--color-wk-bg-muted); border-radius: 0.5rem; height: 5rem; width: 100%;"></div>
</x-wirekit::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`.

:::preview{title="Shimmer, pulse, and static"}
<x-wirekit::stack gap="md">
    <x-wirekit::skeleton type="text" :lines="2" animation="shimmer" />
    <x-wirekit::skeleton type="text" :lines="2" animation="pulse" />
    <x-wirekit::skeleton type="text" :lines="2" animation="none" />
</x-wirekit::stack>
:::

## Conditional Rendering

Use skeletons as a placeholder until data is loaded:

```blade
@if($loading)
    <x-wirekit::skeleton type="card" />
@else
    <x-wirekit::card>
        {{-- real content --}}
    </x-wirekit::card>
@endif
```

With Livewire, pair with `wire:loading`:

```blade
<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:

```blade
{{-- 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:

```blade
<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) animates `transform: translateX` on a dedicated `::after` pseudo-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.
- **`pulse`** removes the `::after` layer 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.
- **`none`** removes 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: auto`** on 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: transform`** on the shimmer's `::after` hints the compositor to promote the layer eagerly with the most optimal paint path (shimmer only — pulse and none have no `::after`).
- **`animation-play-state: paused`** kicks in automatically when the wrapper's `aria-busy` flips to `false` (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):

<table>
<thead>
<tr>
<th style="text-align: left;">Density × Variant</th>
<th style="text-align: right;">Before optimizations</th>
<th style="text-align: right;">After optimizations</th>
<th style="text-align: right;">Speedup</th>
</tr>
</thead>
<tbody>
<tr><td style="text-align: left;">100 × card</td><td style="text-align: right;">59 fps</td><td style="text-align: right;">60 fps</td><td style="text-align: right;">≈1.0×</td></tr>
<tr><td style="text-align: left;">300 × card</td><td style="text-align: right;">16 fps</td><td style="text-align: right;">60 fps</td><td style="text-align: right;"><strong>3.75×</strong></td></tr>
<tr><td style="text-align: left;">100 × table</td><td style="text-align: right;">25 fps</td><td style="text-align: right;">27 fps</td><td style="text-align: right;">1.1×</td></tr>
<tr><td style="text-align: left;">100 × mixed</td><td style="text-align: right;">53 fps</td><td style="text-align: right;">56 fps</td><td style="text-align: right;">1.1×</td></tr>
<tr><td style="text-align: left;">1000 × mixed</td><td style="text-align: right;">5 fps</td><td style="text-align: right;">45 fps</td><td style="text-align: right;"><strong>9.0×</strong></td></tr>
</tbody>
</table>

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:

```blade
<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

:::preview{title="Skeleton text sub-component"}
<x-wirekit::skeleton.text :lines="4" />
:::

### skeleton.avatar

:::preview{title="Skeleton avatar sub-component"}
<x-wirekit::skeleton.avatar />
:::

### skeleton.card

:::preview{title="Skeleton card sub-component"}
<x-wirekit::skeleton.card />
:::

### skeleton.table

Render a table placeholder with configurable rows and columns:

:::preview{title="Skeleton table — 5 rows, 4 columns" wide}
<x-wirekit::skeleton.table :rows="5" :cols="4" />
:::

:::preview{title="Skeleton table — compact 3×2"}
<x-wirekit::skeleton.table :rows="3" :cols="2" />
:::

### 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: reduce` set (see `dist/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`:

```php
'components' => [
    'skeleton' => [],
],
```

## Further Reading

- [Google: Material Design — Loading Placeholders](https://m2.material.io/design/communication/launch-screen.html)
- [MDN: `prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
- [WAI-ARIA: `aria-live`](https://www.w3.org/TR/wai-aria-1.2/#aria-live)
