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:
- Filter bars with conditional dropdowns
- Tab interfaces where different tabs have different controls
- Form sections that appear based on a selection
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:
- Modals that fetch data on open
- Panels that load content lazily
- Any component where the server provides data after the initial render
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:
- Modals with
x-initdirectives - Any hidden content that triggers server requests on render
- Components that pre-load data for “performance” but cause side effects
The three patterns at a glance
| Type of shift | Cause | Fix |
|---|---|---|
| Layout shift from conditional UI | Elements appear/disappear, pushing others around | Show and disable instead of hide and show |
| Content shift from async loading | Data arrives after the container is visible | Loading skeletons that match final dimensions |
| Invisible shift from pre-rendering | x-init or wire:init fires before content is visible | Lazy-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.