accessible image modals
Recently I've been working on a single-page digital rendition of a zine complete with many hand-drawn images. The author wanted to be able to bring images up to a full-screen view, to either zoom in or to put the whole enlarged image on one screen with no scrolling. It was a real struggle to find resources on how to do this in an accessible manner, so I'm writing up what I did.
Fair warning: This solution is likely imperfect.
the dialog element
do you know how many tutorials want you to roll your own modals? It's a not-insignificant amount. W3Schools, top of the search results in many cases, recommends it in two places - image modals and responsive image modals. Several search results for "image modal" pop up div solutions - div modal 1, div modal 2, div modal 3.
If you don't know of the existence of <dialog>, a search for image modals will not get you there quickly.
If you search for accessible image modals, you'll still hear about <div>s. Hell, W3C's ARIA Authoring Practices Guide uses a <div>. You kinda have to go digging to read about the dialog element from Scott O'Hara or find AccessibleWeb.dev's piece on modals. Or you can go straight for Adrian Roselli and find examples that use the native <dialog> element. Thanks Adrian!
Did I roll my own modal at first? Regretfully, yes. I'd used the <dialog> element before... several years ago... in a project I don't have access to anymore... Needless to say, I had forgotten about its existence.
Anyway, I got there. Eventually. So let's talk about modals and <dialog>.
what does <dialog> give us?
so, what do we get from the <dialog> element that we don't get from a <div> modal?
- a semantically meaningful element
- a backdrop that fills the screen behind the modal, styleable with
::backdrop - automatic use of the
Esckey to close the modal - automatic focus trapping that prevents any tabbing within the page behind the modal (users can tab off the page into the browser buttons)
- the JS functions
.showModal()and.close() - if
closedbyis set toany, clicking outside the modal (anywhere on the backdrop) will also close the modal - automatic return of focus to the element that triggered the modal
- the
autofocusattribute for in-modal elements to set which element should receive focus on opening the modal - the
openattribute which is set on the<dialog>when open and removed when closed (when you use the functions mentioned previously)
and more. This is just the pieces in use for me. There's also things like setting forms so that submission closes the dialog (point 1 in that list).
what doesn't it give us?
- a close button - gotta roll your own and attach the requisite
.close()call (or rely on users hitting escape or clicking the backdrop) - prevention of scroll on the rest of the page
a bug
MDN warns:
Do not add the
tabindexproperty to the<dialog>element as it is not interactive and does not receive focus.
— <dialog>: additional notes (point 3 in that list).
despite this, I found that in Firefox (but not Edge), the <dialog> itself was focusable. I have no idea why, but I tested this on a totally unstyled and unmodified page and still found it to be true. As a focusable element, it made no sense. It had no interactivity and could not be activated.
I'm still torn: do I add tabindex="-1"? MDN specifically says not to, but I'm pretty sure they're warning against making it focusable. Why warn against making it nonfocusable when it's not supposed to be focusable in the first place, after all?
At current, I'm ambivalent, but I've added tabindex="-1" to handle Firefox's poor behavior. Making the dialog focusable is unhelpful and confusing.
in addition to the <dialog>
here's what else I wrote...
html
besides the <dialog>, I gave my <img> elements tabindex="0" to make them focusable.
js
in modal.js, I created an openDialog() function that takes in the clicked image. It:
- creates a new
<img>element and copies over thesrcandaltattributes - importantly, it doesn't copy the full<img>because we don't want to copy thattabindexattribute - replaces the current
<img>in the modal with our new copy withreplaceChild()(or if there's no current one, it just appends) - calls
dialog.showModal()
I gave my close button two event listeners, one that listens for click events and one that listens for a keydown of the space or enter keys. In the case of the keydown, it calls event.preventDefault() to stop the space key from scrolling the underlying page.
I also looped through all images and attached my openDialog() function to any image with a tabindex attribute (I had some images that weren't intended to be fullscreened, so they lacked tabindex). Again, I gave them listeners on both click and keydown.
css
here's the most relevant parts of the CSS:
dialog::backdropwas given a relatively calculated color -rgba(from var(--color-bg) r g b / .8)- as well as a blurbody:has(dialog[open])hasoverflow: hiddensetdialog imguses amax-heightas well asobject-fit: contain
errors? questions?
reach out!