I was looking at an article about being careful with AI coding today. And while the article was extremely insightful about the dangers of relying on AI coding agents to write all the code for you, another thing that stood out to me was the zoom in effect on the image.

I was curious about how the code worked and began digging in to it. It made use of some Vue.js functionality, but I figured that it was really just some DOM manipulation that I could figure out how to replicate to use as a Hugo shortcode on my blog site.

I started by having AI (ironic, I know, given the nature of the article) examine the effect and explain how it was accomplished. It explained that clicking on the image would trigger a lightbox overlay, a feature of Vue, that injects an IMG tag onto the overlay and does some CSS transition effects to zoom the image up to full size. Then, clicking on the overlay reverses the zoom effect, then removes the overlay from the DOM.

Seems simple enough, but I’m not using Vue. I should be able to replicate that with some simple JavaScript and CSS. Here’s what I came up with:

Hugo Short Code

We’ll start out by setting up our Hugo short code. We’ll follow the source’s pattern and put an IMG tag inside of a FIGURE tag in our HTML. Our image needs a source and alt text

{{ $src := .Get "src" }}
{{ $alt := .Get "alt" | default "" }}
<figure class="zoomable-figure">
  <img
    src="{{ $src }}"
    alt="{{ $alt }}"
    class="zoomable-img"
    aria-haspopup="dialog"
    aria-expanded="false"
    data-state="closed"
  />
</figure>

JavaScript

Next, we’ll need some JavaScript to do our manipulation. We’ll need a couple of functions. One to create the panel and zoom in. The other to reverse that. We’ll also add a function to close the overlay with the escape key on the keyboard.

(function () {
  let backdrop, container, overlayImg, activeThumb;

  function openZoom(thumb) {
    activeThumb = thumb;
    const rect = thumb.getBoundingClientRect();

    // Backdrop
    backdrop = document.createElement('div');
    backdrop.className = 'zoom-backdrop';
    document.body.appendChild(backdrop);

    // Container (click to close)
    container = document.createElement('div');
    container.className = 'zoom-container';
    container.setAttribute('role', 'dialog');
    container.setAttribute('aria-modal', 'true');
    document.body.appendChild(container);

    // Overlay image
    overlayImg = document.createElement('img');
    overlayImg.src = thumb.src;
    overlayImg.alt = thumb.alt;
    overlayImg.className = 'zoom-overlay-img';
    container.appendChild(overlayImg);

    // Calculate origin transform so image starts at the thumbnail's position
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const scaleX = rect.width / vw;
    const scaleY = rect.height / vh;
    const tx = rect.left + rect.width / 2 - vw / 2;
    const ty = rect.top + rect.height / 2 - vh / 2;

    // Start position: small + positioned over the thumbnail
    overlayImg.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scaleX}, ${scaleY})`;
    overlayImg.style.opacity = '0';

    // Update thumb state
    thumb.setAttribute('aria-expanded', 'true');
    thumb.setAttribute('data-state', 'open');
    thumb.classList.remove('cursor-zoom-in');

    // Trigger animation on next frame
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        backdrop.classList.add('zoom-backdrop--visible');
        overlayImg.style.transform = 'translate3d(0,0,0) scale(1)';
        overlayImg.style.opacity = '1';
      });
    });

    container.addEventListener('click', closeZoom);
    document.addEventListener('keydown', onKeyDown);
  }

  function closeZoom() {
    if (!activeThumb || !overlayImg) return;

    const rect = activeThumb.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const scaleX = rect.width / vw;
    const scaleY = rect.height / vh;
    const tx = rect.left + rect.width / 2 - vw / 2;
    const ty = rect.top + rect.height / 2 - vh / 2;

    // Animate back to thumbnail
    overlayImg.style.transform = `translate3d(${tx}px, ${ty}px, 0) scale(${scaleX}, ${scaleY})`;
    overlayImg.style.opacity = '0';
    backdrop.classList.remove('zoom-backdrop--visible');

    overlayImg.addEventListener('transitionend', () => {
      backdrop.remove();
      container.remove();
      activeThumb.setAttribute('aria-expanded', 'false');
      activeThumb.setAttribute('data-state', 'closed');
      activeThumb.classList.add('cursor-zoom-in');
      activeThumb = null;
    }, { once: true });

    document.removeEventListener('keydown', onKeyDown);
  }

  function onKeyDown(e) {
    if (e.key === 'Escape') closeZoom();
  }

  document.addEventListener('click', function (e) {
    const img = e.target.closest('img[data-state="closed"]');
    if (img && img.closest('.zoomable-figure')) {
      openZoom(img);
    }
  });
})();

And we need to reference the JavaScript file in my header template. You’ll need to update the file reference to wherever you put your file.

{{ $js := resources.Get "js/zoom.js" | minify }}
<script defer src="{{ $js.RelPermalink }}"></script>

CSS

Lastly, we’ll need our CSS classes for the transitions. I suck at CSS so I had Claude create these for me.

.zoomable-img {
  cursor: zoom-in;
  will-change: transform;
}

.zoom-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.75);
  backdrop-filter: blur(4px);
  opacity: 0;
  transition: opacity 0.3s ease;
  z-index: 999;
  will-change: opacity;
}

.zoom-backdrop--visible {
  opacity: 1;
}

.zoom-container {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  cursor: zoom-out;
}

.zoom-overlay-img {
  width: 100vw;
  height: 100vh;
  object-fit: contain;
  transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
              opacity 0.35s ease;
  will-change: transform, opacity;
}

I added it to my existing image CSS file, but if you put this in a new CSS file you’ll also need to make sure to add a reference in your base templates.

Let’s Zoom

Putting it all together. Here’s a couple examples: