Create a WordPress Popup Without a Plugin

WordPress popup tutorial

Learn how to build a configurable popup module you can use in multiple projects, without installing a bulky plugin. We’ll create it so we can choose from multiple display methods:

  • After a fixed delay (in milliseconds), show the popup
  • Show the popup only when the user starts to scrolls UP the page
  • Wait until one or more elements comes into view before showing the popup, e.g. the comments section on a blog post

Requirements of a Popup

  • When a user dismisses the popup, we need to “remember” they’ve dismissed it so we don’t show it to them again.
  • It needs to work well on touch/mobile devices.
  • Showing the popup on the front page of your site might damage Google’s impression of your site. So we want a simple way of controlling which pages have the popup (i.e. don’t show it on every page).
  • Use native JavaScript so we don’t rely on any other code (no jQuery in this one).
  • The inner content of the popup can be whatever you want… including a shortcode from Contact Form 7 or MailChimp 4 WordPress

importantMake sure you’re using a custom child theme so you can edit functions.php.

Break it Down

Most of the logic is going to have to happen in the browser, using JavaScript and some CSS transitions. But… we don’t want to include the JS & CSS assets on pages that will never have a popup. So we need some sort of data structure in PHP that controls everything.

Featured plugin

Top Notification Bar plugin
Add a fully customisable top notification banner to any content on your website.
Notification bar plugin

We’re going to use a standard technique of using an associative array to hold a collection of key/value pairs. Some of the values will also be arrays, which will give us a tree-like structure. Associative arrays like this are useful because we can use wp_localize_script() and json_encode() to convert the PHP array into a format we can pass to the browser.

Here’s what our parameters array will look like in PHP:

$wptpu_popup_params = array(
	'html' => null,
	'closeButtonHtml' => '<i class="fas fa-times"></i>',
	'frontend' => array(
		'outerSelector' => '.wptpu-outer',
		'method' => 'element-in-view',
		'smallShowDelay' => 250, // milliseconds
		'fadeDuration' => 1000, // milliseconds
		'closeWhenClickBackground' => true,
		'alwaysShow' => false,
		'showAfterDelay' => array(
			'delay' => 5000, // milliseconds
		),
		'showWhenElementInView' => array(
			'selector' => '#comments',
		),
		'showWhenScrollUp' => array(
			'deltaTrigger' => 200, // pixels
		),
	),
);

Our code will create this array and then run it through a filter, so we can override any of the values on a per-site/per-page basis. We’ll come back to that after we’ve scaffolded the code.

If $wptpu_popup_params['html'] is null (or an empty string) then none of the JS/CSS assets will be enqueued and the popup HTML will not be added to the page. But, if $wptpu_popup_params['html'] has some HTML in it, we’ll enqueue the assets and add the HTML to the end of the document, wrapped in a hidden <div> element.

Let’s Scaffold the Code

In your custom child theme, create a file called “wpt-popup.php” and paste the following code into it:

<?php

/**
 * Headwall WP Tutorials Popup : WPTPU
 *
 * https://wp-tutorials.tech/add-functionality/wordpress-popups-without-a-plugin/
 *
 */

defined('WPINC') || die();

// If you set this to true, the popup will always show, even if has been
// dismissed on a previous page-view. Useful when you're developing content.
const WPTPU_ALWAYS_SHOW = false;

// When the popup is included on a page, how is it activated?
// after-delay
// element-in-view
// scroll-up
const WPTPU_DEFAULT_DISPLAY_METHOD = 'element-in-view';

function wptpu_get_popup_params() {
	global $wptpu_popup_params;

	if (!is_array($wptpu_popup_params)) {
		$wptpu_popup_params = array(
			'html' => null,
			'closeButtonHtml' => '<i class="fas fa-times"></i>',
			'frontend' => array(
				'outerSelector' => '.wptpu-outer',
				'method' => WPTPU_DEFAULT_DISPLAY_METHOD,
				'smallShowDelay' => 250, // milliseconds
				'fadeDuration' => 1000, // milliseconds
				'closeWhenClickBackground' => true,
				'alwaysShow' => WPTPU_ALWAYS_SHOW,
				'showAfterDelay' => array(
					'delay' => 5000, // milliseconds
				),
				'showWhenElementInView' => array(
					'selector' => '#comments',
				),
				'showWhenScrollUp' => array(
					'deltaTrigger' => 200, // pixels
				),
			),
		);

		$wptpu_popup_params = (array) apply_filters('wptpu_get_popup_params', $wptpu_popup_params);
	}

	return $wptpu_popup_params;
}

function wptpu_enqueue_scripts() {
	// Enqueue our JS & CSS assets.
}
add_action('wp_enqueue_scripts', 'wptpu_enqueue_scripts');

function wptpu_maybe_add_popup_to_document() {
	// Render the popup's HTML, wrapped in a hidden DIV.
}
add_action('wp_footer', 'wptpu_maybe_add_popup_to_document');

Next, open your child theme’s functions.php file and add the following couple of lines:

// WP Tutorials : Popup
require_once dirname(__FILE__) . '/wpt-popup.php';

Finally, create a folder called “wpt-popup” and add two empty text files in there, called “wpt-popup.css” and “wpt-popup.js”.

That’s enough to get us up-and-running, so let’s flesh out the back-end PHP stuff.

The Back-end PHP & Data Structure

Our core back-end logic is already defined in wptpu_get_popup_params(). All it does is create an array of key/value pairs and runs it through a filter. When we define the array, notice that the “html” element is null, so the popup will never be shown. What we need to do is hook the wptpu_get_popup_params filter and put something into the “html” element.

Open your child theme’s “functions.php” file and add the following, to get you started:

/**
 * The HTML for our custom popup for our site, in functions.php
 */
function custom_get_popup_params($popup_params) {
	// Only show our popup on single Posts, but you can change this condition to
	// whatever you want, or remove it completely and show the popup on any/all
	// content.
	if (is_single()) {
		// Build the HTML for our popup and set the "html" element of the popup's
		// parameters.
		$html = '<h2>Join our mailing list</h2>';
		$html .= '<p>get our latest news in your mailbox</p>';
		$html .= do_shortcode('[mc4wp_form id="123"]');
		$popup_params['html'] = $html;

		// Change the display method to "after-delay" while we develop and test.
		// This is not the preferred method - you should consider using
		// "element-in-view" or "scroll-up".
		$popup_params['frontend']['method'] = 'after-delay';
	}

	return $popup_params;
}
add_filter('wptpu_get_popup_params', 'custom_get_popup_params');

All we do here is check that we’re rendering a single Post, set the relevant elements in the $popup_params and return it at end the of the function.

infoBy putting this customisation in the child theme’s “functions.php” file, we can reuse “wpt-popup.php” in multiple projects, without having to modify it 😎

Now we’ve got some useful HTML for our popup, we can enqueue the JS & CSS assets and add some HTML to the page. Open “wpt-popup.php” and replace the contents of wptpu_enqueue_scripts() with the following:

function wptpu_enqueue_scripts() {
	$popup_args = wptpu_get_popup_params();

	if (empty($popup_html = $popup_args['html'])) {
		// No HTML specified for the popup, so it must be disabled here.
	} else {
		$theme_version = wp_get_theme()->get('Version');
		$base_uri = get_stylesheet_directory_uri();
		$handle = 'wptpu';

		wp_enqueue_style(
			$handle,
			$base_uri . '/wpt-popup/wpt-popup.css',
			null, // No CSS dependencies
			$theme_version
		);

		wp_enqueue_script(
			$handle,
			$base_uri . '/wpt-popup/wpt-popup.js',
			null, // No JS dependencies
			$theme_version
		);

		// Pass the frontend parameters to the browser in a global JavaScript
		// object called "wptpuData".
		wp_localize_script(
			$handle,
			'wptpuData',
			$popup_args['frontend']
		);

		// If you want to enqqueue some custom assets after the popup's core
		// JS & CSS, hook wptpu_popup_enqueued and enqueue them in there.
		do_action('wptpu_popup_enqueued');
	}
}

For the final piece of the back-end stuff, we hook the wp_footer action and inject the popup’s HTML into the page. Replace the contents of wptpu_maybe_add_popup_to_document() with this:

function wptpu_maybe_add_popup_to_document() {
	$popup_params = wptpu_get_popup_params();

	if (empty($popup_html = $popup_params['html'])) {
		// No HTML specified for the popup, so it must be disabled here.
	} else {
		echo '<div class="wptpu-outer" style="display:none;">';
		echo '<div class="wptpu-inner">';

		if (!empty($popup_params['closeButtonHtml'])) {
			echo '<div class="wptpu-close-button">';
			echo $popup_params['closeButtonHtml'];
			echo '</div>'; // .wptpu-close-button
		}

		echo wp_kses_post($popup_params['html']);

		echo '</div>'; // .wptpu-inner
		echo '</div>'; // .wptpu-outer
	}
}

Try navigating to a single Post on your site now. You won’t see a popup yet, because we’ve not added the JS & CSS, but if you inspect the DOM in the browser’s Dev Tools and scroll to the end, you should see the our HTML:

HTML for our popup, as seen i the DOM.
Our HTML in the DOM

Core Styles

We’ll keep the CSS simple because you’ll want to customise that to your site. Open “wpt-popup/wpt-popup.css” and dump the following into it:

/**
 * Headwall WP Tutorials Popup : WPTPU
 *
 * https://wp-tutorials.tech/add-functionality/wordpress-popups-without-a-plugin/
 *
 */

.wptpu-outer {
	position: fixed;
	left: 0;
	top: 0;
	width: 100vw;
	height: 100vh;
	background-color: #3338;
	z-index: 1000;
	opacity: 0.0;
	transition: 0.3s opacity;
	flex-direction: column;
	justify-content: center;
	align-items: center;
	display: flex;
}

.wptpu-outer.show {
	opacity: 1.0;
}

.wptpu-inner {
	max-width: 80%;
	position: relative;
	background-color: blue;
	color: white;
	padding: 2rem;
}

.wptpu-close-button {
	position: absolute;
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: center;
	right: 0;
	top: 0;
	width: 2em;
	height: 2em;
	transform: translate(50%, -50%);
	border-radius: 50%;
	background-color: red;
	color: white;
	cursor: pointer;
}

.wptpu-inner p:last-child {
	margin-bottom: 0;
}

That’s enough to create a fixed-position overlay with a centred inner container for our Popup’s HTML. You can change the colours and other properties in your child theme’s “style.css” file, or in the Customiser’s “Additional CSS” area.

The Front-end JavaScript

Open “wpt-popup/wpt-popup.js” and paste the following into it. The code is well commented, so read through it see what’s happening.

/**
 * Headwall WP Tutorials Popup : WPTPU
 *
 * https://wp-tutorials.tech/add-functionality/wordpress-popups-without-a-plugin/
 *
 */

document.addEventListener('DOMContentLoaded', function() {
	'use strict';

	// Uncomment to confirm the script has loaded.
	// console.log('WPT Popup : load');

	// Only proceed if wptpuData has been defined as a global variable by
	// wp_localize_script().
	if (typeof wptpuData === 'undefined') {
		// wptpuData has not been defined. We should never end up in there,
		// so something must be wrong.
		console.log('WPT Popup : wptpuData not defined');
	} else if (typeof localStorage === 'undefined') {
		// This browser does not have access to local storage. Maybe it's an old
		// browser? Who knows. But it means we can't save when a user clicks the
		// "close" button, so we can't go any further.
		console.log(`WPT Popup : Local storage is not available.`);
	} else if (!(wptpuData.element = document.querySelector(wptpuData.outerSelector))) {
		// Somehow, we've not got our outer/wrapper DIV in the DOM. Something
		// must be wrong.
		console.log(`WPT Popup : Outer element not found: ${wptpuData.outerSelector}`);
	} else {
		// Uncomment to confirm the script is ready to initialise.
		// console.log('WPT Popup : init');

		wptpuData.hasTriedToShow = false;
		wptpuData.lastScrollY = 0;
		wptpuData.inner = wptpuData.element.querySelector('.wptpu-inner');
		wptpuData.closeButton = wptpuData.element.querySelector('.wptpu-close-button');

		if (wptpuData.closeButton) {
			wptpuData.closeButton.addEventListener("click", closeWptPopup);
		}

		if (wptpuData.closeWhenClickBackground) {
			wptpuData.element.addEventListener("click", closeWptPopup);

			wptpuData.inner.addEventListener('click', (event) => {
				event.stopPropagation();
			});
		}

		function hasUserSeenThePopupBefore() {
			let hasSeenItBefore = false;

			let now = new Date();
			let lastSeenDate = Date.parse(localStorage.getItem('wptpuLastShown'));
			if (lastSeenDate) {
				hasSeenItBefore = true;
				// let timeSinceLastShown = parseInt(mgwcsData.cd) * 86400;

				// Diagnostics
				// let timeSinceLastShown = Math.round((now - lastSeenDate) / 1000.0);
				// console.log(`The user saw the popup ${timeSinceLastShown} secsseconds ago`);
			} else {
				// Diagnostics
				// console.log('The user has never seen the popup');
			}

			return hasSeenItBefore;
		}

		function showWptPopup() {
			// Diagnostics
			// console.log('WPT Popup : show');

			wptpuData.hasTriedToShow = true;

			if (!wptpuData.alwaysShow && hasUserSeenThePopupBefore()) {
				// Diagnostics
				// console.log('User has already seen the popup');
			} else {
				localStorage.setItem('wptpuLastShown', new Date());

				wptpuData.element.style.removeProperty('display');

				// We leave a short delay between removing the "display" propery
				// and adding the "show" CSS Class. If we just add the "show"
				// class immediately, the CSS transition doesn't work properly.
				setTimeout(
					() => {
						wptpuData.element.classList.add('show');
					},
					wptpuData.smallShowDelay // arbitrary small delay
				);
			}
		}

		function closeWptPopup() {
			// Diagnostics
			// console.log('WPT Popup : hide');

			wptpuData.element.classList.remove('show');

			// Removing the "show" class will cause a CSS transition that takes
			// a few hundred milliseconds, so we need to leave a short delay
			// before we set "display:none;". Otherwise we'll hide the element
			// before it has finished fading-out.
			setTimeout(
				() => {
					wptpuData.element.style.display = 'none';
				},
				wptpuData.fadeDuration
			);
		}


		// Show the popup after a delay using setTimeout().
		if (wptpuData.method == 'after-delay') {
			setTimeout(
				showWptPopup,
				wptpuData.showAfterDelay.delay
			);
		}


		// Show the popup when the user has scrolled up the page a set number
		// of pixels.
		if (wptpuData.method == 'scroll-up') {
			document.addEventListener('scroll', (event) => {
				const thisScrollY = window.scrollY;
				const verticalScrollDelta = thisScrollY - wptpuData.lastScrollY;

				if (!wptpuData.hasTriedToShow) {
					if (verticalScrollDelta < (0 - wptpuData.showWhenScrollUp.deltaTrigger)) {
						showWptPopup();
					}

					if (thisScrollY > wptpuData.lastScrollY) {
						wptpuData.lastScrollY = thisScrollY;
					}
				}
			});
		}


		// Wait for an "observed" element to come into view (at least partially)
		// before showing the popup.
		if (wptpuData.method == 'element-in-view') {
			const elementsToObserve = document.querySelectorAll(wptpuData.showWhenElementInView.selector);

			if (elementsToObserve.length == 0) {
				console.log('WPT Popup : No elements to observe');
			} else {
				wptpuData.observer = new IntersectionObserver(function(entries) {
					let needsToBeShownNow = false;

					if (!wptpuData.hasTriedToShow) {
						entries.forEach((entry) => {
							if (entry.isIntersecting === true) {
								console.log('Intersect');
								needsToBeShownNow = true;
							}
						});
					}

					if (needsToBeShownNow) {
						showWptPopup();
					}
				});

				elementsToObserve.forEach((elementToObserve) => {
					wptpuData.observer.observe(elementToObserve);
				});
			}
		}

	}
});

Let’s look at what’s going on here:

  • Check that all requirements are met
  • Find the popup’s wrapper DIV using document.querySelector(".wptpu-outer")
  • If the popup has a close button then…
    • Connect the close button’s click event
  • If “closeWhenClickBackground” is true, then…
    • close the popup when a click event is detected on the wrapper DIV
  • function hasUserSeenThePopupBefore()
    • If “wptpuLastShown” is defined in this browser’s local storage, return true
  • function showWptPopup()
    • If the user has not seen the popup before, then…
      • Put the current date & time into this browser’s local storage, under the “wptpuLastShown” key
      • Remove “display:none” from the outer element, so it become’s visible (although the opacity is 0.0 so it is totally see-through at first)
      • After a short delay, add “show” to the outer element’s CSS classes, so it changes from class="wptpu-outer" to class="wptpu-outer show"
  • function closeWptPopup()
    • Remove “show” from the outer element’s CSS classes. Because there’s a transition time specified for the “opacity” style, it will fade out over the course of a 0.3s
    • Wait for a few hundred milliseconds and then add style="display:none;" to the outer element
  • If the display method is “after-delay”, then…
    • Set a timeout to call showWptPopup() after the delay (in milliseconds) specified in wptpuData.showAfterDelay.delay
  • If we want to use the “scroll-up” method, then…
    • Create a small function to listen for the document’s scroll event
    • When the document scrolls up for more than the number of pixels specified in the parameters, call showWptPopup()
  • If we’re using the “element-in-view” method, then…
    • If there are no elements in the DOM that match our selector, then…
      • Report an error
    • Else…
      • Create an IntersectionObserver and add our observable elements to it
      • If any of our observed come into view, then…
        • Call showWptPopup()

It might look like a lot of code but, if you take out the comments, there’s not much to it. It follows the familiar pattern of…

  • Test that requirements are met
  • Define our support variables and functions
  • Execute the core logic

The only tricky bit is the IntersectionObserver. Everything else should make sense even if you’re not very comfortable with JavaScript.

Extending the Code

Because we’ve made the code’s configuration filterable (using the “wptpu_get_popup_params” filter), you can apply any logic you want in your filter handler. So… if you want to show the popup on WooCommerce product pages, you could do something like this:

/**
 * The HTML for our custom popup for our site, in functions.php
 */
function custom_get_popup_params($popup_params) {
	if (function_exists('is_product') && is_product()) {
		$html = '<h2>Shipping Deadline</h2>';
		$html .= '<p>We\'re getting close to the holiday shipping deadline. Order by Friday if you want to guarantee delivery.</p>';
		$popup_params['html'] = $html;

		$popup_params['frontend']['method'] = 'after-delay';
		$popup_params['frontend']['showAfterDelay']['delay'] = 5000; // milliseconds

		// Disable the Close button (don't do this though)
		$popup_params['closeButtonHtml'] = null;

	}

	return $popup_params;
}
add_filter('wptpu_get_popup_params', 'custom_get_popup_params');

Notice how we call function_exists('is_product') before calling the is_product() conditional tag. This is important in case you ever deactivate WooCommerce for some reason – you really don’t want to be calling functions that don’t exist.

That’s about it – have fun with your popups, but please don’t abuse them. Most website visitors do not like intrusive popups.

Be respectful of your audience 😎 👍

Like This Tutorial?

Let us know

WordPress plugins for developers

Leave a comment