With many lightbox gallery solutions on NPM, I ended up writing my own React Custom Lightbox Gallery Component to meet my specific needs.
I was looking for a “lightbox” type gallery that would allow me to display images (multiple images in the same modal), videos, text, and a mix of that, all within the same modal window. All I could find were galleries that could only display a single image in a modal.
This is a two-part tutorial:
- Create a React Custom Modal
- Create a React Custom Lightbox Gallery (you are here)
[topads][/topads]
React Custom Lightbox Gallery Preview
A little context about this gallery before we start: I created this lightbox gallery for my wife’s website (an artist, an illustrator, and a graphic designer), so some of the variables reflect a “portfolio” context. That being said, let us start.
The following will be the content structure for this tutorial:
- Gallery images/content structure. I’m storing the images data in a JSON file
- A parent container passing the images data to the gallery
- A Gallery component looping through the images data forming the gallery and modal
- Helper Components
Images Content Structure
Note: I’m processing all of my images and videos through Cloudinary, so the URLs here will be relative to my Cloudinary account.
art.json
file:
[ { "thumb": "portfolio/art/baby-watercolor-portrait-andrea-silva-1.jpg", "title": "Aedan's Milk Bath", "blurb": "11x14 on watercolor paper.", "portfolioType": "image", "portfolio": [ "portfolio/art/baby-watercolor-portrait-andrea-silva-1.jpg", "portfolio/art/baby-watercolor-portrait-andrea-silva-2.jpg", "portfolio/art/baby-watercolor-portrait-andrea-silva-3.jpg" ] }, { "thumb": "portfolio/motion-graphics/february-budget-monster-motion-graphics-andrea-silva-1.jpg", "title": "February Budget Monster", "blurb": "To increase an awareness ... to promote giving.", "portfolioType": "video", "portfolio": [ "portfolio/motion-graphics/february-budget-monster-motion-graphics-andrea-silva-1.jpg|portfolio/motion-graphics/february-budget-monster-motion-graphics-andrea-silva.mp4" ] } ]
thumb
: Gallery thumbnail.title
: This is the gallery thumbnail image’salt
&title
attributes and the modal’s heading.blurb
: Whatever text I want to display in the modal. My use case is the image/video description.portfolioType
: I support two portfolio (or lightbox) types. Meaning, when I click on a gallery thumbnail image, the modal will display either an array of images or videos.- As of now, I only support either only images or videos for a particular JSON node, but it wouldn’t be hard to extend it to support mixed media.
portfolio
: Array of images or videos to be displayed in the modal.- Notice the video array item is a little different than the image array item. Here I have two relative URLs separated by a pipe (
|
). The first part will be the video’s poster image and the second the actual video source.
- Notice the video array item is a little different than the image array item. Here I have two relative URLs separated by a pipe (
The Container to the React Lightbox Gallery
art.js
component:
import React from 'react'; import { MainLayout } from '~components/layouts/MainLayout'; import { SecondaryLayout } from '~components/layouts/SecondaryLayout'; import { Title } from '~styles/Title'; // Here we import the Lightbox Gallery Component import { Gallery } from '~components/Gallery/Gallery'; // Here we import the images data content from the previous section import artData from '~content/portfolio/art.json'; const Art = () => { return ( <MainLayout pageTitle="Fine Art" pathName="portfolio/art"> <SecondaryLayout> <Title>Fine Art</Title> <p> As a fine art and portrait artist, my primary medium is watercolor, but I also enjoy painting with oils. My inspiration comes from nature, travel, and the everyday moments that make life special. </p> </SecondaryLayout> {/* The lightbox gallery component acepts only a "data" prop for the images content. */} <Gallery data={artData} /> </MainLayout> ); }; export default Art;
The React Custom Lightbox Gallery Component
As you saw on the preview GIF, the gallery has a masonry layout, for which I implement react-masonry-css package.
Let’s start with a “skeleton” template, Gallery.js
component:
import React, { useState, useReducer } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; // The masonry layout gallery import Masonry from 'react-masonry-css'; // Helper component for my images in Cloudinary import { Image } from '~helpers/Image'; // Utility to pass Cloudinary custom transformations to the above Image component import { transformationsFormat } from '~utils/index'; // SVG magnifier icon at the center of a gallery image component when hovered over. // Refer to the preview GIF import { ZoomIn } from '~svgs/ZoomIn'; // Custom modal component from the first tutorial in the series import { Modal } from '~src/components/Gallery/Modal'; const Gallery = ({ data }) => { return (); }; // The data prop is an array of objects reflecting a JSON node from the // images data structure (first section) Gallery.propTypes = { data: PropTypes.arrayOf( PropTypes.shape({ thumb: PropTypes.string.isRequired, title: PropTypes.string.isRequired, blurb: PropTypes.string, portfolio: PropTypes.arrayOf(PropTypes.string).isRequired, }), ).isRequired, }; export { Gallery };
CSS
Even though the CSS here is specific to this component, some other global styles are being applied.
react-masonry-css.css
. These styles are necessary to implement react-mansory-css
package:
.my-masonry-grid { display: flex; margin-left: 0; /* gutter size offset */ width: auto; } .my-masonry-grid_column { padding-left: 0; /* gutter size */ background-clip: padding-box; }
Now import the previous styles and create a new styled component.
Gallery.js
compnent:
import '~styles/react-masonry-css.css'; // This will be wrapping the individual images const GalleryImageContainer = styled.a` margin-bottom: -0.7rem; display: block; /* Since the SVG icon needs to be contained within the individual image, I need the wrapper to be relative positioned */ position: relative; /* By default, the SVG icon will have an opacity of 0 (not visible) */ &:hover svg { opacity: 1; } /* This gives the individual image a darkening look when hovered over */ &:hover img { filter: brightness(50%); } img { margin-bottom: 0; /* Animation for a smooth filter transition */ transition: filter 0.2s ease-in; padding: 0.7rem; width: 100%; height: 100%; } /* The SVG icon will display at the center of the individual image */ svg { margin: auto; position: absolute; top: 0; left: 0; bottom: 0; right: 0; opacity: 0; /* Animation for a smooth opacity transition */ transition: opacity 0.2s ease-in; } `;
Masonry Layout
I’m only including the component’s return
method below
Gallery.js
component:
import '~styles/react-masonry-css.css'; const GalleryImageContainer = styled.a`...` const Gallery = ({ data }) => { return ( <> <Masonry // Different columns can be specified by passing an object containing the // window widths as keys and the number of columns as their value. // To have a fallback value, use the default key. breakpointCols={{ default: 5, 1186: 4, 910: 3, 700: 2, 500: 1 }} // The below two classes come from the css file we created earlier: // react-masonry-css.css className="my-masonry-grid" columnClassName="my-masonry-grid_column" > {/* We loop thru all the images, creating a link (GalleryImageContainer) for each one. */} {data.map( ({ thumb, title, blurb, portfolioType, portfolio }, index) => ( <GalleryImageContainer key={thumb} href="#" > {/* This is my custom Image component to handle Cloudinary images. All it does is put together the relative image link path, applies the transformations, and returns an img element */} <Image relativePath={thumb} alt={title} title={title} transformations={transformationsFormat('w_300')} /> {/* This is the SVG icon that appears when hovered over the image */} <ZoomIn /> </GalleryImageContainer> ), )} </Masonry> </> ); };
I will go over the Image
component later in the tutorial, but for now, this is how the gallery looks so far:
Lightbox Modal State
When we click on one of the images within the gallery, a lightbox modal opens (as seen in the preview GIF earlier) and we can see the heading, description, and images/videos corresponding to that image. When we click on the left/right arrow button (or press the left/right arrow key), we go to the previous/next image.
All of that data is being saved in the local state and switched accordingly when we change the image. I’m implementing useReducer
hook to manage this local state.
Building on top of the previous code snippet, the updated code will be highlighted.
Gallery.js
component:
// This is what we see in the modal, with the exception of currentIndex, and corresponds // to a single node from the initial images JSON data. // "currentIndex" is used to identify the position in the image data array and calculate // the previous/next position when we change slides const initialModalBodyState = { heading: '', blurb: '', portfolioType: '', portfolio: [], currentIndex: 0, }; // This is the reducer for useReducer hook. Since I don't need the first // parameter, (current state) in the reducer, I replace it with an underscore. // I destructure the second parameter (action) and just straight replace/update // the current state by returning a new object matching the state (initialModalBodyState) const modalBodyReducer = ( _, { heading, blurb, portfolioType, portfolio, currentIndex }, ) => ({ heading, blurb, portfolioType, portfolio, currentIndex, }); // These two constants are being used to move to the previous/next slide. // I'm also exporting them because I use them in the Modal component. // I will point them out later export const FORWARD = 'forward'; export const BACKWARD = 'backward'; const Gallery = ({ data }) => { // useReducer hook declaration. Again, this state is used to display what you see // in the Modal. a.k.a The Modal Body const [modalBodyState, dispatchModalBodyState] = useReducer( modalBodyReducer, initialModalBodyState, ); // We call this method when we click on one of the gallery images to set the state, // or current selected image, passing all the necessary data for the Modal component. const setModalBodyState = ( heading, blurb, portfolioType, portfolio, index, ) => { // This dispatch method sends the new state to the modalBodyReducer method from // useReducer dispatchModalBodyState({ heading, blurb, portfolioType, portfolio, currentIndex: index, }); }; // Once we have the Modal open, we need a way to change the slide. We pass a direction // variable, this would be either the "FORWARD" or "BACKWARD" constant from earlier. const changeSlide = direction => { // We need to know the current image position in the array, so we read the // "currentIndex" from state const currentIndex = modalBodyState.currentIndex; // With the "calculateNextSlide" method we figure out the next image we need to show // in the modal, passing the direction, the current position and the entire array // data. This method returns the next slide node and the "next index" which becomes // the current index const { nextSlide, nextIndex } = calculateNextSlide( direction, currentIndex, data, ); // Finally we set the state with the next, or new current, gallery image dispatchModalBodyState({ heading: nextSlide.title, blurb: nextSlide.blurb, portfolioType: nextSlide.portfolioType, portfolio: nextSlide.portfolio, currentIndex: nextIndex, }); }; return ( <> <Masonry breakpointCols={{ default: 5, 1186: 4, 910: 3, 700: 2, 500: 1 }} className="my-masonry-grid" columnClassName="my-masonry-grid_column" > {data.map( ({ thumb, title, blurb, portfolioType, portfolio }, index) => ( <GalleryImageContainer key={thumb} href="#" // When we click on a gallery image, we call "setModalBodyState" method // we saw earlier to set the state to the selected image onClick={e => setModalBodyState(title, blurb, portfolioType, portfolio, index) } > <Image relativePath={thumb} alt={title} title={title} transformations={transformationsFormat('w_300')} /> <ZoomIn /> </GalleryImageContainer> ), )} </Masonry> </> ); }; // We called this method earlier to calculate the next slide const calculateNextSlide = (direction, currentIndex, data) => { let nextIndex; // Simple switch statement doing the "heavy lifting" to calculate whether we // are requesting the previous or next slide in the image array data switch (direction) { case FORWARD: // Add 1 to the current index nextIndex = currentIndex + 1; // If the next index is greater than the image array data size, then we // want to show the first image, so we assign 0 as the next index. Basically // looping the array forward if (nextIndex > data.length - 1) nextIndex = 0; break; case BACKWARD: // Subtract 1 to current index nextIndex = currentIndex - 1; // If the next index is -1, we want to show the last image in the image // array data so we assign the last array index. Basically looping backwards if (nextIndex === -1) nextIndex = data.length - 1; break; default: nextIndex = 0; break; } // Finally we return the next index and the next slide return { nextIndex, nextSlide: data[nextIndex], }; };
The Modal
I will not go into detail about how the modal component works as I went through it in the first part of this series. If you haven’t read it I would recommend reading that post first: Create a React Custom Modal
Now that we know which image to display in the modal, let’s import the component and implement it.
Updated code is highlighted and some code has been redacted.
Gallery.js
component:
import { Modal } from '~src/components/Gallery/Modal'; const Gallery = ({ data }) => { // We want to save the state of the modal, whether it is opened or closed const [isModalOpen, setIsModalOpen] = useState(false); const [modalBodyState, dispatchModalBodyState] = useReducer( modalBodyReducer, initialModalBodyState, ); // Method to toggle the modal state const toggleModal = () => setIsModalOpen(!isModalOpen); const setModalBodyState = (...) => {...}; const changeSlide = direction => {...}; return ( <> <Masonry breakpointCols={{ default: 5, 1186: 4, 910: 3, 700: 2, 500: 1 }} className="my-masonry-grid" columnClassName="my-masonry-grid_column" > {data.map( ({ thumb, title, blurb, portfolioType, portfolio }, index) => ( <GalleryImageContainer key={thumb} href="#" onClick={e => setModalBodyState(title, blurb, portfolioType, portfolio, index) } > <Image relativePath={thumb} alt={title} title={title} transformations={transformationsFormat('w_300')} /> <ZoomIn /> </GalleryImageContainer> ), )} </Masonry> {/* Show the modal when the modal state is true */} {isModalOpen && ( <Modal // We pass the modal state and the toggle method modalState={{ value: isModalOpen, toggle: toggleModal }} // We also need changeSlide method to change the image when the // left/right arrow buttons or keys are pressed changeSlide={changeSlide} > {/* We pass the entire state object as "children" holding the data we want to show in the modal: heading, blurb, portfolioType, and portfolio, */} {modalBodyState} </Modal> )} </> ); }; const calculateNextSlide = (direction, currentIndex, data) => {...};
Modal.js
component:
import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; // Package to allow keyboard shortcuts import tinykeys from 'tinykeys'; // Package to enable swiping backward/forward on mobile devices import Swipe from 'react-easy-swipe'; // Custom helper that returns either an "Image" or "Video" component // depending on the "PortfolioType" for each node in the data array // displaying all the images/videos in the node import { PortfolioSelector } from '~helpers/PortfolioSelector'; // SVG components for the backward/forward arrow icons. import { ArrowLeft, ArrowRight } from '~svgs/ChevronCircle'; // We created these constants earlier in "Gallery.js" import { FORWARD, BACKWARD } from '~components/Gallery/Gallery'; // Modal styles import { Container as ModalContainer, Body, Close as CloseModal, } from '~styles/Modal'; //#region Styles // Styles for the Modal body. The body will display either images // or videos, so we have the styling for both tags here const ModalBody = styled(Body)` display: flex; flex-direction: column; z-index: 1000; img { align-self: center; } video { width: 100%; } @media (min-width: '824px') { padding: 2rem; } `; // Base styles for the button that will wrap the left/right SVG arrows // (refer to the Preview GIF). First we remove all the "default" style // for the button; then we absolute position to the left and 45% from // the top of the screen. const ArrowButtonBase = styled.button` padding: 0; border: 0; background: none; outline: none; cursor: pointer; position: absolute; top: 45%; `; // We add additional specific styles for the left and right arrows. const ArrowButtonLeft = styled(ArrowButtonBase)` margin-left: 2rem; `; const ArrowButtonRight = styled(ArrowButtonBase)` right: 0; margin-right: 2rem; `; //#endregion // Destructuring the props, we get: children, modalState, changeSlide. // Refer to the PropType definitions at the end of the component const Modal = ({ children, modalState, changeSlide }) => { const { heading, blurb, portfolioType, portfolio } = children; const chevronColor = '#474747'; // We call "changeSlide" method when we want to move to the previous // or next image in the gallery const forward = () => changeSlide(FORWARD); const backward = () => changeSlide(BACKWARD); useEffect(() => { let unsubscribe = tinykeys(window, { // ESC key to close the modal Escape: () => modalState.toggle(), // Left/Right arrow keys to switch slides ArrowLeft: () => backward(), ArrowRight: () => forward(), }); return () => { unsubscribe(); }; }); return ( <ModalContainer isOpen={modalState.value}> {/* Left arrow button that calls "changeSlide" to move backwards */} <ArrowButtonLeft aria-label="Go To Previous Slide" name="Go To Previous Slide" onClick={e => backward()} > {/* SVG icon that takes in a color for the chevron figure */} <ArrowLeft pathFill={chevronColor} /> </ArrowButtonLeft> {/* We need to wrap the Modal, or element we want to make swipeable, and pass in the methods that will move the slide forward and backward. Tolerance is to prevent accidental swipes, so begin swiping after X pixels. */} <Swipe onSwipeLeft={forward} onSwipeRight={backward} tolerance={100}> <ModalBody> <CloseModal onClick={modalState.toggle}>×</CloseModal> <h1>{heading}</h1> <p>{blurb}</p> {/* This helper component takes in - portfolioType: whether the modal will have images or videos. - portfolio: the array or images/videos to display. - heading: used in the "alt" and "title" attributes for the images */} <PortfolioSelector type={portfolioType} portfolio={portfolio} heading={heading} /> </ModalBody> </Swipe> {/* Same as above */} <ArrowButtonRight aria-label="Go To Next Slide" name="Go To Next Slide" onClick={e => forward()} > <ArrowRight pathFill={chevronColor} /> </ArrowButtonRight> </ModalContainer> ); }; Modal.defaultProps = { children: { heading: '', blurb: '', portfolioType: '', portfolio: [], currentIndex: 0, }, }; Modal.propTypes = { children: PropTypes.shape({ heading: PropTypes.string.isRequired, blurb: PropTypes.string, portfolioType: PropTypes.string.isRequired, portfolio: PropTypes.arrayOf(PropTypes.string).isRequired, currentIndex: PropTypes.number, }).isRequired, modalState: PropTypes.shape({ value: PropTypes.bool.isRequired, toggle: PropTypes.func.isRequired, }).isRequired, changeSlide: PropTypes.func.isRequired, }; export { Modal };
Helper Components
PortfolioSelector.js
component:
import React from 'react'; import PropTypes from 'prop-types'; // We saw these earlier in the tutorial import { Image } from '~helpers/Image'; import { transformationsFormat } from '~utils/index'; // Helper component that returns a "video" element import { Video } from '~helpers/Video'; const PortfolioSelector = ({ type, portfolio, heading }) => { return ( <> {/* We map over the array of images/videos returning either an "img", "video" or "div" element */} {portfolio.map((relativePath, index) => { switch (type) { case 'image': return ( <Image key={relativePath} relativePath={relativePath} alt={`${heading} ${index + 1}`} title={`${heading} ${index + 1}`} transformations={transformationsFormat('w_1000')} /> ); case 'video': // When the "portfolioType" is video, we only need the relative path. Remember, // the path is a little different than the image path. We have two relative // URLs separated by a pipe (|). The first part will be the video poster and // the second the actual video source. return <Video key={relativePath} relativePath={relativePath} />; default: // In case we misspelled, or put another string in the "portfolioType" field, // we just return an "error" notice. return ( <div key={relativePath} style={{ background: '#EA868F', fontWeight: 'bold' }} > <p>Check the content source to have a valid portfolio type.</p> <p>Valid portfolio types:</p> <ul> <li>image</li> <li>video</li> </ul> </div> ); } })} </> ); }; PortfolioSelector.propTypes = { type: PropTypes.string.isRequired, portfolio: PropTypes.arrayOf(PropTypes.string).isRequired, heading: PropTypes.string.isRequired, }; export { PortfolioSelector };
Image.js
component:
import React from 'react'; import PropTypes from 'prop-types'; // Package to lazy load images import 'lazysizes'; const Image = ({ alt, title, relativePath, transformations }) => { return ( // We form the image URL and apply any additional Cloudinary transformations <img src={`https://res.cloudinary.com/${process.env.GATSBY_CLOUDINARY_USER}/image/upload/e_blur:1500,f_auto,q_40${transformations}/andrea-silva-design/${relativePath}`} alt={alt} title={title} // The following three attributes are for lazysizes' API data-sizes="auto" data-src={`https://res.cloudinary.com/${process.env.GATSBY_CLOUDINARY_USER}/image/upload/f_auto,q_auto${transformations}/andrea-silva-design/${relativePath}`} className="lazyload" /> ); }; Image.propTypes = { alt: PropTypes.string.isRequired, title: PropTypes.string, relativePath: PropTypes.string.isRequired, transformations: PropTypes.string, }; Image.defaultProps = { title: '', transformations: '', }; export { Image };
utils/index.js
utilities:
// Simple utility method to add a comma at the beginning of a string export const transformationsFormat = transformations => `,${transformations}`;
Video.js
component:
import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; // We destructure the props: // - relativePath: We have two relative // URLs separated by a pipe (|). The first part will be the video poster and // the second the actual video source. // - showNoSupport: the "video" tag has an option to show a "no support" message. // Pass true/false whether you want to show a message or not // - attributes: any additional video tag attributes // - transformations: Cloudinary transformations for the poster image const Video = ({ relativePath, showNoSupport, attributes, transformations, }) => { // We are storing the relative path for the poster image (thumb) and video // source in state const [sources, setSources] = useState({ thumb: '', video: '' }); useEffect(() => { // First we split the combined paths by a pipe const sources = relativePath.split('|'); // Then we update the state. The first element is the poster image, // the second element is the video source setSources({ thumb: sources[0], video: sources[1], }); }, [relativePath]); if (sources.video === '') return null; return ( <video {...attributes} poster={`https://res.cloudinary.com/${process.env.GATSBY_CLOUDINARY_USER}/image/upload/f_auto,q_60,w_500/andrea-silva-design/${sources.thumb}`} > <source type="video/mp4" src={`https://res.cloudinary.com/${process.env.GATSBY_CLOUDINARY_USER}/video/upload/f_auto${transformations}/andrea-silva-design/${sources.video}`} /> {showNoSupport ? ( <p> Sorry, your browser doesn't support embedded videos. <br /> Here is a{' '} <a href={`https://res.cloudinary.com/${process.env.GATSBY_CLOUDINARY_USER}/video/upload/andrea-silva-design/${sources.video}`} > link to the video </a>{' '} instead. </p> ) : null} </video> ); }; // The only required prop is the "relativePath" Video.propTypes = { relativePath: PropTypes.string.isRequired, showNoSupport: PropTypes.bool, attributes: PropTypes.object, transformations: PropTypes.string, }; // Defaults Video.defaultProps = { showNoSupport: true, attributes: { controls: true, disablePictureInPicture: true, controlsList: 'nodownload', }, transformations: '', }; export { Video };
In conclusion
I hope you enjoyed this two-part tutorial as much as I did putting it together.
You can have a look at the final product on my wife’s website: https://andreasilva.design/portfolio/art.
And the React Custom Lightbox Gallery source code here: https://github.com/esausilva/andrea-silva-design/tree/master/src/components/Gallery.
Let me know your thoughts in the comments!
Final Thoughts
This is a very crude implementation and there are many ways to further extract some of the components to create a better API overall. However, this is working for my use case and can serve as a base for you to improve upon it.
If you liked this short tutorial, share it on your social media and you can follow me on Twitter or LinkedIn.
Consider giving back by getting me a coffee (or a couple) by clicking the following button:
[bottomads][/bottomads]