Skip to main content

Remove or reduce intrusive interstitials on mobile

Intrusive interstitials are dialogs and overlays that cover the main content immediately after a page loads. They block the content the customer came for, and Google's search guidance advises against them. Replacing them with inline content, a compact banner, or a dialog that appears only after the customer engages keeps the page usable on a small screen.


On a phone, an overlay that appears on arrival covers most of the screen. The customer has to find and hit a close control before they can read anything, and the close control is often the smallest tap target on the page.

Intrusive interstitials cause specific problems:

  • Blocked content: customers have to dismiss the overlay before they can reach what they came for.
  • Search guidance: Google's guidance on mobile interstitials specifically calls out content that's covered immediately after a customer arrives from search.
  • Loading metrics: a dialog injected during page load can become the Largest Contentful Paint element, which makes your LCP reflect the dialog rather than your content. For more information, refer to Prevent dialogs from hijacking LCP.
  • Accessibility issues: a dialog that appears without warning, without a focus trap, and without a focus return is hard to use with a screen reader or a keyboard.

Google describes intrusive interstitials as:

  • Dialogs that cover the main content immediately after the customer navigates from search.
  • Standalone interstitials that the customer must dismiss before they can access the content.
  • Layouts where the content in the initial viewport looks like a standalone interstitial.

Anchor to Don't cover the content on arrivalDon't cover the content on arrival

The strongest version of this fix is to not show a dialog on load at all. Anything that a customer sees before they've asked for it competes with the content, so start by asking whether the message can live in the page instead of on top of it.

If you keep a dialog, then gate it on an interaction, such as a scroll, a tap, or a click, rather than on a timer. A timed reveal on a page that the customer hasn't touched still covers the content, and it can also become your LCP element. For the loading-metrics side of that decision, and for the interaction-gating pattern, refer to Prevent dialogs from hijacking LCP.

Anchor to Use an inline signup instead of a dialogUse an inline signup instead of a dialog

Put the signup in the content flow, where it doesn't block anything:

{%- comment -%}
Inline signup in content flow, not blocking overlay
{%- endcomment -%}

<section class="newsletter-signup">
<div class="newsletter-content">
<h3>{{ section.settings.heading }}</h3>
<p>{{ section.settings.subheading }}</p>

{% form 'customer' %}
<div class="newsletter-form">
<input
type="email"
name="contact[email]"
placeholder="Your email"
required
>
<button type="submit">Subscribe</button>
</div>
{% endform %}
</div>
</section>

<style>
.newsletter-signup {
padding: 40px 20px;
background: #f5f5f5;
margin: 40px 0;
text-align: center;
}

.newsletter-content {
max-width: 600px;
margin: 0 auto;
}

.newsletter-form {
display: flex;
gap: 12px;
margin-top: 20px;
}

.newsletter-form input {
flex: 1;
padding: 12px;
font-size: 16px;
border: 1px solid #ddd;
border-radius: 4px;
}

.newsletter-form button {
padding: 12px 24px;
font-size: 16px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>

Anchor to Use a sticky banner instead of an overlayUse a sticky banner instead of an overlay

A compact bar at the bottom of the screen leaves the content readable:

{%- comment -%}
Non-intrusive banner at bottom of screen
{%- endcomment -%}

<div class="promo-banner" id="promoBanner">
<div class="promo-content">
<p>{{ section.settings.message }}</p>
<a href="{{ section.settings.link }}" class="promo-button">
{{ section.settings.button_text }}
</a>
</div>
<button class="promo-close" onclick="closeBanner()">×</button>
</div>

<style>
.promo-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #0066cc;
color: white;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
z-index: 1000;
transform: translateY(100%);
transition: transform 0.3s;
}

.promo-banner.visible {
transform: translateY(0);
}

.promo-close {
background: transparent;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
width: 32px;
height: 32px;
flex-shrink: 0;
}

@media (max-width: 767px) {
.promo-content {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
</style>

<script>
// Reveal the banner on the first interaction, not on a timer
function showBanner() {
if (!localStorage.getItem('promoBannerClosed')) {
document.getElementById('promoBanner').classList.add('visible');
}
}

['scroll', 'pointerdown', 'keydown'].forEach(function (eventType) {
window.addEventListener(eventType, showBanner, {once: true, passive: true});
});

function closeBanner() {
document.getElementById('promoBanner').classList.remove('visible');
localStorage.setItem('promoBannerClosed', 'true');
}
</script>

Anchor to Show a dialog only after the customer engagesShow a dialog only after the customer engages

If you do use a dialog, then build it with the native <dialog> element and open it with showModal(). That gives you a focus trap, focus restoration on close, Escape handling, an inert background, and the ::backdrop pseudo-element without writing any of it yourself.

The following example is complete: the markup, the styles, the eligibility check, and the two triggers all refer to the same dialog. It opens after the customer has scrolled past half of the page or moved the pointer out of the top of the window, and only if they haven't seen or dismissed it recently:

<dialog class="email-dialog" id="emailDialog" aria-labelledby="emailDialogTitle">
<form method="dialog" class="email-dialog__dismiss">
<button value="dismiss" aria-label="Close">&times;</button>
</form>

<h2 id="emailDialogTitle">{{ section.settings.heading }}</h2>
<p>{{ section.settings.subheading }}</p>

{% form 'customer' %}
<input
type="email"
name="contact[email]"
placeholder="Your email"
aria-label="Email address"
required
>
<button type="submit">Subscribe</button>
{% endform %}
</dialog>

<style>
.email-dialog {
border: none;
border-radius: 8px;
padding: 32px;
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}

.email-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}

.email-dialog__dismiss button {
position: absolute;
top: 16px;
right: 16px;
background: transparent;
border: none;
font-size: 32px;
line-height: 1;
color: #666;
cursor: pointer;
min-width: 48px;
min-height: 48px;
}

.email-dialog input,
.email-dialog button[type="submit"] {
font-size: 16px;
min-height: 48px;
padding: 12px;
}
</style>

<script>
(function () {
var dialog = document.getElementById('emailDialog');
var opened = false;

function shouldShowDialog() {
// Don't show it again within a week of the last time it was seen
var lastSeen = localStorage.getItem('emailDialogLastSeen');
if (lastSeen) {
var daysSince = (Date.now() - parseInt(lastSeen, 10)) / (1000 * 60 * 60 * 24);
if (daysSince < 7) {
return false;
}
}

// Don't show it to a customer who already subscribed or dismissed it
if (localStorage.getItem('emailDialogSubscribed') === 'true') {
return false;
}

return true;
}

function openDialog() {
if (opened || !shouldShowDialog()) {
return;
}

opened = true;
localStorage.setItem('emailDialogLastSeen', Date.now().toString());
dialog.showModal();
}

// Trigger 1: the customer has scrolled past half of the page
window.addEventListener('scroll', function () {
var scrollable = document.documentElement.scrollHeight - window.innerHeight;
if (scrollable > 0 && window.scrollY / scrollable > 0.5) {
openDialog();
}
}, {passive: true});

// Trigger 2: the pointer leaves the window through the top edge.
// mouseleave on documentElement fires only when the pointer leaves the
// document, so moving between elements inside the page doesn't trigger it.
document.documentElement.addEventListener('mouseleave', function (event) {
if (event.clientY < 10) {
openDialog();
}
});
})();
</script>

Exit intent works with a pointer only, so a touch-only session relies on the scroll trigger. Don't add a timer as a fallback for touch, because that reintroduces the untriggered reveal that this page is about.


Anchor to Convert a dialog to an inline sectionConvert a dialog to an inline section

The following dialog covers the content as soon as the page loads:

// Anti-pattern: the dialog appears immediately on page load
window.addEventListener('load', function() {
document.getElementById('emailPopup').style.display = 'block';
});

Moving the same offer into the page removes the problem entirely:

<!-- Inline email signup section -->
<section class="email-signup" style="padding: 60px 20px; background: #f9f9f9; text-align: center;">
<h2>Stay updated</h2>
<p>Get exclusive offers and new product updates</p>

{% form 'customer' %}
<div style="max-width: 400px; margin: 20px auto; display: flex; gap: 12px;">
<input type="email" name="contact[email]" placeholder="Your email" style="flex: 1; padding: 12px; font-size: 16px;">
<button type="submit" style="padding: 12px 24px; background: #0066cc; color: white; border: none; font-size: 16px;">Subscribe</button>
</div>
{% endform %}
</section>

  1. Mobile device testing: load the page on a phone, arriving from a search result, and confirm that you can read and use the main content without dismissing anything.

  2. Trigger testing: confirm that the dialog opens only after a scroll or an exit-intent gesture, and that it doesn't open at all on a page you load and leave untouched.

  3. Keyboard and screen reader testing: open the dialog, then verify that focus moves into it, that Tab stays inside it, that Escape closes it, and that focus returns to where it was before the dialog opened.

  4. Dismissal testing: dismiss the dialog, reload the page, and confirm that it stays closed for as long as your eligibility check says it should.

  5. Loading metrics: run a Lighthouse mobile audit and confirm that your LCP element is still the main content rather than the dialog or the banner. For more information, refer to Prevent dialogs from hijacking LCP.



Was this page helpful?