I first wrote this post in 2017… What a long time ago! I revisited it recently when I started rebuilding my website and thought I’d update it for anybody who’s new to WordPress developing and wants a quick guide. Let’s see how easy it is…
The problem…
I wanted to load the contents of one page into a popup on another, without dragging in a plugin to do it. When I first wrote this up in 2017 that took a hand-rolled overlay, a pile of CSS to position it, jQuery, and a fair bit of care to stop the page behind scrolling around underneath.
It takes considerably less than that now. The browser actually does most of the work!
1. Start with a link that works
Before any JavaScript, the thing you are opening should be a real page/post at a real URL, and the thing you click should be an ordinary link to it. If the script fails to load, or the request times out, they still get the content like a regular page. Search engines still find it too.
<a class="js-modal" href="<?php the_permalink(); ?>" data-page-id="<?php the_ID(); ?>">
<?php the_title(); ?>
</a>
The data-page-id is what the script will ask the API for. Printing it from PHP is much less trouble than trying to work out an ID from a URL in JavaScript.
2. The <dialog>
This is the part that has really changed. <dialog> is a proper element now, and opening it with showModal() gets you the things that used to be a day’s work: focus moves into it, Tab cannot escape it, Esc closes it, the rest of the page goes inert, and it renders in the browser’s top layer so no z-index on the page can land on top of it. By gad, developers have an easy time in 2026!
<dialog class="modal" id="modal" aria-labelledby="modal-title">
<button type="button" class="modal__close" data-close aria-label="Close">×</button>
<div class="modal__body" aria-live="polite"></div>
</dialog>
Put that in the footer so it is on every page, ready to be filled. The CSS is most of what I used to write, minus the overlay:
.modal {
width: min(640px, calc(100% - 3rem));
padding: 2rem;
border: 0;
border-radius: 12px;
}
.modal::backdrop {
background: rgb(0 0 0 / 0.6);
backdrop-filter: blur(4px);
}
::backdrop is the whole overlay. No second element, no fixed positioning, no z-index arithmetic, and nothing to remember to remove when the dialog closes.
3. Getting the content
In 2017 I fetched the entire target page and pulled one div out of the HTML that came back. While that works, it downloads a full document — header, footer, scripts, the lot — to use just one part of it.
The modern alternative is the REST API: This has been in WordPress core since 4.7 and gives you the content on its own by simply going to:
[domain]/wp-json/wp/v2/pages/42
This returns JSON with title.rendered and content.rendered already run through the same filters the theme would apply. No scraping, and nothing coupled to another template’s markup.
4. The JavaScript
Here’s all you need. And no jQuery insight!
const dialog = document.querySelector('.modal');
const body = dialog.querySelector('.modal__body');
const cache = new Map();
document.addEventListener('click', async (event) => {
const link = event.target.closest('a.js-modal');
if (!link || !link.dataset.pageId) return;
event.preventDefault();
body.innerHTML = '<p class="modal__loading">Loading…</p>';
dialog.showModal();
const id = link.dataset.pageId;
try {
if (!cache.has(id)) {
const response = await fetch(`${modalSettings.root}wp/v2/pages/${id}`);
if (!response.ok) throw new Error(response.status);
const page = await response.json();
cache.set(id, `<h2 id="modal-title">${page.title.rendered}</h2>${page.content.rendered}`);
}
body.innerHTML = cache.get(id);
} catch {
// The link still points at a real page, so stop pretending and
// just go there. Better than a modal sat spinning forever.
dialog.close();
window.location = link.href;
}
});
// Clicking the backdrop means the click landed on the dialog itself
// rather than anything inside it.
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close();
});
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
What’s this doing?
- First set up some
constsfor use later. - We start our Click event listener which looks for the details of the link that’s been clicked, then sets up a modal window with that
dialog.showModal() - Now it starts a
try / catchwhich goes and gets the page data withawait fetch(... - If all that comes through ok, we put it into a
cache(so clicking on it again doesn’t mean it has to reload). At this point, you could amend the HTML to suit your needs. In my example above, it’s literally just grabbing the title and putting it in an H2, then adding the content on after. But you could adapt this to whatever markup and content you need. - All good? Populate the
body.innerHTMLwith the cache data. If there’s been an issue, we just navigate to the page like a regular click. - Finally we have JS to handle the ‘close’ button.
5. Wiring in the API root
Do not hardcode /wp-json/. It moves if the site is in a subdirectory or someone has filtered it. Hand it to the script from PHP – you can do this by dropping the following snippet into your functions.php file:
add_action('wp_enqueue_scripts', function () {
wp_enqueue_script(
'modal',
get_theme_file_uri('js/modal.js'),
[],
null,
['strategy' => 'defer']
);
wp_add_inline_script('modal', sprintf(
'const modalSettings = %s;',
wp_json_encode(['root' => esc_url_raw(rest_url())])
), 'before');
});
A note on privacy (optional)
What we’ve been doing so far is not private!
Reading a published page over the REST API is a public request — anyone can make it with curl, and requiring a token for it would protect nothing.
This changes the moment you want something that is not public: a draft, a private page, a custom field that is only exposed to logged-in users, or anything that writes. Then the request has to be authenticated as the current user, and that is what the WordPress nonce is for. I’m not going to cover that here because I wanted to keep this as simple as possible. But it’s something to remember.
What changed since 2017
This is a rewrite of a post from February 2017. The original still works, which is quite something for nine-year-old JavaScript, but almost every part of it now has a better answer:
- jQuery and
$.ajaxbecamefetchandasync/await, with nothing to install. - The hand-built overlay, its CSS and its scroll-locking became
<dialog>and::backdrop— and with them, focus trapping and Esc handling I no longer have to write or test. - Scraping a class name out of a whole downloaded page became a REST request that returns just the content.
The one thing I would keep from the original is the bit at the top: make the link work first, then improve it. That was right in 2017 and it is still the only part of this that does not depend on the browser being in a good mood.
Tagged