AstroJS Blog Image Preview Component in JavaScript


Hello again! This post is another in the continuing journey with AstroJS and JavaScript. Today we are going to address an issue I have with images in the blog posts.

The Problem

Images are sometimes pretty small and that can make it difficult when the image is detailed or has small text.

Proposed Solution

Use JavaScript to add a click handler to all the images in a blog post. When the image is clicked a larger image view will be shown in a fixed position overlay.

Example

This is an image card I made for a previous post. If you click on it you will see how it works.

preview image for my blog post called "Copy Code Button"

Acceptance Criteria

The Business Case

Given a user is viewing a blog post, when they click on an image, a fullscreen fixed position overlay will be shown with a larger version of the image. It will contain a close button that will be shown in the top right corner. It will dismiss the preview when clicked. Clicking on the image will also dismiss the preview.

Developer Concerns

  1. Reusable
  2. Play nice with AstroJS
  3. Clean up after itself

To accomplish this I am going to create a component. The idea of an AstroJS component is pretty much the same as all the other frameworks and libraries out there i.e. React, Angular, Ember, Vue etc. The implementations can differ but the concepts are the same and it’s all just JavaScript.

The Blog Image Enhancer AstroJS Component

Here is the entire component. I called it BlogImageEnhancer but you can do what you like. We will break down the component in a sec but you can see that it isn’t big. It’s a div with some styles and a small script section.

<div
  class="image-preview fixed top-0 left-0 right-0 bottom-0 overflow-clip bg-black/70 hidden p-8"
>
</div>
<script is:inline>
  let container = document.querySelector(".image-preview");
  let prose = document.querySelector(".prose");
  let images = prose.querySelectorAll("img");

  const closePreview = (e) => {
    e.preventDefault();
    container.replaceChildren();
    container.classList.add("hidden");
  };

  images.forEach((image) => {
    image.onclick = (e) => {
      e.preventDefault();
      let preview = document.createElement("img");
      preview.classList.add("h-full", "w-full", "object-contain");
      preview.alt = image.alt;
      preview.src = image.src;
      preview.onclick = closePreview;

      let closeButton = document.createElement("div");
      closeButton.classList.add("text-white", "absolute", "right-2", "top-2");
      closeButton.textContent = "X";
      closeButton.onclick = closePreview;

      container.appendChild(closeButton);
      container.appendChild(preview);
      container.classList.remove("hidden");
    };
  });
</script>

HTML

The markup here is minimal. We have a div with some styles. The classes are tailwind and result in a pseudo-fullscreen fixed position overlay with a black background that has a 70% opacity. As well as providing a nice backdrop for the image this will be a container for us to use in the JavaScript so we can build the larger image and the close button.

JavaScript

There isn’t anything going on here that isn’t standard JavaScript except <script is:inline>. This is an AstroJS feature that tells the compiler to inline the script. I chose to do it this way so that all functionality would be contained within the one component file.

Moving on… we start off grabbing handles to the container and the image elements. Notice how we use the AstroJS .prose class as the ‘root’ element. This makes sure we only look for images in the blog content and nowhere else. I forgot to do this when developing and the main logo and all other images on the blog pages would zoom, oops!

  let container = document.querySelector(".image-preview");
  let prose = document.querySelector('.prose');
  let images = prose.querySelectorAll("img");

Next is a function called closePreview that does what it sounds like. It will be responsible for dismissing the preview and making sure that the larger image view is removed from the DOM. Remember to always clean up after yourself.

  const closePreview = (e) => {
    e.preventDefault();
    container.replaceChildren();
    container.classList.add("hidden");
  };

First, we preventDefault(). Standard JavaScript stuff here. Then container.replaceChildren() is used to remove all children from the image container. This is a simple and fast way to deal with removing all child nodes. There are faster ways but that feels like “bikeshedding” and I am willing to bet it would take a metric ton of images to cause an issue.

Finally, we hide the image container.

  container.classList.add("hidden");

Now comes the meat of the script. In this loop we are going through all of the img tags we gathered earlier. For each image, we create an onclick handler. In this handler we create a new img and set the alt and src to the same thing as the original. We also set some styles to make it as big as possible without changing the aspect ratio. The last piece is to set the closePreview function from earlier to the click handler on this new image. This is make it so the preview will be dismissed with a click anywhere on the image.

  let preview = document.createElement("img");
  preview.classList.add("h-full", "w-full", "object-contain");
  preview.alt = image.alt;
  preview.src = image.src;
  preview.onclick = closePreview;

Now we build the close button. Nothing crazy here, we set styles, and the text content, and the click handler.

  let closeButton = document.createElement("div");
  closeButton.classList.add("text-white", "absolute", "right-2", "top-2");
  closeButton.textContent = "X";
  closeButton.onclick = closePreview;

We are finished building the UI and now we need to add it to the image container.

  container.appendChild(closeButton);
  container.appendChild(preview);

Then last thing we need to do is to show the image container. We do this by removing the class .hidden.

  container.classList.remove("hidden");

Our blog image preview component is now complete and the only thing left to do is to use <BlogImageEnhancer /> in our blog post layout. Below is my blog layout template at the time of writing.

---
import type { CollectionEntry } from "astro:content";
import BaseHead from "../components/BaseHead.astro";
import FormattedDate from "../components/FormattedDate.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
import BlogComment from "../components/BlogComment.astro";
import CodeCopyButton from "../components/CodeCopyButton.astro";
import BlogImageEnhancer from "../components/BlogImageEnhancer.astro";

type Props = CollectionEntry<"blog">["data"];

const { title, description, pubDate, updatedDate, preview } = Astro.props;
---

<html lang="en">
  <head>
    <BaseHead title={title} description={description} />
  </head>

  <body>
    <Header />
    <main>
      <article>
        <div class="hero-image">
          {preview && <img width={1020} height={510} src={preview} alt="" />}
        </div>
        <div class="prose">
          <div class="title">
            <div class="date">
              <FormattedDate date={pubDate} />
              {
                updatedDate && (
                  <div class="last-updated-on">
                    Last updated on <FormattedDate date={updatedDate} />
                  </div>
                )
              }
            </div>
            <h1>{title}</h1>
            <hr />
          </div>
          <slot />
          <BlogComment />
          <CodeCopyButton />
          <BlogImageEnhancer />
        </div>
      </article>
    </main>
    <Footer />
  </body>
</html>

Feel free to use this and build upon it. I post all my changes to AstroJS on github using the default template.