Eiko Wagenknecht
Software Developer, Freelancer & Founder

Build your own lightbox component for Astro

(updated ) Eiko Wagenknecht

After setting up my new blog with Astro, I missed an automatic lightbox for images, a feature I enjoyed from WordPress. I wanted a simple, seamless solution without much custom code.

Creating a modal dialog from scratch isn’t difficult, but I prefer using a well-tested library with built-in accessibility features, which ensure the content is usable by everyone. This is why I chose Radix UI primitives, a collection of low-level headless UI components for React.

In this article, I will guide you through building a robust and accessible lightbox for Astro using React, Tailwind CSS, and the Radix UI Dialog component. By the end of this tutorial, you’ll have a fully functional lightbox component that you can easily integrate into your Astro-based blog or website.

Table of Contents

Prerequisites

Make sure you have the following installed and set up:

Install the Radix UI Dialog component as well, if you haven’t done so already:

npm install @radix-ui/react-dialog

The Lightbox Component

Let’s start building the lightbox component. There are 3 parts to it:

  1. An Astro component that will be embedded in your MDX content.
  2. The React component that uses the Radix UI Dialog component.
  3. A close icon component.

I prefer separate components for icons to allow reuse, hence the separate close icon component. And since Astro components can’t be imported into React components directly, there’s a wrapper component that uses the React component and passes the Astro icon component into it.

If you prefer, you can inline your icon in the JSX file and get away with just the jsx file.

Let’s see what the components do.

/src/components/imageWithModal.astro

This wrapper bundles together the Astro icon component and the React modal component. The icon component is passed as a named Astro slot to the React component and can be accessed there with the closeIcon prop.

---
import JsxImageWithModal from "./ImageWithModal.jsx";
import X from "./icons/X.astro";

export type Props = {
  src: string;
  alt: string;
};

const { src, alt } = Astro.props;
---

<JsxImageWithModal client:visible src={src} alt={alt}>
  <X slot="closeIcon" />
</JsxImageWithModal>

/src/components/ImageWithModal.jsx

Here’s the React component that uses Radix UI Dialog to create a modal dialog with an image. Radix UI is headless, so Tailwind classes are used for styling. Features include:

import * as Dialog from "@radix-ui/react-dialog";
import { Children, useState } from "react";

export default function ImageWithModal(props) {
  return (
    <div>
      <Dialog.Root>
        <Dialog.Trigger asChild>
          <div>
            <figure>
              <img
                src={props.src}
                alt={props.alt}
                className="m-auto max-h-screen/50 cursor-pointer"
              />
              {props.title && (
                <figcaption className="text-center text-sm text-text">
                  {props.title}
                </figcaption>
              )}
            </figure>
          </div>
        </Dialog.Trigger>
        <Dialog.Portal>
          <Dialog.Overlay className="fixed inset-0 bg-bg opacity-80" />
          <Dialog.Content className="fixed left-1/2 top-1/2 flex max-h-screen/90 w-screen/90 -translate-x-1/2 -translate-y-1/2 flex-col items-center rounded-md border bg-bg-offset p-4">
            <div className="relative w-full">
              <Dialog.Title className="mb-2 max-h-6 w-full overflow-x-auto overflow-y-hidden whitespace-nowrap font-medium text-text no-scrollbar">
                <div className="inline-block pr-8">
                  {props.title ? props.title : props.alt}
                </div>
              </Dialog.Title>
            </div>
            <img
              src={props.src}
              alt={props.alt}
              className="max-h-[calc(90vh-4rem)] max-w-full"
            />
            <Dialog.Close asChild>
              <button
                type="button"
                className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full bg-bg-offset text-interaction hover:text-interaction-highlight focus:outline-none"
                aria-label="Close"
              >
                {props.closeIcon}
              </button>
            </Dialog.Close>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    </div>
  );
}

Some of the Tailwind CSS classes used in the React component are custom classes that I defined in my tailwind.config.js file:

/** @type {import('tailwindcss').Config} */
export default {
  theme: {
    extend: {
      colors: {
        text: "var(--color-text-default)",
        bg: "var(--color-background-default)",
        interaction: "var(--color-text-interaction)",
        "interaction-highlight": "var(--color-text-interaction-highlight)",
        subtle: "var(--color-text-subtle)",
        "bg-offset": "var(--color-background-offset)",
      },
      height: {
        "screen/90": "90vh",
        "screen/50": "50vh",
      },
      width: {
        "screen/90": "90vw",
        "screen/50": "50vw",
      },
      maxHeight: {
        "screen/90": "90vh",
        "screen/50": "50vh",
      },
      maxWidth: {
        "screen/90": "90vw",
        "screen/50": "50vw",
      },
    },
  },
  plugins: [
    ({ addUtilities }) => {
      addUtilities({
        ".no-scrollbar": {
          "-ms-overflow-style": "none",
          "scrollbar-width": "none",
          "&::-webkit-scrollbar": {
            display: "none",
          },
        },
      });
    },
  ],
};

Lastly, the close icon component is a simple SVG icon from the amazing Lucide icons project that can be styled from the outside (by passing classes).

/src/components/icons/X.astro:

---
const { class: className } = Astro.props;
---

<svg
  xmlns="http://www.w3.org/2000/svg"
  width="24"
  height="24"
  viewBox="0 0 24 24"
  fill="none"
  stroke="currentColor"
  stroke-width="2"
  stroke-linecap="round"
  stroke-linejoin="round"
  class:list={["lucide lucide-x", className]}
  ><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg
>

Update: With Astro 5 and the new SVG flag enabled, you can also use a SVG directly instead:

import X from "@/components/icons/X.svg";
<svg
  xmlns="http://www.w3.org/2000/svg"
  width="24"
  height="24"
  viewBox="0 0 24 24"
  fill="none"
  stroke="currentColor"
  stroke-width="2"
  stroke-linecap="round"
  stroke-linejoin="round"
  ><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg
>

Usage

To use this, you need to write your content in MDX, because Astro does not support components in Markdown files. With everything set up, embedding an image with a lightbox is as simple as this:

<ImageWithModal
  src="/assets/images/placeholder-150x150.svg"
  alt="Placeholder 150x150px"
  title="What a nice placeholder!"
/>

And this is what it looks like when rendered (showing a placeholder image from placehold.co):

With an image that’s much larger than the screen height:

And another image with a different aspect ratio:

I hope you found this guide helpful. If you have any questions or suggestions, feel free to reach out to me using the links in the footer.

No Comments? No Problem.

This blog doesn't support comments, but your thoughts and questions are always welcome. Reach out through the contact details in the footer below.