
The short version
- 01Choose the method by who maintains the popup: a no-code script for marketers, a plugin when you want the platform's list, custom code for a developer-owned static popup.
- 02The native dialog element opened with showModal() handles focus trapping, Escape, the backdrop and an inert background; you only write triggers and the frequency cap.
- 03Never open a modal on load for mobile search visitors; scroll depth, exit intent and a minimum time on page keep you clear of Google's intrusive interstitial signal.
- 04Store a lastShown timestamp and a submitted flag in localStorage inside try/catch so the popup respects visitors and still works in private browsing.
- 05On hosted platforms the code goes before the closing body tag: footer code injection on Webflow, Squarespace, Ghost and Framer, a code plugin on WordPress, theme.liquid on Shopify.
To add a popup to your website you have three options: install a no-code popup tool with one script tag, use a plugin built for your platform, or write your own HTML, CSS and JavaScript. This guide covers all three, including a complete accessible popup you can copy, and shows where to paste it on WordPress, Shopify, Webflow and other platforms.
Which route you pick depends on who maintains the site and how often the popup will change. If you want to skip code entirely, the AI popup generator produces a brand-matched popup from a URL in under a minute. If you are deciding between tools, read the best popup builders comparison. If you plan to write code, the rest of this article walks through a working example, then explains the design rules in popup design best practices that the code alone does not enforce.
Three ways to add a popup, and how to choose
There is no single correct method. A marketing team on Shopify has different constraints from a developer shipping a Next.js app. The table below compares the three approaches on the criteria that matter after launch, not only on day one.
| Criteria | No-code popup tool (script tag) | Platform plugin or app | Custom HTML/CSS/JS |
|---|---|---|---|
| Time to first popup | Minutes: build in an editor, paste one script | Minutes to an hour: install, configure in platform admin | Hours to days, including accessibility and QA |
| Who can change copy or design later | Marketer, no deploy | Marketer, inside the platform admin | Developer, requires a deploy |
| Triggers and targeting | Built in (delay, scroll, exit intent, URL rules) | Usually built in, varies by plugin | You write and maintain every trigger |
| Lead storage and delivery | Included (list, CSV, email, webhooks) | Often tied to the platform's own customer list | You build the endpoint, storage, and notifications |
| Analytics | Views, closes, submissions per campaign | Varies; sometimes only submission counts | Only what you wire into your analytics tool |
| Accessibility | Depends on vendor; test before trusting | Depends on plugin; many are weak here | Fully under your control, and fully your responsibility |
| Performance cost | One async script plus an iframe or DOM injection | Plugin code loads on every page, sometimes with its own jQuery | Smallest possible, if you keep it small |
| Portability across platforms | High: same script works anywhere custom HTML is allowed | Low: locked to the platform | High: plain web code |
| Ongoing maintenance | Vendor handles browser changes and bugs | Plugin author handles updates; abandoned plugins are a risk | You handle every browser change and edge case |
| Cost | Free tier, then a monthly subscription | Free to monthly subscription | Developer time, plus the backend you build |
A rough rule for choosing:
- Pick a no-code tool when a non-developer will own the popup, when you need triggers and analytics without building them, or when the site runs on a platform with restricted code access.
- Pick a platform plugin when you want the popup and the platform's native customer list in one place, and you accept being tied to that platform.
- Pick custom code when you have a developer, the popup is simple and stable (a cookie notice, an announcement, one signup form), or your privacy policy forbids third-party scripts.
The remaining sections serve both audiences. Developers get a complete example. Site owners get platform instructions and a one-script alternative. Both get the accessibility and SEO rules that apply whichever way the popup is built.
What a good website popup needs before any code
The word "popup" covers a lot of ground: a modal dialog that blocks the page, a slide-in card in a corner, a bar at the top, a full-screen welcome mat. This article builds a modal dialog, because that is what most people mean by "add a popup" and because it has the strictest accessibility requirements. If you can build a correct modal, the lighter formats are easier.
A correct modal dialog has to do more than appear and disappear. Whatever method you use, verify these behaviors:
- It is announced as a dialog to screen readers, with a name (the heading) and, ideally, a description.
- Focus moves into it when it opens, and returns to where it was when it closes.
- Keyboard focus cannot escape to the page behind it while it is open.
- Escape closes it, and so does a visible close button that is large enough to tap.
- The page behind it is inert: no scrolling, no clicking, no screen-reader navigation into it.
- It respects a frequency rule: a visitor who closed it does not see it again on the next page.
- It does not fire on first paint on mobile, which is what Google's intrusive interstitial guidance penalizes.
The native <dialog> element gives you the first five for free when you open it with showModal(). Items six and seven are trigger logic, which you write yourself. That split is why the example below leans on <dialog> instead of a <div> with role="dialog": the browser does the hard part of focus management, and you write only the parts that are specific to your site.
Which trigger fits which popup
Before writing the popup, decide when it should appear. A trigger is the event that opens the popup; the glossary entry on triggers defines the common ones. The table below summarizes what each is good for and its main risk.
| Trigger type | How it works | Best for | Main risk |
|---|---|---|---|
| Immediately on load | Opens as soon as the script runs | Legal notices, age gates | Highest bounce impact; on mobile it is the pattern Google's interstitial guidance calls out |
| After a time delay | Opens after N seconds on the page | Announcements, content upgrades | A fixed delay ignores whether the visitor is reading or idle |
| At a scroll threshold | Opens once the visitor has scrolled past N% of the page | Blog signups, content upgrades | Fires too early on short pages; needs a minimum time as well |
| On exit intent (desktop) | Opens when the pointer leaves through the top of the viewport | Cart recovery, last-chance offers | False positives from tab switching and multiple monitors |
| On exit intent (touch) | Uses inactivity plus a hide-then-return signal | Same use cases on phones | Weaker signal; keep the popup small and easy to dismiss |
| On click | Opens when a visitor clicks a button or link | Gated downloads, "get a quote" forms | None from an intrusiveness point of view; this is the safest trigger |
| On inactivity | Opens after N seconds without scroll, click or key press | Help offers, "still deciding?" prompts | Interrupts careful readers |
The example implements a scroll threshold and a desktop exit-intent trigger, with a minimum time on page that applies to both. Those cover most signup and offer popups, and an on-click trigger is a one-line addition once the open function exists.
Step 1: the HTML for a website popup
Start with the markup. Paste this anywhere inside <body>, ideally near the closing tag so it does not interfere with the page's own content order. The <dialog> element is hidden by default until it is opened from JavaScript, so there is no flash of unstyled popup on load.
<dialog id="signup-popup" class="popup" aria-labelledby="signup-title" aria-describedby="signup-desc">
<div class="popup__inner">
<button type="button" class="popup__close" data-popup-close aria-label="Close dialog">
<span aria-hidden="true">✕</span>
</button>
<h2 id="signup-title" class="popup__title">Get the weekly checklist</h2>
<p id="signup-desc" class="popup__desc">
One short email every Monday. No spam, unsubscribe in one click.
</p>
<form class="popup__form" action="/subscribe" method="post">
<label for="signup-email" class="popup__label">Email address</label>
<input
id="signup-email"
class="popup__input"
name="email"
type="email"
autocomplete="email"
inputmode="email"
required
autofocus
>
<button type="submit" class="popup__submit">Subscribe</button>
</form>
<p class="popup__fine">
By subscribing you agree to our <a href="/privacy">privacy policy</a>.
</p>
</div>
</dialog>
What each part is doing:
aria-labelledbyandaria-describedbypoint at the heading and the intro paragraph. Screen readers announce "Get the weekly checklist, dialog" followed by the description when focus enters, which is the behavior the WAI-ARIA dialog pattern asks for.- The close button comes first in the source so a keyboard user can reach it with one Tab press from the input. The visible glyph is
aria-hiddenand the button carries anaria-label, so the accessible name is "Close dialog" rather than a cross character. - The
<label>is real and visible. Placeholder-only fields fail once the visitor starts typing and are unreliable for assistive technology. autofocuson the email field tells the browser where to put initial focus whenshowModal()runs. For a short signup form, the field is the right place. For a long dialog with a lot of text, the pattern recommends focusing a static element at the top instead, so the screen reader starts reading from the beginning.autocomplete="email"andinputmode="email"get the visitor a matching keyboard and browser autofill on phones, which matters more for conversion than any styling choice.- The
<div class="popup__inner">wrapper exists so that a click on the dark backdrop can be distinguished from a click inside the content. That is used for click-outside-to-close in step 3.
The form posts to /subscribe. Replace that with whatever endpoint stores the email: your own API route, your email provider's form endpoint, or a form service. Do not send leads to a mailto: link; it breaks on most devices.
Step 2: the CSS
The <dialog> element ships with browser default styles (a border, padding, and a centered position when modal). The CSS below overrides those and styles the backdrop, which is a pseudo-element only available on <dialog>.
.popup {
width: min(92vw, 420px);
max-height: 90vh;
padding: 0;
border: 0;
border-radius: 16px;
background: #ffffff;
color: #111111;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
overflow: auto;
}
.popup::backdrop {
background: rgba(0, 0, 0, 0.55);
}
.popup__inner {
position: relative;
padding: 32px 28px 24px;
}
.popup__close {
position: absolute;
top: 8px;
right: 8px;
min-width: 44px;
min-height: 44px;
border: 0;
border-radius: 999px;
background: transparent;
color: #444444;
font-size: 18px;
cursor: pointer;
}
.popup__close:hover,
.popup__close:focus-visible {
background: #f0f0f0;
}
.popup__close:focus-visible,
.popup__input:focus-visible,
.popup__submit:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
.popup__title {
margin: 0 40px 8px 0;
font-size: 22px;
line-height: 1.25;
}
.popup__desc {
margin: 0 0 20px;
color: #444444;
line-height: 1.5;
}
.popup__form {
display: grid;
gap: 10px;
}
.popup__label {
font-size: 14px;
font-weight: 600;
}
.popup__input {
font: inherit;
padding: 12px 14px;
border: 1px solid #b8b8b8;
border-radius: 8px;
}
.popup__submit {
font: inherit;
font-weight: 600;
padding: 12px 16px;
border: 0;
border-radius: 8px;
background: #111111;
color: #ffffff;
cursor: pointer;
}
.popup__fine {
margin: 16px 0 0;
font-size: 12px;
color: #666666;
}
@media (prefers-reduced-motion: no-preference) {
.popup[open] {
animation: popup-in 180ms ease-out;
}
}
@keyframes popup-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
Notes on the choices:
width: min(92vw, 420px)keeps the dialog narrow on desktop and inside the viewport on phones without a media query.max-height: 90vhwithoverflow: automeans a long popup scrolls internally instead of pushing the close button off screen. This is the most common mobile bug in hand-written popups.- The close button is 44 by 44 pixels. WCAG 2.2 success criterion 2.5.8 sets a 24 pixel minimum for target size, and the older 2.5.5 criterion recommends 44. A close icon smaller than a fingertip is the fastest way to get a popup rage-tapped.
- Focus styles are explicit. Removing outlines without replacing them makes keyboard navigation invisible. The
:focus-visibleselector shows the ring for keyboard users and hides it for mouse clicks. - The entrance animation is wrapped in
prefers-reduced-motion: no-preference, so visitors who have asked their operating system for less motion get an instant appearance. MDN documents the prefers-reduced-motion media query and the reasons it exists.
Colors are deliberately plain. Match them to your brand, but keep text contrast at or above 4.5:1 for body copy; light gray on white is the most frequent contrast failure in popups.
Step 3: the JavaScript to open and close the popup
This is the core. It opens the dialog, restores focus on close, handles the close button and click-outside, and records the visitor's choice. Triggers and the frequency cap follow in the next two steps; they call the openPopup() function defined here.
(function () {
var popup = document.getElementById("signup-popup");
if (!popup || typeof popup.showModal !== "function") return;
var STORAGE_KEY = "signup-popup";
var COOLDOWN_DAYS = 7;
var opened = false;
var previouslyFocused = null;
function openPopup() {
if (opened || popup.open || !canShow()) return;
opened = true;
previouslyFocused = document.activeElement;
popup.showModal();
remember({ lastShown: Date.now() });
removeTriggers();
}
function closePopup() {
if (popup.open) popup.close();
}
// Runs on every close: Escape, close button, backdrop click, or form submit.
popup.addEventListener("close", function () {
if (previouslyFocused && typeof previouslyFocused.focus === "function") {
previouslyFocused.focus();
}
});
popup.querySelector("[data-popup-close]").addEventListener("click", closePopup);
// A click on the backdrop lands on the <dialog> itself, not on .popup__inner.
popup.addEventListener("click", function (event) {
if (event.target === popup) closePopup();
});
popup.querySelector("form").addEventListener("submit", function () {
remember({ submitted: true });
});
/* Steps 4 and 5 add canShow(), remember() and the triggers here. */
})();
Why so little code handles so much:
showModal()traps focus natively. While a dialog is open modally, the browser makes everything outside it inert: Tab and Shift+Tab cycle only through the dialog's focusable elements, clicks on the page are ignored, and screen readers cannot navigate into the background. You do not need a hand-written focus trap.- Escape is handled by the browser. Pressing Escape fires a
cancelevent and thenclose. If you ever need to block Escape (for a legally required notice, for example), listen forcanceland callevent.preventDefault(). For a marketing popup, never do that. - The
closeevent is the single place to restore focus. Whether the visitor pressed Escape, clicked the close button, or clicked the backdrop, the dialog firesclose, so focus restoration lives in one handler. The WAI-ARIA pattern requires focus to return to the element that opened the dialog; for a trigger-opened popup, "the element that had focus before" is the closest equivalent. event.target === popupis true only when the click landed on the dialog box outside.popup__inner, which withpadding: 0means the backdrop. Clicking the input, text, or buttons targets a child, so the popup stays open.- Native form submission closes nothing by itself. The form posts to
/subscribeand the page navigates. If you submit withfetch()instead, callclosePopup()after a successful response and show a confirmation inside the dialog before closing.
A note on the role="dialog" alternative. If you must support a browser without <dialog> (support is universal in current browsers, and MDN's dialog element reference tracks the details), you would use a <div role="dialog" aria-modal="true">, toggle it with a class, write a keydown handler that wraps Tab between the first and last focusable elements, listen for Escape yourself, and set inert on the rest of the page. That is roughly 60 more lines and several more edge cases. Prefer the native element; the fallback is where most accessibility bugs in old popup scripts come from.
Step 4: scroll trigger and exit-intent trigger
Replace the comment in step 3 with the code below. It sets a minimum time on page, then arms two triggers. The first to fire opens the popup; both are removed afterwards so nothing fires twice.
var MIN_TIME_ON_PAGE_MS = 8000;
var SCROLL_THRESHOLD = 0.5;
var startedAt = Date.now();
function engagedLongEnough() {
return Date.now() - startedAt >= MIN_TIME_ON_PAGE_MS;
}
// Trigger A: scroll depth. Fires once the visitor has seen 50% of the page.
function onScroll() {
if (!engagedLongEnough()) return;
var seen = window.scrollY + window.innerHeight;
var total = document.documentElement.scrollHeight;
if (total > 0 && seen / total >= SCROLL_THRESHOLD) openPopup();
}
// Trigger B: desktop exit intent. Fires when the pointer leaves the
// document through the top edge, where the tab bar and address bar are.
function onMouseOut(event) {
if (event.relatedTarget !== null) return; // moved to another element, not out of the page
if (event.clientY > 0) return; // left through a side or the bottom, not the top
if (!engagedLongEnough()) return;
openPopup();
}
// Trigger C: explicit click on any element with data-popup-open.
function onOpenClick(event) {
var opener = event.target.closest("[data-popup-open]");
if (!opener) return;
event.preventDefault();
previouslyFocused = opener;
opened = false; // an explicit click always wins over the cap
popup.showModal();
removeTriggers();
}
function removeTriggers() {
window.removeEventListener("scroll", onScroll);
document.removeEventListener("mouseout", onMouseOut);
}
window.addEventListener("scroll", onScroll, { passive: true });
document.addEventListener("mouseout", onMouseOut);
document.addEventListener("click", onOpenClick);
Details worth understanding:
- The minimum time applies to every automatic trigger. A visitor who lands and immediately scrolls to find something should not be interrupted at second one. Eight seconds is a starting point, not a rule; the exit-intent popup guide goes deeper on what "engaged" should mean per page type.
- The scroll trigger uses the passive flag. Scroll listeners without
{ passive: true }can delay scrolling in some browsers; the flag tells the browser the handler will not callpreventDefault(). mouseouton the document is the classic desktop exit-intent signal.relatedTargetisnullonly when the pointer has left the document entirely, andclientY <= 0means it left through the top. This is the simple version. It still has false positives: a visitor reaching for a bookmark, a second monitor above the first, or browser devtools docked at the top. Adding an upward-velocity check reduces that, at the cost of more code.- There is no touch exit-intent here. Phones have no pointer to leave the page. A touch strategy uses inactivity timers and the
visibilitychangeevent (the tab was hidden, then shown again). PopupForge implements that combination; if you write it yourself, keep the popup compact and never trigger it on the back gesture. - The click trigger bypasses the frequency cap on purpose. If a visitor clicks "Get the checklist" in your footer, the popup must open even if they closed it last week. Add
data-popup-opento any link or button and it works.
Step 5: a localStorage frequency cap
The last piece keeps the popup from reappearing on every page. The code stores two facts: when the popup was last shown, and whether the visitor submitted. Add these two functions inside the wrapper as well.
function readState() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch (error) {
return {};
}
}
function remember(patch) {
try {
var state = readState();
for (var key in patch) state[key] = patch[key];
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
/* Storage can be unavailable (private mode, blocked cookies). Fail open. */
}
}
function canShow() {
var state = readState();
if (state.submitted) return false;
if (!state.lastShown) return true;
return Date.now() - state.lastShown > COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
}
How the cap behaves:
- A visitor who submits never sees the popup again on that browser. This is the "hide after submit" rule every popup tool offers, and it is the one visitors notice most when it is missing.
- A visitor who closes it sees it again after seven days. Change
COOLDOWN_DAYSto tune that; "once per session" would usesessionStoragewith the same code. - Every storage call is wrapped in try/catch. MDN's Web Storage documentation notes that
localStorageaccess throws in some private browsing configurations and when the visitor blocks site data. The popup should still work when storage is missing; it will simply show more often for that visitor. - Storage is per browser, not per person. The same visitor on a phone and a laptop counts as two visitors. Only a server-side identity (a logged-in account) can do better, and for a signup popup that is rarely worth it.
Under GDPR and similar laws, a frequency-cap flag that stores no identifier and only serves the visitor's own preference is generally treated as strictly necessary, but your privacy policy should still describe it. If your consent banner blocks all storage until consent, run the popup script only after consent is granted.
The complete example in one file
The snippet below combines steps 1 to 5 into a single HTML page you can save and open in a browser. Scroll past halfway or move the pointer out through the top of the window after eight seconds to see it open.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Popup example</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 40px 20px; line-height: 1.6; }
main { max-width: 640px; margin: 0 auto; }
.filler { height: 240vh; }
/* Paste the CSS from step 2 here. */
</style>
</head>
<body>
<main>
<h1>A page with a popup</h1>
<p>Scroll down, or move the pointer out of the top of the window.</p>
<p><a href="#" data-popup-open>Or open the popup now</a></p>
<div class="filler"></div>
<p>End of the page.</p>
</main>
<!-- Paste the HTML from step 1 here. -->
<script>
/* Paste the JavaScript from steps 3, 4 and 5 here, inside one wrapper function. */
</script>
</body>
</html>
The whole thing is roughly 100 lines of CSS and 110 lines of JavaScript, with no dependencies. That is the honest cost of a correct, accessible popup. It is not large, but the parts that are easy to skip (focus restoration, the storage try/catch, the minimum time on page, the 44 pixel close target) are the ones that produce complaints when skipped.
Accessibility: the WAI-ARIA dialog pattern in practice
The authoritative description of how a modal dialog should behave is the W3C's WAI-ARIA Authoring Practices dialog (modal) pattern. The example above follows it, but the pattern is worth reading directly because it explains the reasoning. The main requirements, mapped to the code:
| Pattern requirement | How the example satisfies it |
|---|---|
The dialog has role="dialog" and aria-modal="true" |
The <dialog> element opened with showModal() exposes both implicitly |
| The dialog has an accessible name | aria-labelledby="signup-title" points at the visible heading |
| Focus moves into the dialog on open | showModal() focuses the autofocus element, or the first focusable element |
| Tab and Shift+Tab stay inside the dialog | Native modal behavior; the rest of the page is inert |
| Escape closes the dialog | Native cancel then close event |
| Focus returns to the invoking element on close | The close handler focuses previouslyFocused |
| Content outside is hidden from assistive technology | Native modal behavior |
Beyond the pattern, three practical checks catch most real-world failures:
- Turn on a screen reader (VoiceOver on macOS is built in; NVDA on Windows is free) and let the popup open. You should hear the dialog role, the heading, and the description before anything else.
- Unplug the mouse and complete the whole flow with the keyboard: open, type an email, submit, and separately open and close with Escape. Note where focus lands after close.
- Zoom the browser to 200% and open the popup on a phone-sized viewport. The close button must stay visible without scrolling and nothing may be cut off.
The popup design best practices article covers the layout and copy side of accessibility: contrast, reading order, and button labels that say what happens.
Avoiding Google's intrusive interstitial penalty
Google's search documentation on avoiding intrusive interstitials describes a page-level signal that applies when a visitor arrives from a search result and is immediately met with a popup that covers the main content on mobile. The guidance is explicit about what counts as intrusive and what does not, and the code above is structured around it.
Patterns Google names as problematic:
- A popup that covers the main content immediately after the visitor arrives from search, or while they are reading.
- A standalone interstitial the visitor must dismiss before reaching the content.
- A layout where the above-the-fold area looks like an interstitial while the content sits below it.
Patterns Google names as acceptable:
- Interstitials required by law, such as cookie consent or age verification.
- Login dialogs on content that is not publicly indexable.
- Banners that use a reasonable amount of screen space and are easy to dismiss.
The practical rules that follow from the guidance:
- Do not open a modal on load for search visitors, especially on mobile. The example's minimum time on page and scroll or exit triggers exist for this reason. A popup that appears after the visitor has read half the article is a different thing from one that blocks the first screen.
- If you need something on the first screen, use a bar or a small corner card, not a full-screen overlay. Keep it dismissible in one tap.
- Do not treat exit intent as a loophole on mobile. A touch "exit" popup that fires on scroll-up within the first seconds is functionally an immediate interstitial.
- Keep the popup out of the page's rendered HTML for crawlers where possible. A
<dialog>that is closed is not rendered, and its text is not what a crawler sees as the primary content. Rendering the popup only when a trigger fires, inside a script-injected iframe (which is how PopupForge and most tools work), has the same effect. - Never use an interstitial to hide the content Google indexed. If the search snippet promised an article, the article must be readable without dismissing anything.
The signal is one of many and Google describes it as affecting pages, not whole sites. That is not a reason to test the limits. The visitors who bounce from an intrusive popup are the same ones who would have converted a paragraph later.
Adding a popup on each platform
For a site on a hosted platform, the question is usually not how to write the popup but where to paste code. The rule is the same everywhere: the script goes before the closing </body> tag, on every page where the popup should be eligible to appear. Each platform has a place for that. The short instructions below apply to the custom code above and to the one-script method in the next section; the linked integration pages have screenshots and platform-specific notes.
WordPress
Two paths. For the custom HTML, CSS and JavaScript from this article, install a header and footer code plugin (WPCode is the common choice) and paste the CSS in the header section and the HTML plus JavaScript in the footer section. Alternatively, a block theme lets you add a Custom HTML block to the footer template part in the Site Editor. Avoid editing footer.php in a theme you did not build; a theme update overwrites it. Details, including the plugin route, are on the WordPress integration page.
Shopify
Open the admin, go to Online Store, then Themes, click the three-dot menu on the live theme and choose Edit code. Open theme.liquid under Layout and paste the code directly before </body>. If you prefer not to touch theme files, the theme editor's Custom Liquid section accepts the same code on a per-template basis. Test on a product page and the cart, since Shopify's checkout does not run theme scripts. See the Shopify integration page.
Webflow
In the Webflow dashboard, open Site settings, then the Custom code tab, and paste into the Footer code field. Custom code in site settings requires a paid site plan; on the free plan, an Embed element on the page works for testing. Publish the site afterward; the Designer preview does not run custom code. Details on the Webflow integration page.
Squarespace
Go to Settings, then Advanced, then Code Injection, and paste into the Footer field. Code injection is available on plans that allow custom code; on other plans a Code block on an individual page can hold the same markup and script. The Squarespace editor may not run the script until you view the live site. See the Squarespace integration page.
Wix
In the Wix dashboard, open Settings, then Custom Code (under Advanced), click Add Custom Code, paste the script, set it to load on all pages in the body end, and apply. Custom code on Wix requires a premium plan with a connected domain. An Embed HTML element also works for a single page, but it renders inside an iframe, which breaks a page-level modal; use the site-wide custom code instead. See the Wix integration page.
Framer
Open Site settings from the project, choose General, scroll to Custom Code and paste into the "End of body" field. Custom code requires a paid site plan. For a single page, a Code component or Embed works but the modal must still be attached to the page body, so site-wide code is the better home. See the Framer integration page.
Ghost
In Ghost Admin, open Settings, then Code injection, and paste into the Site footer field. Ghost applies it to every page of the publication, including member sign-in pages, so check the popup does not fire on those. Ghost's own Portal already handles member signup; a custom popup is most useful for a lead magnet or an announcement. See the Ghost integration page.
Carrd
Add an Embed element to the page, set its type to Code and placement to the end of the body, and paste. Embeds require a paid Carrd plan. Since Carrd sites are single pages, the scroll trigger threshold should be tested carefully; a short page reaches 50% almost immediately. See the Carrd integration page.
Next.js
For the custom code, put the <dialog> markup in a client component rendered from app/layout.tsx, import the CSS as a module or global stylesheet, and run the JavaScript inside a useEffect so it attaches after hydration. Remove the listeners in the effect's cleanup function, otherwise client-side navigation stacks duplicate handlers. For a third-party popup script, use next/script with strategy="afterInteractive" in the root layout. Details and a component example on the Next.js integration page.
For any other platform, the requirement is simply a place to add HTML and JavaScript to the page body. The plain HTML integration page covers static sites and anything not listed, and the integrations index lists every platform.
The PopupForge one-script method
Disclosure: PopupForge is our product, and this section describes it. Read the custom-code sections as the neutral baseline; this one exists for site owners who would rather not maintain the code above.
PopupForge replaces the HTML, CSS, JavaScript, triggers, storage and lead handling with one script tag:
<script src="https://getpopupforge.com/lead-modal.js?appId=YOUR_APP_ID&configId=YOUR_CONFIG_ID" async defer></script>
The workflow:
- Paste your website URL in the AI popup generator. It returns several brand-matched popup directions; the free tool shows three without an account.
- Pick one and adjust it in the visual editor: copy, fields (text, email, phone, number, URL, textarea, select, checkbox, radio, date), and up to three steps for a multi-step form.
- Choose a trigger: immediately, after a delay, at a scroll threshold, or on exit intent. Exit intent on desktop watches the pointer leaving through the top edge once per session; on touch devices it uses an inactivity timer plus a hide-then-return visibility signal.
- Set frequency: always, once per session, or a cooldown window, with an optional hide-after-submit rule.
- Publish. Copy the script tag with your IDs and paste it using the platform instructions above.
The script injects an iframe that renders the popup. Changing the copy, the trigger, or the design later is done in PopupForge and published without redeploying the site, which is the main practical difference from custom code. Leads land in a list in the app with CSV export and email delivery; paid plans add Slack and signed HTTPS webhooks that Zapier or Make can receive. Analytics cover views, step progression, closes, submissions and conversion by page. URL targeting with include and exclude rules, wildcards and query strings is a paid feature.
Where PopupForge is not the best fit: if your privacy policy prohibits any third-party script, if you need the popup to render server-side with zero client JavaScript, or if the popup is a single static legal notice that will never change, the custom code in this article is the better tool. If you need deep ecommerce personalization tied to cart contents, a Shopify-native app may serve you better; the best popup builders comparison covers those. Pricing is on the pricing section: a free plan, Pro at $16 per month, and Agency at $49 per month, with no credit card required to start.
Testing checklist before you ship a popup
Run through this list on a staging copy or a low-traffic page before enabling the popup site-wide. Most items take under a minute.
Behavior
- The popup does not open on page load for a visitor who has just arrived.
- The scroll trigger fires at the intended depth on a long page and does not fire instantly on a short page.
- Exit intent fires when the pointer leaves through the top and not when moving between elements.
- Closing the popup and reloading the page does not show it again.
- Submitting the form and reloading does not show it again.
- The explicit
data-popup-openlink opens it even after it was closed. - Only one popup can open per page view.
Keyboard and screen reader
- Focus lands inside the popup on open.
- Tab and Shift+Tab stay inside the popup.
- Escape closes it.
- Focus returns to the previously focused element after close.
- A screen reader announces the dialog role and the heading.
- The close button reads as "Close dialog", not as a symbol.
Mobile
- The close button is visible without scrolling at 320 pixels wide.
- The popup scrolls internally when the on-screen keyboard is open.
- Nothing fires on the first screen for a visitor from search.
- Tapping the backdrop closes the popup.
Performance and data
- The script loads with
asyncordeferand does not block rendering. - Storage errors in private mode do not break the page (check the console).
- The form's endpoint receives the email and responds within a second.
- Submissions appear in your analytics as an event, so you can compute the conversion rate per page.
- Bot submissions are filtered, for example with a honeypot field.
Once it is live, compare the email capture rate and bounce rate for the pages with the popup against the same pages the week before. A popup that captures emails but raises bounce on a page that also sells something is not a net win; the popup conversion rate calculator helps compare those numbers.
Frequently asked questions
How do I add a popup to my website without coding?
Use a popup tool that installs with a script tag or a plugin for your platform. In PopupForge, paste your URL into the AI popup generator, pick a design, set the trigger and frequency, publish, then paste the script tag into your platform's custom code area (footer code injection on Webflow, Squarespace, Ghost and Framer; a code plugin on WordPress; theme.liquid on Shopify). No code editing beyond the paste is required.
What is the simplest HTML for a website popup?
A <dialog> element with a heading, a close button and your content, opened from JavaScript with showModal(). That single element gives you the overlay, focus trapping, Escape to close and an inert background. The example in this article adds a form, triggers and a frequency cap on top of that base.
How do I make a popup appear after scrolling?
Listen for the scroll event with the passive flag, compare window.scrollY + window.innerHeight to document.documentElement.scrollHeight, and open the popup once the ratio passes your threshold (50% is a common default). Combine it with a minimum time on page so short pages do not trigger it instantly, and remove the listener after it fires.
How do I make a popup show only once?
Store a timestamp in localStorage when the popup opens and a flag when the form is submitted. Before opening, skip the popup if the flag is set or the timestamp is within your cooldown window. Wrap storage calls in try/catch, because private browsing can throw. For once per session, use sessionStorage with the same logic.
Will a popup hurt my Google rankings?
A popup that covers the main content immediately when a mobile visitor arrives from search can trigger Google's intrusive interstitial signal for that page. Popups that appear after engagement (a delay, a scroll depth, exit intent), small banners, and legally required notices such as cookie consent are not affected by the guidance. Trigger timing is the deciding factor, not the existence of a popup.
Should I use the dialog element or a div with role="dialog"?
Use <dialog> with showModal(). It is supported in every current browser and handles focus trapping, Escape, the backdrop and inert background natively. A div with role="dialog" and aria-modal="true" is the fallback for legacy browsers and requires you to write the focus trap, Escape handling and background inerting yourself, which is where most popup accessibility bugs come from.
Reference
Terms in this guide
FAQ
Frequently asked questions
How do I add a popup to my website without coding?
Use a popup tool that installs with a script tag or a plugin for your platform. In PopupForge, paste your URL into the AI popup generator, pick a design, set the trigger and frequency, publish, then paste the script tag into your platform's custom code area. No code editing beyond the paste is required.
What is the simplest HTML for a website popup?
A dialog element with a heading, a close button and your content, opened from JavaScript with showModal(). That single element gives you the overlay, focus trapping, Escape to close and an inert background. Add a form, triggers and a frequency cap on top of that base.
How do I make a popup appear after scrolling?
Listen for the scroll event with the passive flag, compare window.scrollY plus window.innerHeight to the document's scrollHeight, and open the popup once the ratio passes your threshold, commonly 50%. Combine it with a minimum time on page so short pages do not trigger it instantly, and remove the listener after it fires.
How do I make a popup show only once?
Store a timestamp in localStorage when the popup opens and a flag when the form is submitted. Before opening, skip the popup if the flag is set or the timestamp is within your cooldown window. Wrap storage calls in try/catch because private browsing can throw. For once per session, use sessionStorage with the same logic.
Will a popup hurt my Google rankings?
A popup that covers the main content immediately when a mobile visitor arrives from search can trigger Google's intrusive interstitial signal for that page. Popups shown after engagement, small banners, and legally required notices such as cookie consent are not affected. Trigger timing is the deciding factor, not the existence of a popup.
Should I use the dialog element or a div with role="dialog"?
Use the dialog element with showModal(). It is supported in every current browser and handles focus trapping, Escape, the backdrop and inert background natively. A div with role="dialog" and aria-modal="true" is the legacy fallback and requires you to write the focus trap, Escape handling and background inerting yourself.
Put the guide into practice
See three popup directions for your website.
Paste your URL and get three brand-matched popup concepts to review. It is free to try and does not require an account.
Generate 3 popup ideas free