Skip to content
Go back

Preventing Visual Shift in Your Laravel App

Visual shift is when elements on the page jump, resize, or rearrange after the page has already loaded. It’s one of the fastest ways to make an app feel unpolished. The user sees the page, reaches for something, and it moves.

I’ve hit three types of visual shift in my Laravel app, each with a different fix. Here’s what they look like and how to prevent them.

Table of contents

Open Table of contents

Type 1: Conditional UI elements that change the layout

My app has three report types — Individual, Team, and Leaderboard. The Individual report has an extra dropdown for selecting a team member. When switching between report types, that dropdown appeared and disappeared, causing all the other filters to jump sideways.

“the app has 3 reports but they currently have inconsistent dropdown filters… the selector fields jump around when navigating between different report types”

The fix: show and disable, don’t hide and show

Instead of conditionally rendering the dropdown with @if, always render it but disable it when it’s not relevant:

<!-- Before: dropdown disappears, layout shifts -->
@if ($selectedReport === 'Individual Report')
    <flux:select wire:model.live="selectedTeamMemberId" class="w-48">
        ...
    </flux:select>
@endif

<!-- After: always visible, disabled when not applicable -->
<flux:select wire:model.live="selectedTeamMemberId" class="w-48"
  :disabled="$selectedReport !== 'Individual Report'">
    ...
</flux:select>

The disabled dropdown takes up the same space as an active one. No layout shift. The greyed-out state communicates “this exists but doesn’t apply right now.”

When to use this pattern:

Type 2: Modal content that loads asynchronously

My Share modal generates a link when opened. If the link doesn’t exist yet, there’s a brief moment where the modal content shifts as the link populates — the text area appears, the copy button moves, and the layout jumps.

“when clicking ‘Share’ in the nav menu, if a share link already exists it works perfectly but if not, there is some visual shift/misalignment that happens for 1 second until the share link is populated”

The fix: loading skeletons

Show a skeleton placeholder that matches the shape of the final content. When the data arrives, swap the skeleton for the real content:

<!-- Show skeleton while loading -->
<div wire:loading>
    <div class="space-y-3">
        <div class="h-4 bg-zinc-200 rounded animate-pulse w-3/4"></div>
        <div class="h-10 bg-zinc-200 rounded animate-pulse"></div>
        <div class="h-8 bg-zinc-200 rounded animate-pulse w-1/3"></div>
    </div>
</div>

<!-- Show real content when loaded -->
<div wire:loading.remove>
    <flux:input readonly :value="$shareUrl" />
    <flux:button wire:click="copyLink">Copy link</flux:button>
</div>

The skeleton takes up the same vertical space as the real content. When the data loads, the swap is seamless — no shift.

When to use this pattern:

Type 3: Alpine x-init firing before content is ready

This is the most subtle type. Alpine.js’s x-init directive runs as soon as the element enters the DOM — not when it becomes visible. If you pre-render a modal’s content (e.g. using @if($this->selectedItem) in the template), any x-init inside that modal fires on page load.

In my case, a Share modal had x-init="generateLink()". Because the component pre-set selectedItem on mount, the modal rendered invisibly and immediately fired a server request. The response changed the DOM, causing a flash of content shift.

The fix: lazy-load modal content

Don’t pre-set the data that triggers modal rendering. Load it on demand when the user actually opens the modal:

<!-- Before: pre-renders modal, x-init fires on page load -->
<!-- selectedItem is set in mount(), so @if is true immediately -->

<!-- After: load on demand -->
<flux:menu.item
    x-on:click="$wire.selectItemForModal({{ $idea->id }})
        .then(() => $flux.modal('share-modal').show())">
    Share
</flux:menu.item>

This way the modal only renders when the user clicks Share. The x-init fires at the right time, and any layout changes happen inside the modal before it’s visible.

When to use this pattern:

The three patterns at a glance

Type of shiftCauseFix
Layout shift from conditional UIElements appear/disappear, pushing others aroundShow and disable instead of hide and show
Content shift from async loadingData arrives after the container is visibleLoading skeletons that match final dimensions
Invisible shift from pre-renderingx-init or wire:init fires before content is visibleLazy-load content only when needed

Key takeaway

Visual shift makes your app feel unreliable. The fix is almost always the same principle: reserve space for content before it arrives. Show disabled elements instead of hiding them. Use skeletons that match the shape of the final content. And never pre-render content that triggers server requests on mount. Your users may not consciously notice these fixes — but they’ll notice when the page stops jumping around.


Back to top ↑