Livewire 4 @placeholder: Skeleton Loaders That Match Your Island Layout

Use Livewire 4's @placeholder directive with lazy islands to render skeleton loaders that match your widget layout. Cut CLS without splitting components.

Steven Richardson
Steven Richardson
· 9 min read

Lazy islands are great until the user watches an empty <div> pop into a chart 600ms later. The layout jumps, the CLS score tanks, and the page feels broken even though it isn't.

Livewire 4 ships a fix that's hidden one click deeper in the docs than it should be: @placeholder. Drop it inside a lazy island and you get a skeleton that occupies the exact dimensions of the hydrated content. No spinners. No layout shift.

Here's how to wire it up end to end, including the gotcha that bit me first time: the placeholder root element and the component root element must match, or Livewire's morph engine silently does nothing.

Wrap the slow widget in a lazy island#

Identify the section of your component that's expensive, a chart that runs a quarter's worth of aggregates, a "trending posts" list, a third-party API call. Wrap that block with @island(lazy: true) so Livewire defers the work until the island enters the viewport. The rest of the page paints immediately and the user sees real content above the fold.

{{-- resources/views/components/⚡dashboard.blade.php --}}
<?php
use Livewire\Attributes\Computed;
use Livewire\Component;
use App\Models\Transaction;

new class extends Component {
    #[Computed]
    public function revenue(): string
    {
        // Slow aggregate that used to block initial paint
        return number_format(Transaction::monthToDate()->sum('amount'), 2);
    }
};
?>

<div>
    <h1>Dashboard</h1>

    @island(lazy: true)
        <div class="rounded-lg border border-zinc-200 bg-white p-6">
            <p class="text-sm text-zinc-500">Revenue this month</p>
            <p class="text-3xl font-semibold">${{ $this->revenue }}</p>
        </div>
    @endisland
</div>

If you're new to islands, my walkthrough on Livewire 4 islands and lazy-loading expensive components covers the underlying behaviour, when islands re-render, why they're isolated, and where they break.

Drop in an @placeholder that matches the widget#

Inside the island, add an @placeholder block that mirrors the real content's shape. Same outer container, same padding, same heading and value rows, just swapped out for animated grey bars. When Livewire fetches the island, it morphs the placeholder into the rendered content in place, so nothing on the page moves.

@island(lazy: true)
    @placeholder
        <div class="rounded-lg border border-zinc-200 bg-white p-6">
            <div class="h-4 w-32 animate-pulse rounded bg-zinc-200"></div>
            <div class="mt-3 h-8 w-40 animate-pulse rounded bg-zinc-200"></div>
        </div>
    @endplaceholder

    <div class="rounded-lg border border-zinc-200 bg-white p-6">
        <p class="text-sm text-zinc-500">Revenue this month</p>
        <p class="text-3xl font-semibold">${{ $this->revenue }}</p>
    </div>
@endisland

Both blocks start with a <div> with the same rounded-lg border container. That matters, the docs are emphatic about it, and so am I after losing an afternoon to it once.

Pick between lazy, defer, and skip for your hydration trigger#

lazy, defer, and skip are the three flags @island accepts to control when the island actually renders. They map to three real-world scenarios, viewport-triggered, page-load-triggered, and click-triggered, and there is no on-viewport or on-interact string value. The brief I was working from used those names; the canonical API is just three booleans.

{{-- Loads when the island scrolls into view (good for below-the-fold widgets) --}}
@island(lazy: true)
    {{-- ... --}}
@endisland

{{-- Loads immediately after page paint (good for slow above-the-fold queries) --}}
@island(defer: true)
    {{-- ... --}}
@endisland

{{-- Renders only when triggered, perfect for collapsed sections and modal triggers --}}
@island(skip: true)
    @placeholder
        <button type="button"
                wire:click="$refresh"
                class="rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium hover:bg-zinc-200">
            Load revenue details
        </button>
    @endplaceholder

    <div>
        <p class="text-3xl font-semibold">${{ $this->revenue }}</p>
    </div>
@endisland

skip is the one that's easy to miss. Combine it with a placeholder that contains a wire:click="$refresh" button and you get genuine on-demand hydration, the island is dead weight until the user asks for it. That's the right pattern for accordion content, modal bodies, and tab panes you haven't opened yet, much like the Alpine + @entangle modal pattern for state you only need when the modal is open.

Build a Tailwind animate-pulse skeleton#

Tailwind's animate-pulse utility gives you a clean shimmer with zero JavaScript. For a chart widget, stack three sized blocks, title bar, the chart area, and a footer row, so the placeholder occupies the same vertical space as the rendered output. The rule I follow: every dimension that the real content sets explicitly (heights, paddings, gap), the skeleton sets too.

@island(lazy: true)
    @placeholder
        <div class="rounded-xl border border-zinc-200 bg-white p-6">
            <div class="flex items-center justify-between">
                <div class="h-4 w-40 animate-pulse rounded bg-zinc-200"></div>
                <div class="h-4 w-16 animate-pulse rounded bg-zinc-200"></div>
            </div>
            <div class="mt-6 h-64 w-full animate-pulse rounded bg-zinc-200"></div>
            <div class="mt-4 flex gap-2">
                <div class="h-3 w-16 animate-pulse rounded bg-zinc-200"></div>
                <div class="h-3 w-20 animate-pulse rounded bg-zinc-200"></div>
                <div class="h-3 w-12 animate-pulse rounded bg-zinc-200"></div>
            </div>
        </div>
    @endplaceholder

    <livewire:revenue-chart />
@endisland

If you've already tuned dark mode, add dark:bg-zinc-800 on every skeleton bar, the white-grey bars look like glaring holes against a dark background.

Swap in Flux UI skeleton components#

If you're on Flux UI, <flux:skeleton> does the same job with a tighter API. Pick animate="shimmer" for a sweeping highlight or animate="pulse" for the Tailwind-style fade. Wrap related skeletons in <flux:skeleton.group> to set the animation once and let every child inherit it, handy when a widget has eight or nine bars and you don't want animate="shimmer" repeated on each.

@island(lazy: true)
    @placeholder
        <flux:skeleton.group animate="shimmer">
            <div class="rounded-xl border border-zinc-200 bg-white p-6">
                <flux:skeleton class="h-4 w-40" />
                <flux:skeleton class="mt-6 h-64 w-full" />
                <div class="mt-4 flex gap-2">
                    <flux:skeleton class="h-3 w-16" />
                    <flux:skeleton class="h-3 w-20" />
                </div>
            </div>
        </flux:skeleton.group>
    @endplaceholder

    <livewire:revenue-chart />
@endisland

This pairs cleanly with the rest of the Flux table tooling, if you're already running Flux UI tables with server-driven pagination and sorting for the data grid, the skeleton component family keeps your loading states visually consistent.

Add a fade transition between placeholder and content#

When the island hydrates, Livewire morphs the placeholder DOM into the real content. By default that swap is instant, which can feel abrupt on slower widgets. Wrap the real content root with wire:transition and Livewire uses the browser's native View Transitions API to crossfade in. The fade is hardware-accelerated and ignored gracefully in browsers that don't support it.

@island(lazy: true)
    @placeholder
        <div class="rounded-xl border border-zinc-200 bg-white p-6">
            <div class="h-64 w-full animate-pulse rounded bg-zinc-200"></div>
        </div>
    @endplaceholder

    <div wire:transition class="rounded-xl border border-zinc-200 bg-white p-6">
        <livewire:revenue-chart />
    </div>
@endisland

View Transitions need Chrome 111+, Edge 111+, or Safari 18+ to actually animate. Older browsers fall back to the instant swap, the page still works, you just don't get the fade.

Measure the improvement before you ship it#

Before/after numbers turn this from "looks nicer" into "objectively better." Run Lighthouse on the page in incognito with throttling on (Slow 4G, 4× CPU) and capture three numbers: Time to Interactive (TTI), Cumulative Layout Shift (CLS), and Largest Contentful Paint (LCP). Repeat with lazy islands + matched placeholders in place and the delta on a dashboard with five slow widgets is usually dramatic on TTI and decisive on CLS.

# Quick CLI check from the project root
npx lighthouse https://your-app.test/dashboard \
  --only-categories=performance \
  --form-factor=mobile \
  --throttling-method=simulate \
  --output=html \
  --output-path=./reports/before.html

# Repeat after adding @island + @placeholder and diff the scores
npx lighthouse https://your-app.test/dashboard \
  --only-categories=performance \
  --form-factor=mobile \
  --throttling-method=simulate \
  --output=html \
  --output-path=./reports/after.html

For a perceived-speed comparison the user actually feels, pair this with Livewire 4 wire:navigate and prefetching. The two stack: wire:navigate makes navigations cheap, and islands + placeholders make individual pages paint fast.

Avoid the @placeholder gotchas#

A few things will silently break this pattern, and none of them throw errors. Check each one against your code before you push.

The placeholder root element type must match the component root element type. If your placeholder opens with <div>, your real content must also open with <div>. Swap that to <section> on one side and Livewire's morph algorithm has no shared anchor and the placeholder either persists or vanishes without the swap. The official docs call this out in a small callout; treat it as a load-bearing rule.

@placeholder is for view-based components only, single-file (⚡foo.blade.php) and multi-file. Class-based components ignore the directive entirely. For those, define a placeholder() method on the class that returns either an HTML string or a Blade view. The lazy-loading docs show the exact signature, including the array $params = [] argument that receives the component's incoming props.

Islands can't live inside @foreach or @if. Loops and conditionals belong inside the island, not around it, the moment you wrap an island in a control structure, the island loses its surrounding scope and Livewire errors out. The same rule applies to placeholders since they live inside the island.

Don't over-skeleton. If a section renders in under 50ms, skip the placeholder entirely. Skeletons are for content that's actually slow, wrapping every widget in a placeholder makes the page feel slower because you've trained the user to wait for everything.

Wrapping Up#

@placeholder plus @island(lazy: true) is the simplest layout-shift fix in the Livewire 4 toolbox. You get viewport-aware lazy loading, on-demand hydration via skip: true, native fade transitions, and a skeleton that lives next to the markup it replaces, all without splitting your component tree.

Once your dashboard is paint-fast, the next bottleneck is usually duplicated state and validation logic in your forms. The pattern that fixes that is Livewire 4 form objects with the #[Validate] attribute, which gets your component classes back under 50 lines.

FAQ#

What is the @placeholder directive in Livewire 4?

@placeholder is a Blade directive that defines the markup Livewire renders inside a lazy or deferred component (or island) while the real content is loading. It's only available in view-based components, single-file and multi-file, and the placeholder content is swapped out in place when the island or component hydrates.

How do I add a skeleton loader to a Livewire 4 island?

Wrap the slow region in @island(lazy: true) and put a @placeholder block at the top of the island. Inside the placeholder, render a Tailwind animate-pulse skeleton, or <flux:skeleton> if you're on Flux UI, that occupies the same dimensions as the real content. Keep the placeholder's root element type identical to the real content's root element type.

What is the difference between lazy="on-viewport" and lazy="on-interact"?

Those values don't exist in the official Livewire 4 API. The real flags are @island(lazy: true) (loads when the island enters the viewport), @island(defer: true) (loads immediately after the page paints), and @island(skip: true) (renders only when the island is triggered by a wire:click or similar). Use skip whenever you want a click-to-load button or an accordion that hydrates on open.

Can I use Flux UI skeleton components inside a Livewire @placeholder?

Yes. Drop <flux:skeleton class="h-64 w-full" /> straight into your @placeholder block and pick animate="shimmer" or animate="pulse" per element. Group related skeletons with <flux:skeleton.group> to set the animation once and have every child inherit it.

Does @placeholder work on full Livewire components or only islands?

Both. For a full component, render it with <livewire:revenue lazy /> and add a top-level @placeholder block inside the component's Blade file. For an island, use @island(lazy: true) with an inline @placeholder. Class-based components don't honour the directive, they use a placeholder() method on the class that returns HTML or a view instead.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.