Modals and the history API
The search for the ultimate user experience
Update! Modals should be built using the
<dialog>
element, which has not been implemented in this demo.
Please refer to the
MDN <dialog> docs for more information.
Overview
This website is a simple demonstration of how to use the history API to improve the user experience of modals. It does so without changing the URL of the page, so that the page can't be bookmarked with the modal open (if the content is such that the user should be able to bookmark it, then perhaps it should be an entirely separate page).
The impetus for this project was to try to improve the user experience of modals on mobile devices, specifically Android devices, where the OS provides a very prominent back button that is generally always present on screen. In this case a user may open a modal that fills a large portion of the screen and then hit the back button expecting the modal to close, but instead, the user is often taken back to the page they were on before. Whether or not this technique should be used is dependent upon the specific application being built.
Goals:
-
The modal should close when doing any of the following:
- Pressing the browser's back button.
- Hitting the escape key.
- Clicking on the backdrop behind the modal.
- Clicking the X in the modal window.
- Every history entry should be meaningful (i.e. every time you go backwards or forwards, a change should be rendered in the page.
- There should be no repetitive history entries, even if you repeatedly open and close the modal.
- Pressing the browser's back button should close the modal.
- Pressing the browser's forward button after closing the modal should reopen it.
- If the user opens the modal and refreshes the page, the modal should be open after the page refreshes.
- If the user opens the modal and then navigates to another page and then presses the browser's back button, the page should load with the modal open.
-
NOT MET IN THIS DEMO: The user should not be able to navigate
to the elements behind the modal (use the
<dialog>
element to handle this).
Go to the demo page to see this in action. Or keep reading to learn about the implementation.
How to do it
High level:
-
Use
history.pushState()
to create a history entry with astate
value that indicates that the modal should be open. -
On page load and navigation, check the history's
state
value to determine whether or not to render the modal.
Creating the history entry
Here's what pushState()
looks like:
history.pushState(state, title, url)
And here's what those parameters mean:
state |
An object that gets saved with the history entry. |
title |
This is deprecated so don't use it. |
url |
This is the URL to set in the browser's URL bar. In this demo we're not using it because the modal does not represent an entirely different set of content, so it should not have its own unique URL. Note that if you do use this it does not actually cause a page navigation, it just changes what the user sees in the URL bar. |
Here's what we'll use:
history.pushState('modal-open', null, null);
Checking the history state
To access the history state we can use history.state
, but the trick
is knowing when to check it. At a high level there are two types of
events where we need to check the history state:
- When the page is loaded.
- When history is traversed
Here are the various ways this can happen:
User action | What is triggered |
---|---|
Initial page load | Global scope |
Refresh page | Global scope |
Fwd/back that is server rendered | Either bfcache * or global scope |
Fwd/back that is client rendered (i.e. navigating history that has been added using JavaScript) | popstate |
* When bfcache
is used, we don't have to do anything to get the modal
to display again, that's handled by bfcache
itself. So we can ignore
that.
The end result is that we will check the history state in the
global scope and on popstate
.
The code
function handleModal() { if (history.state === 'modal-open') { // show modal } else { // hide modal } }; handleModal(); window.addEventListener('popstate', function() { handleModal(); }); openModalButton.addEventListener('click', function() { history.pushState('modal-open', null, null); handleModal(); });
Note: When clicking the button to open the modal, instead of opening the modal and then pushing to history, we're pushing to history and then using the same logic we use everywhere else to see if a modal should be displayed, and allowing that code to open the modal. The goal here was to reduce duplication of code.
There's a bit more to it than that, but this covers the basics. To see the full details, check out the JavaScript file.
Notes on the implementation
-
Every event that closes the modal just triggers
history.back()
, which is the same as pressing the browser's back button. The reason for this is to prevent superfluous history entries. No matter how many times you open and close the modal, it will only ever add one history entry. Other methods of handling this can result in a lot of useless history entries, which can put the user in a state where they have to hit back several times before getting where they want to go. - It is possible to set a different URL when the modal is open, but doing so introduces a lot of complexity, especially when going directly to the URL that should display the modal.
Additional information
-
bfcache can make things a bit confusing because it will restore the modal
even without this logic implemented. It should be noted that different browsers
also use
bfcache
differently. For example, some browsers may usebfcache
on navigating back to a page from a different domain and some browsers may not. In any event, neither the global scope norpopstate
are triggered whenbfcache
is used, but that doesn't matter because the modal is reopened anyway. -
When you first load a page,
history.state
isnull
, so if you need to sethistory.state
for the first page load, usehistory.replaceState()
. -
When a modal is displayed, background elements should be disabled so that the
user cannot navigate to them. This is not covered by this demo, but is best
handled by using the
<dialog>
element.