Grommet Image Gallery

Build an accessible image gallery using Grommet
By Dan Polant | 11/20/2021
Article

When you have a lot of images that you want your visitors to see, an image gallery is a great tool. I use galleries a lot on "Place" pages on this site. For example:

Not that many design systems have an image gallery component though. Material UI did not - so for https://gatsbybricks.com/, I used the React Image Lightbox package. This package worked well and had good accessibility, but it was a little bit conflict-y with the design system.

For this website, I decided to see if I could build an image gallery UI using just the building blocks provided by the design system in use (Grommet).

It wasn't as hard as you might think, but not as easy as I hoped. I put it together in about 100 lines of React.

The following Grommet concepts played a part:

  • Layer: Layer is Grommet's way of doing modal interactions. It gives you concepts like locking out the background, background shading and dismissability.
  • Box: Boxes are Grommet's abstraction on top of divs. You use them for lots of things.
  • Stack: Stacks are Grommet's solution to absolute positioning needs. Whatever you put in a Stack component gets layered on top of its siblings. You can determine at the stack level how each of the children align (e.g. top right, top left, etc). This helps us put the "close" button on top of the image.
  • Icons: Grommet icons are great. We use them for the "close" button.
  • Keyboard: The Keyboard component lets us do arrow-navigation between expanded images, as well as escape-to-close.

Gotchas

The only difficult part about this was getting the "lightbox" interaction to load neatly. I had to fiddle a bit but the native onLoad image property does solve this. The trick is:

  • Put an onload handler on the image that sets a state variable
  • Only show elements like caption, close button etc, if the state variable is set.

Conclusion

Here is the code:

import React, { useState } from "react";
import PropTypes from "prop-types";
import { Box, Layer, Image, Grid, Text, Stack, Keyboard } from "grommet";
import { Close } from "grommet-icons";
import SandboxComponent from "./SandboxComponent";

/**
 * Intermal component: A thumbnail for one image in the gallery.
 */
const Thumbnail = ({ image }) => {
  return <Image fit="cover" src={image.urls.thumbnail} />;
};

/**
 * Internal component: the stacked large image, caption and close button.
 */
const ImageWithCaption = ({ image: { id, urls, caption }, closeHandler }) => {
  const [imageLoaded, setImageLoaded] = useState(false);

  const imageUrl = urls.full;

  return (
    <Box flex={true} justify="center">
      <Stack anchor="top-right">
        <Box width="large">
          <Image
            fit="cover"
            fill={true}
            src={imageUrl}
            style={!imageLoaded ? { display: "none" } : null}
            onLoad={() => setImageLoaded(true)}
          />
          {imageLoaded && (
            <Box pad="medium">
              <Text>{caption}</Text>
            </Box>
          )}
        </Box>
        {imageLoaded && (
          <Box
            margin="large"
            background={{ color: "dark-1" }}
            opacity="medium"
            pad="small"
          >
            <Close size="large" onClick={closeHandler} cursor="pointer" />
          </Box>
        )}
      </Stack>
    </Box>
  );
};

/**
 * Exported component: the image gallery.
 */
const ImageGallery = ({ images }) => {
  const [activeImageId, setActiveImageId] = useState(null);

  /**
   * Determines whether the current image is active.
   *
   * @param object image
   *   A Sanity image object.
   *
   * @returns boolean
   *   Whether the current image is active.
   */
  const imageIsActive = (image) => {
    return activeImageId === image.id && activeImageId !== null;
  };

  /**
   * Moves to the next or previous image.
   *
   * @param String direction
   *   Either "next" or "prev"
   * @param object currentImage
   *   The current Sanity image object that we are navigating from.
   */
  const navigate = (direction, currentImage) => {
    // Find next image
    const currentIndex = images.findIndex(
      (image) => image.id === currentImage.id
    );

    const targetImage =
      images[direction === "next" ? currentIndex + 1 : currentIndex - 1];

    if (targetImage) {
      setActiveImageId(targetImage.id);
    }
  };

  return (
    <Grid
      gap="small"
      columns={{ count: 3, size: "xsmall" }}
      margin={{ vertical: "medium" }}
    >
      {images.map((image) => (
        <div key={image.id}>
          {imageIsActive(image) && (
            <Layer
              onClickOutside={() => setActiveImageId(null)}
              animate={false}
            >
              <Keyboard
                onRight={() => navigate("next", image)}
                onLeft={() => navigate("prev", image)}
                onEsc={() => setActiveImageId(null)}
                target="document"
              >
                <ImageWithCaption
                  image={image}
                  closeHandler={() => setActiveImageId(null)}
                />
              </Keyboard>
            </Layer>
          )}
          <Box onClick={() => setActiveImageId(image.id)}>
            <Thumbnail image={image} />
          </Box>
        </div>
      ))}
    </Grid>
  );
};

ImageGallery.propTypes = {
  images: PropTypes.arrayOf(
    PropTypes.shape({
      id: PropTypes.String,
      caption: PropTypes.String,
      urls: PropTypes.shape({
        full: PropTypes.String,
        thumbnail: PropTypes.String
      })
    })
  )
};

const images = [
  {
    id: "1",
    caption: "First image",
    urls: {
      thumbnail: "https://via.placeholder.com/200",
      full: "https://via.placeholder.com/1600x900"
    }
  },
  {
    id: "2",
    caption: "Second image",
    urls: {
      thumbnail: "https://via.placeholder.com/200",
      full: "https://via.placeholder.com/1600x900"
    }
  },
  {
    id: "3",
    caption: "Third image",
    urls: {
      thumbnail: "https://via.placeholder.com/200",
      full: "https://via.placeholder.com/1600x900"
    }
  },
  {
    id: "4",
    caption: "Fourth image",
    urls: {
      thumbnail: "https://via.placeholder.com/200",
      full: "https://via.placeholder.com/1600x900"
    }
  }
];

export default () => (
  <SandboxComponent>
    <ImageGallery images={images} />
  </SandboxComponent>
);

I think this gallery is a pretty good example of how you can use Grommet to make cool interactions, the way you want.

Next I would like to

  • Add the option to have a "main" image that displays full width and large.
  • Review accessibility related properties like aria, and make sure the galleries implement what is needed.
Powered by Gatsby and Sanity. Copyright 2021.