Modals in Gatsby

Table of contents

Imagine that you want to build an Instagram-like app in Gatsby. You display a grid of images on the homepage, and when you click on an image, you render it inside a modal. That modal has a full-size version of the image and some extra information. The information may be the text of the post or counts for likes and comments. If you are a visual person, this is what the finished app looks like.

The modal is an important part of the application, so you want it to satisfy the following requirements:

  • The modal should be accessible and somewhat pretty. A popular library that can help you with that is react-modal.
  • You also want to have good SEO. To achieve that, you want to display the modal on a different page. By rendering the modal on a different page, Google is now able to index the extra post information. Another benefit is that you can share the picture with a link. Finally, you can use the back button to close the modal and return to the homepage. The last is something the users expect. If you don’t do that, you risk your users leaving the page while trying to close the modal (lol).

So how can you implement that modal in Gatsby?

Create the pages

First, you have to create the pages from your data. In this example (repo link), I’m using the gatsby-source-instagram plugin as my data source. I’m querying the last 12 posts from alicexz’s Instagram account. I’m also using a theme that setups theme-ui in Gatsby and exposes some components. This is what my gatsby-config.js looks like:

gatsby-config.js
module.exports = {
  siteMetadata: {
    title: "Alice X. Zhang",
  },
  plugins: [
    "@affectionatedoor/gatsby-theme-ui",
    {
      resolve: `gatsby-source-instagram`,
      options: {
        username: `alicexz`,
      },
    },
  ],
};

I create the pages with the createPages API from Gatsby Node:

gatsby-node.js
exports.createPages = async ({ graphql, actions: { createPage } }) => {
  const result = await graphql(`
    query {
      allInstaNode {
        nodes {
          id
        }
      }
    }
  `);

  if (result.errors) {
    console.log(result.errors);
    return;
  }

  const postTemplate = require.resolve(
    "./src/templates/InstaModalPost/index.jsx"
  );
  result.data.allInstaNode.nodes.forEach((post) => {
    createPage({
      path: post.id,
      component: postTemplate,
      context: {
        id: post.id,
      },
    });
  });
};

Now that we have the pages, the next thing you have to do is to render the modal.

Render the modal

A good place to render the modal is in the post template. But now the question is, how can you render the index page that’s sitting behind the modal?

If you take a look at gatsbygram (an example app that literally does the same thing we do here), you can see that it uses a PageRenderer component to render the background page. At the time of writing, PageRenderer is an undocumented component. This means that is not supposed to be used that much (?). You can see that the only prop that’s being passed to the PageRenderer component is the location. This will be useful later.

You’re now going to do something similar in the post template file. In gatsbygram, this is done in a Layout component, but I find it easier to do in the template file. The previous link has the completed code. If you want to follow along, better paste the code below for now:

src/templates/InstaModalPost/index.jsx
/** @jsx jsx */
import { useState } from "react";
import PropTypes from "prop-types";
import { jsx, Styled } from "theme-ui";
import { graphql, navigate, PageRenderer } from "gatsby";
import Img from "gatsby-image";
import Modal from "react-modal";
import Link from "@affectionatedoor/gatsby-theme-ui/src/components/Link";

import { Times, Heart, Comment } from "../../components/Icons";

import "./style.css";

Modal.setAppElement(`#___gatsby`);

const modalStyles = {
  overlay: {
    backgroundColor: "rgba(0, 0, 0, 0.58)",
  },
  content: {
    position: "relative",
    top: "auto",
    left: "auto",
    right: "auto",
    bottom: "auto",
    maxWidth: "960px",
    margin: "32px auto",
    padding: 0,
    border: 0,
  },
};

const InstaPostTemplate = ({
  data: {
    instaNode: {
      caption,
      comments,
      id,
      likes,
      localFile: {
        childImageSharp: { fluid },
      },
    },
  },
}) => {
  
  const [modalOpen, setModalOpen] = useState(true);
  const modalCloseTimeout = 300;
  const closeModal = () => {
    setModalOpen(false);
    setTimeout(() => navigate(`/`), modalCloseTimeout);
  };
  return (
    <div>
      <PageRenderer key={"/"} location={{ pathname: "/" }} />
      <Modal
        isOpen={modalOpen}
        onRequestClose={closeModal}
        style={modalStyles}
        contentLabel="Modal"
        closeTimeoutMS={modalCloseTimeout}
      >
        <Styled.root>
          <div
            sx={{
              bg: "background",
              display: "flex",
              flexDirection: "column",
              position: "relative",
            }}
          >
            <Link
              to="/"
              aria-label="close modal"
              sx={{
                width: "50px",
                height: "50px",
                position: "absolute",
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                m: 3,
                right: 0,
                top: 0,
                zIndex: 1,
                color: "white",
                borderRadius: "50%",
                ":hover": {
                  color: "white",
                  bg: "#fbf9f926",
                },
              }}
              onClick={(e) => {
                e.preventDefault();
                closeModal();
              }}
            >
              <Times sx={{ fontSize: 4 }} />
            </Link>
            <Img title={id} fluid={fluid} />
            <div sx={{ mt: 3, px: [3, 4] }}>
              <small sx={{ mr: 3 }}>
                <Heart sx={{ color: "secondary", fontSize: 3 }} />{" "}
                {likes}
              </small>
              <small sx={{ mr: 3 }}>
                <Comment sx={{ color: "secondary", fontSize: 3 }} />{" "}
                {comments}
              </small>
            </div>
            <p sx={{ my: 4, px: [3, 4] }}>{caption}</p>
          </div>
        </Styled.root>
      </Modal>
    </div>
  );
};

InstaPostTemplate.propTypes = {
  data: PropTypes.shape({
    instaNode: PropTypes.shape({
      caption: PropTypes.string.isRequired,
      comments: PropTypes.number.isRequired,
      id: PropTypes.string.isRequired,
      likes: PropTypes.number.isRequired,
      localFile: PropTypes.shape({
        childImageSharp: PropTypes.shape({
          fluid: PropTypes.object.isRequired,
        }).isRequired,
      }).isRequired,
    }).isRequired,
  }).isRequired,
};

export default InstaPostTemplate;

export const query = graphql`
  query InstaPostModal($id: String) {
    instaNode(id: { eq: $id }) {
      likes
      id
      localFile {
        childImageSharp {
          fluid(maxWidth: 1200) {
            ...GatsbyImageSharpFluid
          }
        }
      }
      comments
      caption
    }
  }
`;
src/templates/InstaModalPost/style.css
/* react-modal transitions */
.ReactModal__Overlay {
  overflow: auto;
  opacity: 0;
  transform: translateY(-10%);
  transition: opacity 0.3s cubic-bezier(0.445, 0.05, 0.55, 0.95), transform
      0.3s cubic-bezier(0.445, 0.05, 0.55, 0.95);
}
.ReactModal__Overlay--after-open {
  opacity: 1;
  transform: translateY(0);
}

.ReactModal__Overlay--before-close {
  opacity: 0;
  transform: translateY(-10%);
}

If we run the code in development (after you create the index page, the other 2 components and shadow the layout from the theme), everything works just fine:

everything is fine in development
Artwork by Alice X. Zhang

But if you try to build the project, you see that the background page is missing:

background image missing
Artwork by Alice X. Zhang

PageRenderer problem

This happens because the PageRenderer behaves differently in production. If you take a look at the development version of page renderer, you will see that the component downloads the page data using the location prop. This does not happen in production. Following the changes in the production file, I suspect that this happened recently as a part of some performance optimizations.

As a result, if you want to use the PageRenderer component to display the index page behind the modal, you’ll have to download the page data yourself. In the live gatsbygram site, the modal works fine without downloading the page data. But that happens because the deployed version of the site probably uses an earlier version of Gatsby.

But how to get the page data?

Getting the page data

All the browser API’s that you can implement in your gatsby-browser.js file, take the loadPage method as a parameter. This method accepts a page path and returns the data for that page. This is exactly what we want. So the plan now is to implement an appropriate browser API where:

  • We download the index page data,
  • and store them somewhere to use later in the PageRenderer component.

A good candidate for that is to implement the onPrefetchPathname method. This method is called whenever Gatsby prefetches the data for a page at idle time. This happens when it encounters a Link for that page. For example, if you have in the index page a Link to page-2, Gatsby will download the data for page-2 at idle time. This way, the navigation between pages feels instant. For this to work, you’ll have to add a link to the homepage in the template. In our case, the link is already in the template. It’s the button that closes the modal. This is the implementation of the onPrefetchPathname method:

gatsby-browser.js
import "@fortawesome/fontawesome-svg-core/styles.css";

// This only works if in the modal page you add a
// Link to the homepage.
export const onPrefetchPathname = ({ loadPage }) => {
  if (window.indexPageData === undefined) {
    loadPage("/").then((result) => {
      window.indexPageData = result;
      
      
      
      if (window.setIndexPageData) window.setIndexPageData();
    });
  }
};

// https://github.com/gatsbyjs/gatsby/issues/7454#issuecomment-425403812
export const shouldUpdateScroll = ({
  routerProps: { location },
  getSavedScrollPosition,
}) => {
  return getSavedScrollPosition(location);
};

You can see that I’m saving the page data in the window. I’m also calling the setIndexPageData method, if it’s truthy, that causes a re-render by setting the state in the template. If we load the modal page directly, the initial result is null for some reason. This is what you have to add in the template file:

src/templates/InstaModalPost/index.jsx
import { useState, useEffect } from "react";

// skipping imports and modal styles...

const InstaPostTemplate = (
  {
    // skipping props..
  }
) => {
  
  const building = typeof window === "undefined";
  const [indexPageData, setIndexPageData] = useState(
    !building && window.indexPageData
  );
  useEffect(() => {
    window.setIndexPageData = () => {
      setIndexPageData(window.indexPageData);
    };
  }, []);

  // Modal stuff.
  const [modalOpen, setModalOpen] = useState(true);
  const modalCloseTimeout = 300;
  const closeModal = () => {
    setModalOpen(false);
    setTimeout(() => navigate(`/`), modalCloseTimeout);
  };
  return (
    <div>
      <PageRenderer
        key={"/"}
        location={{ pathname: "/" }}
        pageResources={indexPageData}
        path="/"
      />
      {/* skipping... */}
    </div>
  );
};

One thing to note about prefetching data is that it may not always work. For example, the mobile Chrome offers the “Lite mode” where the browser refuses to download data for pages the user may never visit. In this case, we are not able to render the background page by implementing the onPrefetchPathname method.

Scroll position caveat

You may also notice in the previous gatsby-browser.js snippet, that I’m implementing the shouldUpdateScroll API. If you don’t do that, and you return to the index page after closing the modal, you’ll lose your scroll position in the index page, and you’ll start at the top of the page. This is obviously really annoying, and the implementation above fixes it.

Further improvement

The modal in this example could be further improved. For example, we could add next and previous buttons to cycle through the images when the modal is open. Gatsbygram does this but also adds keyboard navigation with the mousetrap library. There are some accessibility issues as well. For example, the modal pages do not have a Heading 1 element because Instagram API does not have a title for posts. Finally, because we’re downloading data for many pages (when the modal opens), the performance may suffer as well.

Other things to read

Popular

Previous/Next