Build a Reference Lightbox for a Web Treatment Without Losing Context
Build a Reference Lightbox for a Web Treatment Without Losing Context
A lightbox has two jobs, and only one of them is the one people think about. Enlarging the image is obvious. The second job is leaving everything else exactly as it was: the same reference, the same explanation, the same place in the page where the reader was standing.
Most treatment lightboxes do the first job and fumble the second. A click opens a black rectangle over the page; the caption is gone, the source line is gone, and if you were reading with a keyboard, the focus is now behind the overlay in a link you can't see. You press Escape. Nothing. You press Tab. You are in the footer.
The fix is smaller than the problem suggests. Keep one record per reference — thumbnail, large image, caption, source — and let the platform's dialog element open it. Move focus in deliberately, close it deliberately, and put it back where it came from. Keep the caption readable when the large image never arrives.
Four moves, in order.
Make the unexpanded reference understandable first
Before any overlay exists, each reference has to work as an ordinary part of the page. That means three things on every one:
- A thumbnail someone can identify — not a grey rectangle with a filename under it.
- A caption that says what the image demonstrates, not what it depicts.
- A source line that names where it came from and whose it is.
The caption is not decoration for the enlargement. It is the argument. If the only place a reader can find out why a reference matters is inside the lightbox, then the lightbox is load-bearing, and a reader who doesn't open it — or can't — has lost the treatment's reasoning. Write the caption so that someone who never clicks still understands the point. The enlargement then does the smaller job of revealing detail: the third step of the light falloff, the actual angle of the grid.
Then give the enlargement an ordinary route that doesn't depend on your script. The simplest is to wrap the thumbnail in a link to the full-size file:
<h2 id="gallery-heading">References</h2>
<figure class="ref" id="ref-02">
<a class="ref__link" href="refs/study-02-large.jpg">
<img src="refs/study-02-thumb.jpg"
alt="Plan grid with its main axes rotated 45 degrees; a secondary set of lines stays at the original angle.">
</a>
<figcaption>
<span class="ref__title">Study 02 — overhead grid at 45°</span>
<p>The grid is rotated so the room edges run diagonal while the corridor reading stays
straight. That split is what the location section depends on.</p>
<p class="ref__source">Source: in-house spatial study, 2024.</p>
</figcaption>
</figure>
With scripting off, that link opens the image and you get a worse but working experience. With scripting on, you intercept it and open something better. Either way the reader is not stranded.
One distinction worth keeping straight: a filename is not a source. study-02-large.jpg tells you nothing about who made the image or whether you're allowed to use it. The source line does that work, and it belongs alongside the caption in both views.
Open the selected record, not a disconnected picture
The classic broken lightbox is a single <div> somewhere at the end of the body, plus a click handler that does this:
overlay.querySelector('img').src = this.src;
overlay.querySelector('.caption').textContent = this.dataset.caption;
Now image identity is a string copied at click time. Reorder the gallery, rename a file, add a second treatment page, and the copy drifts from the record it came from. You get a large picture of Study 02 under the caption for Study 03, and nobody notices until a client does.
Instead, give each reference its own dialog and put it inside the same <figure>. Identity by containment rather than identity by copying. Duplication is the cost — the caption text exists twice — but the two copies sit three lines apart in the same file, which is a maintenance problem you can see.
<dialog class="lightbox" aria-labelledby="ref-02-title">
<button type="button" class="lightbox__close">Close</button>
<h3 class="lightbox__title" id="ref-02-title" tabindex="-1">Study 02 — overhead grid at 45°</h3>
<img class="lightbox__image" src="refs/study-02-large.jpg"
alt="Plan grid with its main axes rotated 45 degrees; a secondary set of lines stays at the original angle.">
<p class="lightbox__note" hidden>The full-size image did not load. The description and
source below still apply to this reference.</p>
<p class="lightbox__caption">The grid is rotated so the room edges run diagonal while the
corridor reading stays straight. That split is what the location section depends on.</p>
<p class="lightbox__source">Source: in-house spatial study, 2024.</p>
</dialog>
And the script that turns the link into a viewer:
const isPlainClick = (event) =>
event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
document.querySelectorAll('.ref').forEach((figure) => {
const link = figure.querySelector('.ref__link');
const dialog = figure.querySelector('.lightbox');
const heading = figure.querySelector('.lightbox__title');
const close = figure.querySelector('.lightbox__close');
const image = figure.querySelector('.lightbox__image');
const note = figure.querySelector('.lightbox__note');
link.addEventListener('click', (event) => {
if (!isPlainClick(event)) return; // Ctrl-click and middle-click still get the file
event.preventDefault();
if (!dialog.open) dialog.showModal(); // guards a double click
heading.focus();
});
close.addEventListener('click', () => dialog.close());
image.addEventListener('error', () => { note.hidden = false; });
if (image.complete && image.naturalWidth === 0) note.hidden = false;
dialog.addEventListener('close', () => {
const target = figure.isConnected ? link : document.getElementById('gallery-heading');
target.focus();
});
});
Three details in there are doing quiet work.
The modifier-key guard matters more than it looks. If you swallow every click on that link, Ctrl-click and middle-click stop behaving like links, and a reader who wants the file itself — to save it, to open it in a new tab, to send it to someone — has no way to get it. Letting modified clicks through costs one line and preserves the escape hatch.
The if (!dialog.open) guard keeps a fast double click from throwing. Repeated use is a real case, not an edge case: readers open Study 01, close it, open Study 03.
And showModal(), not show(), is what makes this a modal in the platform's sense rather than a box that merely looks like one. The W3C's technique H102 documents the HTML dialog element and draws exactly that distinction: the element is the same, and how you open it determines whether the rest of the page stays reachable. This is the whole reason to prefer the native element over a positioned <div>: you are not hand-rolling modality, focus containment, or Escape handling, and you are not claiming them either. Where the native element fits, use it. Build a modal framework only when you have a requirement the element genuinely cannot meet.
Initial focus is your decision, not the browser's. For a short dialog — one image, one caption, a close button — focusing the close button is fine and quick. For a taller dialog where the content scrolls, put focus on the heading (tabindex="-1" makes it focusable without adding it to the tab order) so the reader lands on the name of the reference and can read downward. Whichever you pick, set it explicitly after opening. Leaving focus to whatever the browser guesses first is how you get a lightbox that dumps people onto a close button when they wanted the description.
Name the dialog while you're there. aria-labelledby pointing at the visible heading gives the dialog an accessible name taken from the reference itself, which is the naming behavior the W3C's ARIA Authoring Practices dialog pattern expects.
Keep the modal usable and return to the right place
A modal is a promise that the rest of the page is unavailable. Break that promise and you have the trap described at the start of this article.
The cheap version of the trap is a hand-rolled overlay carrying role="dialog" and aria-modal="true" over a page that is still fully tabbable. aria-modal is an announcement, not an implementation. If you write it, you owe the reader focus containment, an inert background, Escape dismissal, and focus restoration — all of it by hand, in every browser you support. The native dialog with showModal() takes on that work. It does not take on your testing, and it does not certify anything; H102 is a technique, and the APG dialog pattern is design guidance. Neither one is evidence that your page works. Both tell you what to check.
So check these on the real page:
- A visible close control inside the dialog, reachable by Tab without a long journey. Put it first in the dialog's markup and make it stay visible if the content scrolls.
- Escape dismissal, without a
keydownhandler of your own. - Tab and Shift+Tab that cycle within the dialog's controls and never land on the page behind it.
- Long captions and heavily enlarged images that scroll rather than clip, with the close control still reachable.
- Text enlarged to 200% without the caption being cut off or the close button sliding out of reach.
Then the return. When the dialog closes, focus goes back to the link that opened it — not to the top of the page, not to the body. That is not only a keyboard courtesy. It is the difference between a dialog and a navigation. Clicking a lightbox never leaves the page, so the scroll position is still where it was, and putting focus back on the invoking thumbnail means the reader resumes exactly where they stopped. If you close the lightbox and scroll to the top, or route to a new URL, you have converted a small reveal into a lost place in the document.
Have a successor in mind for the case where the trigger no longer exists — a reference removed, a gallery re-rendered. The APG pattern allows focus to move to a logical successor instead of the invoker; the point is that the destination is decided, not accidental. A gallery heading with a stable id — the <h2 id="gallery-heading"> in the markup above — will do.
Preserve context when the image or the script does not arrive
An enlarged image that fails to load is a common, boring event: a file wasn't exported, a path was rewritten, a CDN hiccuped. The wrong response is a dark empty rectangle with a close button, which tells the reader their browser is broken.
Keep the record intact when the picture is missing. The heading, caption, and source stay visible; the alt text on the image element still describes what should be there; and a short note says plainly that the full-size file didn't load. The listener alone is not enough, because the failure may have happened before your script ran — during parsing, or straight from cache — and in that case no error event is still coming. So check the element's own state after you attach the listener: if image.complete is true and image.naturalWidth is zero, the file is already gone and the note belongs on screen. Browsers present missing images differently — some draw the alt text, some a small broken icon — so the note is what makes the state legible either way, rather than the browser's rendering.
The same reasoning covers the no-script case, which is why the thumbnail is a link and not a div with a click handler. And check that your CSS doesn't quietly depend on the image for layout: a dialog sized by its image collapses to something odd when the image is absent.
None of this is a substitute for testing with an actual keyboard. A working pointer click tells you almost nothing about whether Tab enters the dialog, whether it stays inside, whether Escape works, or where focus lands afterward. Those are separate behaviors with separate failure modes, and one of them working is not evidence for the others.
A three-reference gallery, walked through
Here is the proposed build in full. The three studies are invented for this article — placeholder captions and placeholder source lines standing in for whatever real references the treatment eventually carries. Only three references, because the failure modes show up at three.
Study 01 — corridor vanishing point. Thumbnail refs/study-01-thumb.jpg, large image refs/study-01-large.jpg, both present. Caption: single-point perspective down a service corridor, vanishing point left of center so the exit door stays off-axis.
Study 02 — overhead grid at 45°. Both files present. The markup above.
Study 03 — window light falloff. Thumbnail refs/study-03-thumb.jpg present. The large image path points at refs/study-03-large.jpg, and that file is not in the folder. The break is deliberate.
Now the sequence, starting from the second reference.
The reader arrives at the Study 02 figure with Tab. Before pressing anything, the link's content is already announced and the caption and source are already readable on the page. They press Enter. The handler sees a plain click, calls preventDefault(), opens the dialog with showModal(), and moves focus to the heading.
The dialog now shows the reference's name, the large image, the caption, and the source line — the same record, not a copy assembled at click time. Focus is on the heading, placed there by the script; tabindex="-1" kept the heading out of the tab order, so this programmatic move is the only way it receives focus. Tab moves on to the close button, and from there Tab and Shift+Tab stay inside the dialog — with the close button the only control in its tab order, both directions come back to that same control — never landing on the page behind it. Escape closes it, or the close button does, and either way the close listener puts focus back on the Study 02 link. The page has not moved, because nothing navigated. The reader continues from the reference they were on.
Now the failure case. Open Study 03. The thumbnail loaded fine, so the link is there and Enter works. The dialog opens, focused on its heading, showing the caption and source — and the large image never arrives, because the file isn't there. The error listener, or the check for an image that had already failed before the script ran, reveals the note. The reader learns that the full-size file is missing, reads what Study 03 demonstrates anyway, closes the dialog, and returns to the Study 03 thumbnail. The argument survives the missing file.
This is a proposed interaction, not a recorded one. The code above is written to produce that sequence; the sequence has not been run in a browser, and no assistive-technology review has been performed. What the pattern and the technique establish is the expected behavior, not the behavior your page will exhibit.
What this does not settle
Nothing above is a conformance claim, and using a dialog element does not make a page accessible. The APG dialog pattern is guidance about how modal dialogs should behave; H102 documents the native mechanism and how showModal() differs from show(). Both describe a design, and a design is not a test result.
What remains, on whatever page you build this into: run the sequence in named browsers with a keyboard, confirm entry, containment, Escape, close-button operation, and return focus, then repeat at enlarged text and revisit the failure case with the network throttled or the file genuinely absent. Get a screen reader in front of it if you can. Fix the paths.
And handle the images separately. The three studies here are invented. A real reference needs its own permission established before it enters the deliverable, and that is a different kind of work from writing a caption or wiring a dialog.
When all of that holds, the reader can enlarge a reference, understand why it's there, close it, and keep reading from the same spot — including on the day one of the files is missing.
Frequently asked questions
What are the two jobs of a reference lightbox?
One is enlarging the image. The other is leaving everything else as it was: the same reference, the same explanation, and the reader's place in the page. A lightbox that opens a black rectangle over the page, loses the caption or source, and strands keyboard focus behind the overlay has fumbled the second job.
Why should the caption work before the lightbox is opened?
The caption is the argument, not decoration for the enlargement. If the only place a reader can learn why a reference matters is inside the lightbox, then a reader who does not or cannot open it loses the treatment's reasoning. Write the caption so someone who never clicks still understands the point; the enlargement then reveals detail.
Why put a dialog inside each figure instead of using one global overlay?
A single global overlay copies the image source and caption at click time, so identity is maintained by copying and can drift when the gallery is reordered or files are renamed. Putting each dialog inside the same figure gives identity by containment. The caption text exists twice, but the copies sit close together in the file, which is a visible maintenance problem.
What does showModal() do that show() does not?
showModal() opens the dialog as a modal in the platform's sense; show() does not. The native dialog element with showModal() takes on modality, focus containment and Escape handling, so you are not hand-rolling them. That does not take on testing or certify accessibility; the W3C technique and APG pattern describe expected behavior, not a test result.
Where should focus go when the lightbox closes?
Back to the link that opened it, not to the top of the page or the body. Clicking a lightbox never leaves the page, so scroll position is unchanged; returning focus to the invoking thumbnail lets the reader resume where they stopped. If the trigger no longer exists, choose a logical successor such as the gallery heading.