Every diagram and screenshot on this site opens into the same full-screen viewer when you click it: wheel to zoom on desktop, pinch or double-tap on touch, drag to pan once you’re zoomed in. There’s exactly one of these mounted on the page, once, in the base layout, and every image on every post wires into it through a single HTML attribute. None of it comes from a library. It’s built directly on the Pointer Events API, and I want to walk through why that was the right call here, and where I’d expect the opposite call to be the right one instead.
What’s actually there
The component is Lightbox.astro, mounted once in BaseLayout: one instance for the whole site, not one per image. Any element carrying a data-lightbox-trigger attribute with an <img> inside it becomes a trigger: a single delegated click listener on document checks e.target.closest("[data-lightbox-trigger]"), pulls the image out of it, and opens that image in the shared viewer. ImagePlaceholder.astro (the component every diagram and screenshot in this log renders through) is one of the places that wires up the trigger; it wraps its image in a <button data-lightbox-trigger data-lightbox-caption={caption}> and lets the global lightbox pick it up on click.
Inside the viewer itself, the state is genuinely small: a scale, an x/y pan offset, and a Map<number, {x, y}> of active pointers. Every gesture is built on the same three Pointer Events (pointerdown, pointermove, pointerup/pointercancel), because those events already unify mouse, touch, and pen input under one API, which is exactly the unification a hand-rolled implementation needs to avoid writing separate mouse and touch code paths. One pointer down while zoomed starts a pan, tracked as an offset from the pointer’s start position. Two pointers down starts a pinch, tracked by the distance between them relative to where the pinch began, with the zoom anchored at the midpoint between the two fingers so the image zooms toward where you’re pinching rather than snapping to a fixed center. A wheel listener handles desktop scroll-to-zoom, anchored the same way at the cursor position. A dblclick listener toggles between 1x and a fixed 2.5x zoom. A small didDrag flag distinguishes an actual pan gesture from a tap, so tapping the backdrop closes the viewer but dragging on the image doesn’t accidentally close it mid-pan.
None of that is exotic code. It’s maybe a couple hundred lines total, including the zoom-in/zoom-out buttons, the close button, the caption rendering, and the escape-key and click-outside handling. It’s also code I understand completely, because I wrote every line of it against a single well-known API rather than against a library’s abstraction over that API.
What a library would have bought
I looked at existing lightbox and zoom libraries before writing this, and I want to be fair to what they offer, because it’s real. A mature image-viewer library gets you gesture handling that’s already been exercised against a much wider range of devices than my own testing covers: edge cases in how different browsers report pointer events, momentum/inertia scrolling that feels natural, accessibility patterns that have been refined against real screen-reader usage, keyboard navigation between a whole gallery of images, thumbnail strips, slideshow modes, lazy-loading integration, and a community that finds and fixes the bugs I’d otherwise have to find myself. Pulling in a well-maintained library means someone else already hit the pinch-zoom bug on some specific Android WebView version, and already fixed it.
That’s a genuine trade-off, not a strawman. Writing your own version of anything means you inherit its bugs alone, and a widely used library has had orders of magnitude more usage hours finding its rough edges than a single-site implementation ever will.
Why building it directly won this time
The interaction surface this site actually needs is narrow: open an image full-screen, zoom, pan, close. That’s it. There’s no gallery mode, no slideshow, no thumbnail strip, no need to swipe between images in sequence: each diagram or screenshot is opened and closed on its own. Once the requirement is that small and that well-understood, the calculus around a general-purpose library changes. A library built to cover galleries, slideshows, multiple image formats, video, and a dozen configuration options is solving a much bigger problem than the one this site has, and every one of those extra capabilities is code the browser has to parse and run, and API surface I’d have to learn, even for the fraction of it I’d use.
Three things tipped it toward building directly:
No added dependency for a single, well-scoped interaction. The whole point of ImagePlaceholder and Lightbox here is that they’re small, self-contained pieces, in keeping with how routes and components stay small and single-purpose across this codebase, rather than pulling in a runtime dependency to solve a problem the platform already has most of the primitives for. Pointer Events already unify mouse/touch/pen. getBoundingClientRect, CSS transforms, and a handful of clamped math already give you zoom-anchored-at-a-point and bounded panning. There isn’t a missing browser capability here that only a library provides, just glue code, and the glue code is small enough to own directly.
Exact control over the one interaction pattern that matters. Because I wrote the pan/zoom math myself, I know exactly why zooming anchors at the cursor or pinch midpoint instead of the image center, exactly why clampPan bounds the pan offset to the image’s actual scaled dimensions instead of letting you drag it off-screen, and exactly why the close-on-backdrop-click handler checks a drag flag first. Every one of those is a small decision, and every one of those decisions is easy to change because there’s no library default I’d be overriding or working around to get there.
No fighting a general-purpose library’s assumptions. This is the failure mode I was actively avoiding. General-purpose UI libraries make choices for the common case that don’t always fit a specific one: a fixed set of breakpoints, a particular DOM structure the library expects to own, an animation timing you have to override with your own CSS specificity fight, an event model that doesn’t quite expose the hook you need for the one behavior you care about. None of that is a knock on those libraries; it’s the nature of building something general enough for many callers. But it means adopting one often trades “write the interaction yourself” for “learn the library’s model, then bend it toward the one thing you need,” and for an interaction this narrow, that second cost isn’t obviously smaller than the first.
Where this call would flip
I don’t think “build it yourself” is the right default for UI interactions generally, and I’d be misrepresenting this decision if I framed it that way. If this site needed a real gallery experience (swiping through a sequence of images, thumbnail navigation, mixed image/video content, or the kind of polish that comes from thousands of hours of cross-device testing on a consumer-facing product), I’d reach for an established library without much hesitation, because at that point the problem is genuinely bigger than “one well-understood interaction,” and the maintenance and edge-case burden of owning all of that code directly stops being worth it. The reason this specific call went the other way is that the requirement stayed small enough, for long enough, that I could describe the whole interaction surface in a single paragraph, and once a requirement is that bounded, owning the code directly costs less than learning and fighting someone else’s abstraction over it.
What I gave up by not testing it the way a library has been tested
I want to be honest about the part of this trade that’s easy to gloss over: my own testing surface is nowhere close to what a widely used library has behind it. I’ve verified this works with a mouse, with trackpad pinch gestures, and with touch on the couple of phones and tablets I actually own. I have not verified it against the long tail of older Android WebViews, unusual stylus input, or every combination of browser and OS a library with thousands of users would have already had bug reports filed against. If there’s a device out there where pointercancel fires at a slightly different moment than the ones I tested against, or where a dblclick doesn’t compose cleanly with a preceding pointer sequence, I probably won’t find out from a bug report. I’ll find out, if I find out at all, by noticing it myself on a device I happen to be using. That’s a real cost of owning this directly, and it’s the cost I’d point to first if asked why this isn’t automatically the right call for every interaction on the site.
The mitigating factor is that the failure mode here is bounded and non-critical. If the zoom gesture behaves slightly wrong on some uncommon device, the worst outcome is that a reader can’t zoom into a diagram as smoothly as intended: the image itself is still visible, the caption is still there, the rest of the page still works. It’s not an interaction gating access to core content or a checkout flow, where an edge-case bug has a real cost to someone. That’s part of why the calculus favored building it: not just that the interaction surface is small, but that the blast radius of getting an edge case wrong is small too.
Keeping it a single instance instead of one per image
One structural choice worth calling out separately from the zoom math itself: there’s exactly one Lightbox mounted in BaseLayout, not one instantiated per image on a page. Every data-lightbox-trigger element on the page, regardless of how many diagrams or screenshots that particular post has, shares the same viewer, the same state variables, the same event listeners. A post with a dozen diagrams doesn’t mean a dozen sets of pointer-event listeners fighting over gesture recognition; it means a dozen triggers, each of which populates the same single viewer with a different image and caption when clicked. That’s not something a library necessarily gets wrong, but it’s the kind of assumption that’s easy to get wrong by accident when reaching for a component-per-instance mental model, and it’s the kind of thing that’s trivial to get right when you’re the one deciding how the piece is structured from the start.