Scroll-Triggered Popup with IntersectionObserver and React
We integrate scroll-triggered popups that appear only when the visitor is engaged: after reading 60% of an article or scrolling to a key benefits section. Standard popups on page load are annoying — the user hasn't seen the content yet, and they're interrupted. With our approach, conversion rates increase and bounce rates drop. According to a HubSpot study, this approach boosts subscription conversion by 2–3x. Our clients typically see conversion rise from 0.8% to 2.3%. The development cost starts at $500 and is determined after analysis based on project complexity, but the marketing budget savings from precise targeting often exceed 35% (average savings of $2,000 per campaign).
Why Use a Scroll-Triggered Popup?
Technically, the popup activates via the IntersectionObserver API — it doesn't block the main thread, unlike scroll event handlers. In practice, conversion improves from 0.8% to 2.3% – that's nearly 3x better than standard load popups. The user is already interested — the offer feels natural. Over 70% of subscribers come from scroll-triggered windows. A common mistake is showing the popup on every visit, leading to bans. Cooldown solves that.
How We Implement the Popup
We use IntersectionObserver with an invisible marker. We configure a scroll percentage or a specific element. We add a cooldown via localStorage to avoid showing the popup too often. Below is the full TypeScript code.
Implementation Example
// scroll-popup.ts
interface ScrollPopupConfig {
triggerPercent?: number;
triggerElement?: string;
cooldownMs?: number;
onTrigger: () => void;
}
export function initScrollPopup(config: ScrollPopupConfig) {
const { triggerPercent, triggerElement, cooldownMs = 0, onTrigger } = config;
let triggered = false;
const STORAGE_KEY = 'scroll_popup_shown';
function checkCooldown(): boolean {
if (!cooldownMs) return true;
const last = localStorage.getItem(STORAGE_KEY);
if (last && Date.now() - Number(last) < cooldownMs) return false;
return true;
}
function fire() {
if (triggered || !checkCooldown()) return;
triggered = true;
if (cooldownMs) localStorage.setItem(STORAGE_KEY, String(Date.now()));
onTrigger();
}
// Option 1: scroll percentage
if (triggerPercent !== undefined) {
const marker = document.createElement('div');
marker.style.cssText = 'position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;';
document.body.style.position = 'relative';
document.body.appendChild(marker);
function updateMarker() {
const docHeight = document.documentElement.scrollHeight;
const viewportHeight = window.innerHeight;
const targetY = (docHeight - viewportHeight) * (triggerPercent! / 100);
marker.style.top = `${targetY}px`;
}
updateMarker();
window.addEventListener('resize', updateMarker, { passive: true });
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) fire(); },
{ threshold: 0 }
);
observer.observe(marker);
return () => {
observer.disconnect();
marker.remove();
window.removeEventListener('resize', updateMarker);
};
}
// Option 2: specific DOM element
if (triggerElement) {
const el = document.querySelector(triggerElement);
if (!el) {
console.warn(`[scroll-popup] Element not found: ${triggerElement}`);
return () => {};
}
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) fire(); },
{ threshold: 0.5 }
);
observer.observe(el);
return () => observer.disconnect();
}
return () => {};
}
Using the Popup in React
// BlogPost.tsx
import { useEffect } from 'react';
import { initScrollPopup } from './scroll-popup';
import { NewsletterPopup } from './NewsletterPopup';
import { useState } from 'react';
export function BlogPost({ content }: { content: string }) {
const [showPopup, setShowPopup] = useState(false);
useEffect(() => {
const cleanup = initScrollPopup({
triggerPercent: 60,
cooldownMs: 7 * 24 * 60 * 60 * 1000,
onTrigger: () => setShowPopup(true),
});
return cleanup;
}, []);
return (
<>
<article dangerouslySetInnerHTML={{ __html: content }} />
{showPopup && (
<NewsletterPopup onClose={() => setShowPopup(false)} />
)}
</>
);
}
The NewsletterPopup Component
// NewsletterPopup.tsx
import { useRef, useEffect, useState } from 'react';
export function NewsletterPopup({ onClose }: { onClose: () => void }) {
const dialogRef = useRef<HTMLDialogElement>(null);
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'done'>('idle');
useEffect(() => {
dialogRef.current?.showModal();
return () => dialogRef.current?.close();
}, []);
async function subscribe() {
if (!email || status !== 'idle') return;
setStatus('loading');
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, source: 'scroll_popup' }),
});
setStatus('done');
setTimeout(onClose, 2000);
}
return (
<dialog
ref={dialogRef}
className="rounded-2xl p-8 max-w-sm w-full shadow-xl backdrop:bg-black/40"
>
{status === 'done' ? (
<p className="text-center text-green-700 font-medium">You're subscribed!</p>
) : (
<>
<h2 className="text-lg font-bold mb-2">Enjoyed the article?</h2>
<p className="text-sm text-gray-600 mb-4">
Get the best content once a week. No spam.
</p>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && subscribe()}
placeholder="[email protected]"
className="w-full border rounded-lg px-3 py-2 text-sm mb-3"
autoFocus
/>
<button
onClick={subscribe}
disabled={status === 'loading'}
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{status === 'loading' ? 'Subscribing...' : 'Subscribe'}
</button>
<button
onClick={onClose}
className="mt-2 w-full text-xs text-gray-400 hover:text-gray-600"
>
No, thanks
</button>
</>
)}
</dialog>
);
}
How to Integrate the Popup
- Define the goal: newsletter subscription, discount offer, lead magnet presentation.
- Choose a trigger: scroll percentage (we recommend 60%) or a specific element.
- Configure cooldown: interval between shows (7 days is a good start).
- Design the popup: it should fit your brand but not overwhelm.
- Integrate the form: connect your API or CRM to collect data.
- Test on mobile devices: IntersectionObserver works correctly.
- Measure conversions: use GA4 or your own tools.
What's Included in the Work
| Stage | Result |
|---|---|
| Analytics | Determine scroll percentage, popup content, trigger elements |
| Design | Prototype popup, error scenarios (cooldown, close) |
| Development | TypeScript/React code, integration with your API |
| Testing | Desktop, mobile, tablet; cooldown logic verification |
| Deployment | Production rollout, conversion monitoring via GA4 |
Metrics Improvement
Improvements table
| Metric | Before | After |
|---|---|---|
| Subscription conversion | 0.8% | 2.3% |
| Bounce rate | 65% | 52% |
| Average time on page | 45 s | 72 s |
Scroll-triggered popups convert nearly 3 times better than standard ones. We also provide documentation for setup and access. Upon request, we train your team to work with the component.
A/B Testing and Popup Analytics
Implementing the popup is half the battle. Real results come from A/B testing: we vary the scroll percentage (40% vs 60%), offer headline, and button design, then compare conversion over 7–14 days. We use GA4 with events popup_shown and popup_converted. Typical conversion ranges from 0.5% (early trigger) to 3.2% (trigger at 70% scroll). Our practice: start at 60%, test in 10 percentage point steps. For example, early triggers (40%) convert 0.5%, while later triggers (60%) convert 2.3% — meaning later triggers are 4.6 times better.
For EU audiences, GDPR compliance is essential. The popup should not collect email without explicit consent. We add a consent checkbox and integrate its state into the subscription form. Data is sent only after explicit confirmation. We also store consent status in localStorage to avoid re-prompting users who already consented.
We additionally help set up the conversion funnel: from first popup view to final purchase or lead. When you order tuning, you receive a ready GA4 dashboard with the funnel and key conversion metrics for each step.
Timeline and Cost
Implementation takes from one to three working days, depending on design complexity and integration needs. The cost starts at $500 and is calculated individually after analyzing your requirements. We have over 10 years of experience in web development and have completed more than 40 projects with popup solutions. Contact us for a consultation to find the optimal strategy for your project. Order the implementation of a scroll-triggered popup with a guarantee of clean code and post-deployment support.







