accessible image modals crosses fingers

This commit is contained in:
2026-03-25 13:00:13 -07:00
parent 409ca37f74
commit ebe2490fd3
2 changed files with 92 additions and 0 deletions

BIN
src/img/2026/snacking-seagull.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@ -0,0 +1,92 @@
---
title: accessible image modals
image:
src: 2026/snacking-seagull.jpg
alt: Image unrelated to post. A seagull floating in the water with a starfish hanging out of eir mouth.
tags:
- reference
- software
---
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](https://www.w3schools.com/howto/howto_css_modal_images.asp){target="_blank"} and [responsive image modals](https://www.w3schools.com/css/css3_images_modal.asp){target="_blank"}. Several search results for "image modal" pop up div solutions - [div modal 1](https://stackoverflow.com/questions/75598914/how-to-display-an-image-clicking-on-it-using-modal-window-on-html-css-and-js){target="_blank"}, [div modal 2](https://dev.to/salehmubashar/create-an-image-modal-with-javascript-2lf3){target="_blank"}, [div modal 3](https://www.youtube.com/watch?v=Y9TNHynFjaQ){target="_blank"}.
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>`](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/dialog/){target="_blank"}. You kinda have to go digging to read about the [dialog element from Scott O'Hara](https://www.scottohara.me/blog/2023/01/26/use-the-dialog-element.html){target="_blank"} or find [AccessibleWeb.dev's piece on modals](https://accessibleweb.dev/modals){target="_blank"}. Or you can go [straight for Adrian Roselli](https://adrianroselli.com/2025/06/where-to-put-focus-when-opening-a-modal-dialog.html){target="_blank"} 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?
1. a semantically meaningful element
1. a backdrop that fills the screen behind the modal, styleable with `::backdrop`
1. automatic use of the `Esc` key to close the modal
1. automatic focus trapping that prevents any tabbing within the page behind the modal (users can tab off the page into the browser buttons)
1. the JS functions `.showModal()` and `.close()`
1. if `closedby` is set to `any`, clicking outside the modal (anywhere on the backdrop) will also close the modal
1. automatic return of focus to the element that triggered the modal
1. the `autofocus` attribute for in-modal elements to set which element should receive focus on opening the modal
1. the `open` attribute 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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog#additional_notes){target="_blank"} (point 1 in that list).
### what doesn't it give us?
1. a close button - gotta roll your own and attach the requisite `.close()` call (or rely on users hitting escape or clicking the backdrop)
1. prevention of scroll on the rest of the page
### a bug
MDN warns:
> Do not add the `tabindex` property to the `<dialog>` element as it is not interactive and does not receive focus.
— [`<dialog>`: additional notes](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog#additional_notes){target="_blank"} (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 the `src` and `alt` attributes - importantly, it doesn't copy the full `<img>` because we *don't* want to copy that `tabindex` attribute
- replaces the current `<img>` in the modal with our new copy with `replaceChild()` (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::backdrop` was given a [relatively calculated](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Colors/Using_relative_colors){target="_blank"} color - `rgba(from var(--color-bg) r g b / .8)` - as well as a blur
- `body:has(dialog[open])` has `overflow: hidden` set
- `dialog img` uses a `max-height` as well as `object-fit: contain`
## errors? questions?
reach out!