Matt Felten ⚡️

The Image Overlay I Wanted

July 8, 2026

Image overlays are a solved problem on paper. There are dozens of libraries. Each one I tried for this site had a compromise I couldn’t accept. The URL wouldn’t update. The back button reopened images I’d already closed. Zoom didn’t work on retina screenshots. Accessibility was rough or missing. So I wrote my own.

Click the image to try it. Click again to zoom to natural pixel size, then click anywhere to return.

A placeholder landscape photograph used to demonstrate the image overlayClick the image to open the overlay. Click the image again to zoom to its native pixel size. Press Esc, click the X, or click the dimmed area to close.

Source is on GitHub. ImageOverlay.jsx and ImageOverlay.css.

UI options I considered

The first attempt was a 350px right rail. Close button on top, caption underneath.

It looked editorial in mockups. In context, the problem became clear. My case study images are usually 16:9, and a fixed side rail takes horizontal space from the image whether that space is earned or not. Not every image has a caption, so the rail was often sitting empty, reserved for content that wasn’t there.

The second attempt was a row at the bottom. Caption left, close right.

Better, but the row still consumed vertical space that could have gone to the image. When there was no caption, the row remained, holding space it didn’t need.

The version that shipped removed the row entirely. The close button floats top-right. The caption, when one exists, sits in a low-priority band at the bottom that only appears when needed. The image fills the rest.

When neither caption nor close interrupts the frame, the image gets the whole space. That was the goal.

URL state

I wanted three properties from the URL behavior, at the same time.

Shareable URLs. Opening an image appends an overlay query parameter to the current page URL. The pattern is <page url>?overlay=<image-id>. A query param rather than a route parameter means every page supports the overlay without routing changes. Sending the link reproduces the same view for the recipient.

Back-button-closes. Pressing the browser back button while the overlay is open should close it. The user stays on the case study.

No history pollution. Opening ten images and closing each shouldn’t leave ten history entries. The back button should return to the page before the case study, not cycle through ten previously closed images.

The first two are straightforward history.pushState behavior. The third requires a small workaround.

const closeOverlay = () => {
  window.history.replaceState({}, '', urlWith(null));
  window.history.back();
};

replaceState clears the overlay parameter from the current entry. history.back() then pops that entry. Because the entry no longer carries an overlay URL, forward-navigating into it later doesn’t reopen anything.

The browser still owns the history stack. Each pushed entry leaves no trace once popped.

Click to zoom

Try it in the demo above. The cursor changes to zoom-in over the image. Clicking renders the image at its native pixel size and the container becomes scrollable. For retina case study screenshots, typically 2880×1800 or larger, this lets readers inspect fine details without leaving the page.

The detail worth explaining is where the container scrolls to. A common approach snaps to the top-left of the image, which forces the reader to hunt for the region they were looking at.

Snap to top-left
Follow the cursor

This overlay captures the cursor’s position as a ratio of the image bounds before zooming, then scrolls so that same ratio lands at the center of the viewport after the transition. The point under the cursor stays under the cursor. The effect reads less as a state change and more as the image expanding outward from where the reader pointed.

Zoom is disabled when the source isn’t larger than its rendered size. The cursor stays default and the click is a no-op.

Animation

Three animations play in sequence when the overlay opens.

Backdrop fades in. 280ms, ease-out. Establishes the surface.

Image scales in. From 0.975 to 1.0 with a fade. 440ms, 60ms delay.

Close button and caption fade up. Staggered 200ms and 240ms delays.

The image uses cubic-bezier(0.16, 1, 0.3, 1). It scales up quickly at first, then eases into the final size without bouncing past it. That curve reads less mechanically than ease-out for short transitions.

The stagger matters more than the curve, because the stagger is what tells the reader where to look. If everything animates at once, the overlay reads as a single state flip. Delaying the close button and caption by 200ms after the image makes them feel like they’re settling around a picture that already opened.

Accessibility

The dialog uses role="dialog" with aria-modal="true" and an aria-label bound to the image’s alt text. When focus moves into the dialog, screen readers announce it. I considered adding an explicit aria-live region for the open event but skipped it. The dialog’s label covers the same purpose without the risk of double-announcement.

Focus is trapped inside the card using focus-trap-react. Tab and Shift+Tab cycle through the close button, the image itself when zoom is available, and any links inside the caption. Tab can’t escape into the case study content behind the overlay.

Keyboard
Esc
Close the overlay
Tab
Cycle focus forward inside the card
Shift+Tab
Cycle focus backward
EnterorSpace
Toggle zoom on the focused image
Pan the zoomed view

The card carries tabIndex={-1} and outline: none, which lets the overlay move focus to it programmatically on open without surfacing a visible focus ring. Focusable elements only get their keyboard focus ring when a reader tabs to them. Body scroll is locked while the overlay is open.

Keyboard zoom uses the image’s midpoint as the target, since there’s no cursor position to work from. Mouse zoom keeps the point under the cursor stable. Keyboard zoom keeps the center stable. Different reference points, same idea. After the transition, the reader’s attention lands somewhere they can predict.


Every choice above traded generality for fit. The libraries weren’t wrong. They were built for a context that wasn’t this one.

Matt Felten