What a respectful email capture modal should do
You’ve probably seen the bad ones: they slam over the content the second the page loads, ignore the Escape key, and keep reappearing even after you’ve clearly said “no thanks”.
This post walks through the email capture modal I use on my own site. It’s:
Accessible, like my responsive navbar with Flexbox
Brand-matched
Light on JavaScript
Careful about when and how often it appears
You’ll see the final HTML, CSS, and JavaScript, plus how it plugs into WordPress with Contact Form 7 and MailPoet.
For the base code (without plugins handling for submission etc) and to skip all explanation view the codepen here.
What this modal actually does
Here’s the behaviour in plain English:
- You can open it manually via a button.
- It auto-opens once per tab after 6 seconds or when a visitor scrolls ~80% down the page – whichever comes first.
- It remembers if someone:
- dismissed it (localStorage:
emailCaptureDismissed), or - subscribed (localStorage:
emailCaptureSubscribed)
…and it stops auto-opening in both cases.
- dismissed it (localStorage:
It locks keyboard focus inside the dialog while it’s open and closes on Escape.
It uses a hidden honeypot field to catch basic bots.
After a valid email, it shows a success state in the same panel instead of redirecting or jumping the layout.
Everything runs on semantic HTML, a small amount of CSS, and vanilla JavaScript -no external JS libraries.
HTML structure: overlay, panel, and success state
The modal is always in the DOM, tucked near the end of the page. CSS and aria-hidden control whether it’s visible.
The Trigger:
<button class="trigger-btn" data-open-modal>
Open modal
</button>
The Modal markup:
<div
class="modal-overlay"
id="email-modal-overlay"
aria-hidden="true"
>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="email-modal-title"
aria-describedby="email-modal-desc"
>
<div class="modal__panel" id="email-modal" tabindex="-1">
<button
class="close"
type="button"
aria-label="Close email sign-up"
data-close-modal
>
<!-- Close icon -->
</button>
<div class="modal__content">
<div class="modal__left">
<div class="eyebrow">Join the list</div>
<h2 class="title" id="email-modal-title">
Get new posts in your inbox
</h2>
<p class="desc" id="email-modal-desc">
No spam. Unsubscribe anytime.
I send 1–2 emails per month with my best articles.
</p>
<!-- The form goes here -->
</div>
<aside class="modal__aside" aria-label="Why subscribe">
<h3>Why subscribe?</h3>
<ul class="bullets">
<li>Fresh posts, zero fluff</li>
<li>Exclusive notes and code</li>
<li>Occasional giveaways</li>
</ul>
</aside>
</div>
<div class="success" id="success-state">
<!-- Success content -->
</div>
</div>
</div>
</div>
Key accessibility details:
role="dialog"+aria-modal="true"tell assistive tech this is a proper dialog.aria-labelledby/aria-describedbyconnect to the heading and intro text.aria-hidden="true"on the overlay hides the whole thing from screen readers until it opens.The close button gets a clear label:
aria-label="Close email sign-up".
Styling the email signup modal with CSS tokens
The CSS is built around design tokens so you can drop it into your own system without hunting down random hex codes later.
:root {
--bg: #03000c;
--ink: #fff;
--muted: #cdd3df;
--accent: #fcd21d;
--panel: #0b0a14;
--panel-2: #0a0913;
--radius: 16px;
--shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
--ease: cubic-bezier(0.2, 0.6, 0.2, 1);
--speed: 200ms;
}
The overlay and panel use a soft fade + slide:
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(3, 0, 12, 0.6);
backdrop-filter: blur(2px);
opacity: 0;
pointer-events: none;
transition: opacity var(--speed) var(--ease);
z-index: 9999;
}
.modal-overlay[aria-hidden="false"] {
opacity: 1;
pointer-events: auto;
}
.modal__panel {
width: min(760px, 100%);
background: linear-gradient(180deg, var(--panel), var(--panel-2));
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--radius);
box-shadow: var(--shadow);
transform: translateY(12px) scale(0.98);
opacity: 0;
transition:
transform var(--speed) var(--ease),
opacity var(--speed) var(--ease);
position: relative;
overflow: hidden;
}
.modal-overlay[aria-hidden="false"] .modal__panel {
transform: translateY(0) scale(1);
opacity: 1;
}
A couple of UX choices baked in:
Backdrop blur keeps the page visible but de-emphasised.
The panel looks like it’s sitting on top of the content, not replacing it.
A
prefers-reduced-motioncheck disables transitions for visitors who prefer less motion.
Form design (and the honeypot)
The form keeps things as simple as possible: a single email field, a hidden honeypot, and a short consent line.
<form class="wpcf7-form" id="demo-subscribe" novalidate>
<div class="field" aria-live="polite">
<label for="email">Email address</label>
<input
id="email"
name="email"
type="email"
required
autocomplete="email"
placeholder="Your email"
/>
</div>
<!-- Honeypot: bots tend to fill this; humans never see it -->
<div
class="field"
style="position:absolute; left:-9999px;"
aria-hidden="true"
>
<label for="company">Company (leave empty)</label>
<input id="company" name="company" type="text" tabindex="-1" />
</div>
<button class="sub btn" type="submit" id="submit-btn">
Subscribe
</button>
<p class="fineprint">
By subscribing, you agree to receive emails from this blog.
<a href="#">Privacy policy</a>.
</p>
</form>
To keep the label and field nicely spaced, with no weird jump on focus, the wrapper uses a CSS grid gap:
#email-modal .field {
display: grid;
gap: 10px; /* consistent label ↔ input spacing */
}
#email-modal .field label {
margin: 0;
font-size: 10px;
}
Inputs themselves follow the site’s dark UI style:
input[type="email"],
input[type="text"] {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
color: var(--ink);
padding: 12px 14px;
font-size: 15px;
outline: none;
width: 100%;
transition:
border-color var(--speed) var(--ease),
box-shadow var(--speed) var(--ease),
background var(--speed) var(--ease);
}
input::placeholder {
color: color-mix(in srgb, var(--muted) 65%, black 35%);
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 10px 5px
color-mix(in srgb, var(--accent) 20%, transparent);
background: rgba(255, 255, 255, 0.05);
margin-top: 0 !important;
}
JavaScript: open, close, and focus handling
All the behaviour lives in a single self-invoking function so it doesn’t leak variables into the global scope.
Wiring it up
First, the script caches the key DOM nodes and defines the storage keys and timing:
(function () {
const overlay = document.getElementById("email-modal-overlay");
const panel = document.getElementById("email-modal");
const success = document.getElementById("success-state");
const openers = document.querySelectorAll("[data-open-modal]");
const closers = document.querySelectorAll("[data-close-modal]");
const form = document.getElementById("demo-subscribe");
const KEY_SHOWN = "emailCaptureShown"; // per tab
const KEY_DISMISSED = "emailCaptureDismissed"; // cross visits
const KEY_JOINED = "emailCaptureSubscribed"; // cross visits
const SHOW_DELAY_MS = 6000;
const SCROLL_PCT = 0.80;
let lastFocused = null;
// ...functions below...
})();
Opening and closing
The modal respects scroll lock and returns focus to where you came from:
function openModal() {
if (overlay.getAttribute("aria-hidden") === "false") return;
lastFocused = document.activeElement;
overlay.setAttribute("aria-hidden", "false");
panel.style.display = "";
success.style.display = "none";
document.body.style.overflow = "hidden";
setTimeout(() => {
panel.querySelector('input[type="email"]')?.focus();
}, 60);
document.addEventListener("keydown", onKeydown);
document.addEventListener("keydown", trapFocus);
}
function closeModal() {
overlay.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
document.removeEventListener("keydown", onKeydown);
document.removeEventListener("keydown", trapFocus);
lastFocused && lastFocused.focus?.();
}
Escape key support is just:
function onKeydown(e) {
if (e.key === "Escape") closeModal();
}
Focus trapping (for keyboard users)
To stop visitors tabbing “behind” the modal:
function trapFocus(e) {
if (overlay.getAttribute("aria-hidden") === "true" || e.key !== "Tab")
return;
const list = [
...panel.querySelectorAll(
'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'
),
].filter(
(el) => !el.disabled && !el.getAttribute("aria-hidden")
);
if (!list.length) return;
const first = list[0];
const last = list[list.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
Opening rules and frequency capping
The modal can open three ways: click, timer, or scroll. The auto versions are carefully capped.
Manual triggers:
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeModal();
});
openers.forEach((btn) =>
btn.addEventListener("click", (e) => {
e.preventDefault?.();
openModal();
})
);
closers.forEach((btn) =>
btn.addEventListener("click", (e) => {
e.preventDefault?.();
closeModal();
localStorage.setItem(KEY_DISMISSED, "1");
})
);
Auto-open logic:
function openOnce() {
if (!sessionStorage.getItem(KEY_SHOWN)) {
openModal();
sessionStorage.setItem(KEY_SHOWN, "1");
window.removeEventListener("scroll", onScroll);
}
}
function onScroll() {
const d = document.documentElement;
const scrolled =
(window.scrollY + window.innerHeight) / Math.max(d.scrollHeight, 1);
if (scrolled >= SCROLL_PCT) openOnce();
}
if (
!localStorage.getItem(KEY_DISMISSED) &&
!localStorage.getItem(KEY_JOINED) &&
!sessionStorage.getItem(KEY_SHOWN)
) {
window.addEventListener("scroll", onScroll, { passive: true });
const arm = () => setTimeout(openOnce, SHOW_DELAY_MS);
let timer = arm();
document.addEventListener("visibilitychange", () => {
if (
document.visibilityState === "visible" &&
!sessionStorage.getItem(KEY_SHOWN)
) {
clearTimeout(timer);
timer = arm();
}
});
window.addEventListener("pageshow", () => {
if (!sessionStorage.getItem(KEY_SHOWN)) {
clearTimeout(timer);
timer = arm();
}
});
}
The net effect:
Each tab only gets one auto-open.
Once a visitor dismisses or subscribes, they’re not auto-prompted again.
There’s also a handy test hook: add ?showModal=1 or #test-modal to the URL to open it immediately while you’re designing.
Submit handling and success state
For the front-end demo, submit is handled in the browser. In production, this is where your form plugin or API call would go.
form.addEventListener("submit", (e) => {
e.preventDefault();
const email = form.email.value.trim();
const bot = form.company.value.trim(); // honeypot
if (!email || bot) return;
form.style.display = "none";
success.style.display = "block";
localStorage.setItem(KEY_JOINED, "1");
});
The success state is part of the same modal panel, so the layout doesn’t jump or flicker.
How this ties into WordPress, CF7 and MailPoet
On my site this is wired into a fairly standard WordPress stack:
Contact Form 7 handles the form markup and server-side validation.
MailPoet manages the actual list and email automation.
The modal itself lives in a child theme.
Note: Unsure if WordPress is for you? Check out my WordPress vs Webflow article.
At a high level:
1. The modal HTML you’ve seen above is printed in wp_footer only on single blog posts:
add_action( 'wp_footer', function () {
if ( ! is_singular( 'post' ) ) return;
// echo the modal + a hidden CF7 form (via do_shortcode)
}, 5 );
2. The CF7 form is rendered in a hidden container, then JavaScript moves it into the .modal__left area at runtime so the markup stays clean in the editor.
3. A MailPoet signup tag is added inside the CF7 form so each successful submission is stored in a mailing list.
The front-end modal script doesn’t know or care whether you use MailPoet, another plugin, or a custom API. It just shows the form, validates the basics, and handles the UI states. That separation makes it easy to swap out providers later without touching the UX.
Why this email popup pattern works for your visitors
From your visitor’s perspective, this pattern:
Asks at sensible times (after they’ve spent a moment on the page or actually scrolled).
Listens when they say “no” by backing off after a dismissal.
Works with keyboard and screen readers, not against them.
Looks like your brand, not a generic plugin pop-up.
From your side, you get:
A clean, token-based design that’s easy to restyle.
A small, understandable JavaScript file (no framework lock-in).
A modal you can re-use for other flows: waitlists, content upgrades, or feature announcements.
Base CodePen
Below you’ll find a fully editable base set-up of the form (without the submission collection/handling etc). I recommend using ‘edit on codepen’ in the top-right while at a desktop.
See the Pen
Blog page newsletter popup modal by Andrew Murray (@AJM-Media)
on CodePen.
Need this email modal added to your WordPress site?
If you’d like a modal like this wired into your own stack, whether that’s WordPress with CF7/MailPoet or something completely different -I’m happy to help.
If you’re ready to take your site to the next level with more thoughtful UX and email capture that actually respects your visitors, get in touch. I’d love to explore what this could look like for your business.
