The dialog element lets you overlay content on top of your site in something called the "top layer", which is a special layer that sits above all other content. This way you never have to worry about your dialog being covered by other elements on the page due to z-index issues. While, like the name suggests, it's mostly used for dialogs, it can be used for any kind of overlay content, including lightboxes.
Recently we've been helping out with the DevtoolsTips website, adding Polypane tips and going through the list of issues to add and improve some of the features.
While the site has a ton of screenshots, they were shown relatively small and inline. Adding a lightbox so people could see the large image was on the list of improvements, so I decided to give it a go.
The requirements
All the devtools tips are written in markdown files, and images are linked with the default markdown syntax: ![alt text](image url)
. We wanted to keep it that way, so we could easily add new tips and images without having to worry about the markup or train people to use an alternative syntax.
That meant we had to either transform the markdown to add the dialog element during the build process, or add the dialog element in the browser. Since we needed JS to open the dialog anyway, I decided to go with adding the dialog element in the browser.
After discussing with Patrick, I figured we could use the dialog element to build a lightbox instead of including a big javascript lightbox script. The result is a lightweight function that's a little over 40 lines long that uses the platform as much as possible.
Demo
See it in action here: "Simulate multiple devices that are kept in sync" on DevtoolsTips or check out the video below:
The code
We created a javascript function that adds a dialog for all the content image elements on the page and wraps them in a button that opens the dialog. Here's the entire code, styling excluded:
// The function
function createDialogs(selector) {
if (!selector) {
return console.error('Missing selector argument');
}
const buttonTemplate = document.createElement('button');
buttonTemplate.classList.add('lightbox-button');
buttonTemplate.setAttribute('aria-haspopup', 'dialog');
const dialogTemplate = document.createElement('dialog');
dialogTemplate.classList.add('lightbox');
dialogTemplate.innerHTML = `
<form method="dialog">
<button type="submit">
<span aria-hidden>×</span>
<span class="sr-only">Close dialog</span>
</button>
<span aria-hidden></span>
</form>
`;
function createDialog(img) {
const button = buttonTemplate.cloneNode();
const dialog = dialogTemplate.cloneNode(true);
const form = dialog.querySelector('form');
const span = dialog.querySelector('form > span');
span.before(img.cloneNode());
span.textContent = img.getAttribute('alt');
img.before(button);
button.append(img);
button.after(dialog);
button.addEventListener('click', () => {
dialog.style.setProperty('width', img.naturalWidth + 'px');
dialog.showModal();
});
dialog.addEventListener('click', (event) => event.target === dialog && dialog.close());
}
[...document.querySelectorAll(selector)].forEach(createDialog);
}
// calling the function with a specific CSS selector
createDialogs('.tip-content img');
Line by line
Lets go through it line by line and see what's going on:
function createDialogs(selector) {
if (!selector) {
return console.error("Missing selector argument");
}
The function takes a CSS selector as an argument. If no selector is passed, it logs an error and returns.
After this, we create two templates to re-use for each image: one for the button we wrap an image in, and one for the dialog element itself.
const buttonTemplate = document.createElement('button');
buttonTemplate.classList.add('lightbox-button');
buttonTemplate.setAttribute('aria-haspopup', 'dialog');
We create a button template that we'll use to create the button for each image. We add a class to it for styling, and set the aria-haspopup
attribute to dialog
so screen readers know that the button opens a dialog.
const dialogTemplate = document.createElement('dialog');
dialogTemplate.classList.add('lightbox');
dialogTemplate.innerHTML = `
<form method="dialog">
<button type="submit">
<span aria-hidden>×</span>
<span class="sr-only">Close dialog</span>
</button>
<span aria-hidden></span>
</form>
`;
Next is the dialog template. We give it a class, again for styling, and add the markup for the dialog. There are a few things worth calling out here.
First, the entire contents of the dialog is wrapped in a form with the method "dialog". This is a new type of form method specifically for dialogs. What it lets you do is use the native form handling to close the dialog when the form is submitted, which is what the button with type "submit" does. You can also use multiple buttons with different values to build a dialog with multiple choices, but we don't need that here.
Second, the close button contains two spans: one that has a "×" character and is hidden from screen readers, and one that says "Close dialog" and is hidden from sighted users. This way we can show a close icon for sighted users without having screenreaders read "times" out loud.
We also pre-add a span with aria-hidden
to the dialog. This is where we'll add the image description later.
Next up is the function that creates the elements for us. We begin by creating duplicates of the templates we created earlier:
function createDialog(img) {
const button = buttonTemplate.cloneNode();
const dialog = dialogTemplate.cloneNode(true);
For the dialog we pass "true" to the cloneNode
method, which tells it to clone the entire contents of the template, not just the element itself. If you don't, all you get is an empty dialog element.
Next we create variables for the form and span elements so we can easily access them later.
const form = dialog.querySelector('form');
const span = dialog.querySelector('form > span');
Because we want to show the same image as already on the page (only larger) we clone the image and add it before the span. Next, we set the span's text content to the image's alt text.
span.before(img.cloneNode());
span.textContent = img.getAttribute('alt');
A warning about re-using the alt description: My friend Ben Myers warns about showing the alt text as a description, because it can encourage content editors to add non-alt content in there like a caption or image credits. These don't belong in an alt text, which is meant to describe the image for non-sighted users.
In our case, the alt texts were well written, and there is good content moderation in place, so we decided to go with it. Remember that this span has an aria-hidden
attribute. We add this because the alt text is already set for the image itself, and having it read out twice for screen reader users would be annoying.
Next we add the button and dialog to the page:
img.before(button);
button.append(img);
button.after(dialog);
This code looks a little weird, but here's what happens:
- We add the button to the DOM before the image.
- We add the image to the button. Because the image is already in the DOM, it's moved into the button. Now the image is wrapped in the button.
- With the button in the DOM, we can add the dialog element after it.
We now have the DOM in place and we can hook up our events:
button.addEventListener('click', () => {
dialog.style.setProperty('width', img.naturalWidth + 'px');
dialog.showModal();
});
We begin with an event listener on the button that opens the dialog as a modal when clicked (You can also show a dialog in-page by using dialog.show()
instead of dialog.showModal()
, but that's not very lightbox-y).
In it we also set the dialog's width to the image's natural width, so that the dialog and the alt text are as wide as the image itself. We don't have to worry about naturalWidth
not being available here because the image is already loaded by the time we click on it.
If we don't do this, the dialog would be as wide as the text which, if it goes across multiple lines, is usually much wider than the image. This way, the text is always as wide as the image and the dialog looks much nicer. Let me know if there is a CSS-only way to do this, I'd love to know.
Finally, we add an event listener to the dialog that closes it when the user clicks outside of it.
dialog.addEventListener(
"click",
event => event.target === dialog && dialog.close()
);
What happens here is that when the user clicks on the dialog we check if the target
of the event is the dialog itself. If it is, we close the dialog. Because the entire contents of the dialog is wrapped in a form, the only time the target
matches the dialog element is when we click on the :backdrop
of the dialog, and so this way we've implemented a "click outside" feature.
That's the end of the createDialog function, and we call that for every matching image found on the page, by casting the NodeList returned by querySelectorAll
to an array and calling forEach
on it:
[...document.querySelectorAll(selector)].forEach(createDialog);
Styling
So far, the above doesn't include any styling. You can see the styling in the demo above, but there is a specific bit of functionality that lightboxes have and that is that they prevent the lightbox from becoming wider or taller than the viewport.
While most lightboxes will scale down the image in both dimensions, we only shrink the image horizontally. Most screenshots on the site are from desktop versions of browsers and they're usually in landscape. If the image overflows vertically, we want the image to scroll instead so that people can see all the detail. For this we don't need any additional javascript, we can use modern CSS to fix this for us.
Firstly, we set a maximum width and maximum height to the dialog itself:
.lightbox {
max-width: calc(100vw - 1rem);
max-height: calc(100vw - 1rem);
}
We could also use the newer
vi
for inline (width) andvb
for block (height) units, but in this case it won't make much of a difference, since we're using it to size the image in relation to the viewport.
Then, we set a grid layout on the form inside the lightbox.
.lightbox form {
display: grid;
grid-template-rows: 0 1fr min-content;
}
We have three rows:
- A
0px
high row to place the close button in, so that it can overlap the image in the top right corner. - A
1fr
row for the image, that will take up all the space that is left. - A
min-content
row for the alt description, that will be as high as the text content needs it to be.
The button (in the first row) and the text description (in the last row) are both set to position: sticky
. The button is set to be stuck at top:0
, while the image is set to be stuck at bottom:0
. This way, the button will always be in the top right corner, and the text description will always be at the bottom of the dialog.
The image lastly sits in a row in the middle that's 1fr
, meaning the row will size to the available space (which is all of it apart from the text content at the bottom) or the height of the image. When that 1fr is shorter than the image, it automatically becomes a scroll area and you can scroll to see all of the image.
Preventing scroll on the page
Even though the dialog overlays the entire page, behind it the page still remains scrollable. This can be potentially confusing because you can see the scrollbar move, but the dialog doesn't move. This isn't a huge problem, so we can use progressive enhancement to fix it.
When the dialog is open we want to prevent scrolling, which we can do with overflow:clip
on the :root
element, and we can use the :has()
selector to only apply it when the dialog is open.
:root:has(.lightbox[open]) {
overflow: clip;
}
If you're unfamiliar with overflow: clip you can learn more about it here: Do you know about overflow: clip?.
Animation
The last thing we want to do is add a little animation to the dialog. We want it to fade in so the user has a better idea of what's happening, but when it closes we want to close it without animation, so visitors don't have to wait before interacting with the rest of the page.
Because the dialog is hidden, we can not use transitions and instead we have to use an animation when it becomes visible. To do this, we can hook into the dialog's open
property, which is automatically added and removed by the browser when the dialog is shown and hidden.
@media (prefers-reduced-motion: no-preference) {
.lightbox[open] {
animation: show 0.25s ease-in-out normal;
}
@keyframes show {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
We wrap the entire block in a media query that checks if the user is fine with motion. If they don't mind motion, we add the animation.
Future improvements
There are two improvements we can make to this script:
- Instead of a global function, it would be neater to wrap this functionality up in a web component.
- Instead of the simple fade, we could use the upcoming View Transition API to zoom in the image when it's clicked.
Work for the View Transitions API is underway in this issue, and I welcome anyone that wants to to refactor the function above into a web component.