Choosing between Popover API and Dialog API is difficult because they seem to do the same job, but they don’t!
After a bit lots of research, I discovered that the Popover API and Dialog API are wildly different in terms of accessibility. So, if you’re trying to decide whether to use Popover API or Dialog’s API, I recommend you:
- Use Popover API for most popovers.
- Use Dialog’s API only for modal dialogs.
Popovers vs. Dialogs
The relationship between Popovers and Dialogs are confusing to most developers, but it’s actually quite simple.
Dialogs are simply subsets of popovers. And modal dialogs are subsets of dialogs. Read this article if you want to understand the rationale behind this relationship.
![[popover-accessible-roles.jpg.webp]]
This is why you could use the Popover API even on a <dialog> element.
<!-- Using popover on a dialog element -->
<dialog popover>...</div>
Stylistically, the difference between popovers and modals are even clearer:
- Modals should show a backdrop.
- Popovers should not.
Therefore, you should never style a popover’s ::backdrop element. Doing so will simply indicate that the popover is a dialog — which creates a whole can of problems.
You should only style a modal’s ::backdrop element.
Popover API and its accessibility
Building a popover with the Popover API is relatively easy. You specify three things:
- a
popovertargetattribute on the popover trigger, - an
idon the popover, and - a
popoverattribute on the popover.
The popovertarget must match the id.
<button popovertarget="the-popover"> ... </button>
<dialog popover id="the-popover"> The Popover Content </dialog>
Notice that I’m using the <dialog> element to create a dialog role. This is optional, but recommended. I do this because dialog is a great default role since most popovers are simply just dialogs.
This two lines of code comes with a ton of accessibility features already built-in for you:
- Automatic focus management
- Focus goes to the popover when opening.
- Focus goes back to the trigger when closing.
- Automatic aria connection
- No need to write
aria-expanded,aria-popupandaria-controls. Browsers handle those natively. Woo!
- No need to write
- Automatic light dismiss
- Popover closes when user clicks outside.
- Popover closes when they press the Esc key.
Now, without additional styling, the popover looks kinda meh. Styling is a whole ‘nother issue, so we’ll tackle that in a future article. Geoff has a few notes you can review in the meantime.
Dialog API and its accessibility
Unlike the Popover API, the Dialog API doesn’t have many built-in features by default:
- No automatic focus management
- No automatic ARIA connection
- No automatic light dismiss
So, we have to build them ourselves with JavaScript. This is why the Popover API is superior to the Dialog API in almost every aspect — except for one: when modals are involved.
The Dialog API has a showModal method. When showModal is used, the Dialog API creates a modal. It:
- automatically
inerts other elements, - prevents users from tabbing into other elements, and
- prevents screen readers from reaching other elements.
It does this so effectively, we no longer need to trap focus within the modal.
But we gotta take care of the focus and ARIA stuff when we use the Dialog API, so let’s tackle the bare minimum code you need for a functioning dialog.
We’ll begin by building the HTML scaffold:
<button
class="modal-invoker"
data-target="the-modal"
aria-haspopup="dialog"
>...</button>
<dialog id="the-modal">The Popover Content</dialog>
Notice I did not add any aria-expanded in the HTML. I do this for a variety of reasons:
- This reduces the complexity of the HTML.
- We can write
aria-expanded,aria-controls, and the focus stuff directly in JavaScript – since these won’t work without JavaScript. - Doing so makes this HTML very reusable.
Setting up
I’m going to write about a vanilla JavaScript implementation here. If you’re using a framework, like React or Svelte, you will have to make a couple of changes — but I hope that it’s gonna be straightforward for you.
First thing to do is to loop through all dialog-invokers and set aria-expanded to false. This creates the initial state.
We will also set aria-controls to the <dialog> element. We’ll do this even though aria-controls is poop, ’cause there’s no better way to connect these elements (and there’s no harm connecting them) as far as I know.
const modalInvokers = Array.from(document.querySelectorAll('.modal-invoker'))
modalInvokers.forEach(invoker => {
const dialogId = invoker.dataset.target
const dialog = document.querySelector(`#${dialogId}`)
invoker.setAttribute('aria-expanded', false)
invoker.setAttribute('aria-controls', dialogId)
})
Opening the modal
When the invoker/trigger is clicked, we gotta:
- change the
aria-expandedfromfalsetotruetoshowthe modal to assistive tech users, and - use the
showModalfunction to open the modal.
We don’t have to write any code to hide the modal in this click handler because users will never get to click on the invoker when the dialog is opened.
modalInvokers.forEach(invoker => {
// ...
// Opens the modal
invoker.addEventListener('click', event => {
invoker.setAttribute('aria-expanded', true)
dialog.showModal()
})
})
Great. The modal is open. Now we gotta write code to close the modal.
Closing the modal
By default, showModal doesn’t have automatic light dismiss, so users can’t close the modal by clicking on the overlay, or by hitting the Esc key. This means we have to add another button that closes the modal. This must be placed within the modal content.
<dialog id="the-modal">
<button class="modal-closer">X</button>
<!-- Other modal content -->
</dialog>
When users click the close button, we have to:
- set
aria-expandedon the opening invoker tofalse, - close the modal with the
closemethod, and - bring focus back to the opening invoker element.
modalInvokers.forEach(invoker => {
// ...
// Opens the modal
invoker.addEventListener('click', event => {
invoker.setAttribute('aria-expanded', true)
dialog.showModal()
})
})
const modalClosers = Array.from(document.querySelectorAll('.modal-closer'))
modalClosers.forEach(closer => {
const dialog = closer.closest('dialog')
const dialogId = dialog.id
const invoker = document.querySelector(`[data-target="${dialogId}"]`)
closer.addEventListener('click', event => {
dialog.close()
invoker.setAttribute('aria-expanded', false)
invoker.focus()
})
})
Phew, with this, we’re done with the basic implementation.
Of course, there’s advanced work like light dismiss and styling… which we can tackle in a future article.
Can you use the Popover API to create modals?
Yeah, you can.
But you will have to handle these on your own:
- Inerting other elements
- Trapping focus
I think what we did earlier (setting aria-expanded, aria-controls, and focus) are easier compared to inerting elements and trapping focus.
The Dialog API might become much easier to use in the future
A proposal about invoker commands has been created so that the Dialog API can include popovertarget like the Popover API.
This is on the way, so we might be able to make modals even simpler with the Dialog API in the future. In the meantime, we gotta do the necessary work to patch accessibility stuff.
Deep dive into building workable popovers and modals
We’ve only began to scratch the surface of building working popovers and modals with the code above — they’re barebone versions that are accessible, but they definitely don’t look nice and can’t be used for professional purposes yet.
To make the process of building popovers and modals easier, we will dive deeper into the implementation details for a professional-grade popover and a professional-grade modal in future articles.
In the meantime, I hope these give you some ideas on when to choose the Popover API and the Dialog API!
Remember, there’s no need to use both. One will do.
Popover API or Dialog API: Which to Choose? originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
from CSS-Tricks https://ift.tt/EOxRAIf
via IFTTT



