Pan-Able Image Gallery in Cloudinary and Next.js

Banner for a MediaJam post

Nwokocha Wisdom Maduabuchi

Introduction

Building an image gallery for a website has a couple of layout variations. In this tutorial, you will be learning how to build this cool variation where the gallery moves around with the user's mouse/pointer motion. Hovering over any image in the gallery also changes the page's background to the image's dominant color. See the video below for how it looks.

End Result

You can also view the live version by opening the code sandbox in the next section in a new window.

Because this variation requires us to display many images at once, we will be learning how to load optimized images with smaller sizes using Cloudinary. You will also use GSAP mildly to create the buttery-smooth gallery motion.

Codesandbox and Final Code

Here's the final version in the codesandbox link below. Codesandbox The source code is also available on Github.

Prerequisites

  • Junior-level React and JavaScript knowledge.
  • A Cloudinary account.

Setup

You will start by creating a Next.js project and installing all the additional packages we'd be using.

Create a Next.js project using npx create-next-app cool-image-gallery. When this is done, run cd cool-image-gallery to switch into the project folder.

Run the following command to install the additional packages we'd be using.

1npm install sass gsap cloudinary

Here, we've installed the sass package so we can use SCSS to style our project. SCSS extends the CSS language with more features like nesting, mixins, etc. It traditionally requires some setup, but with Next.js, we just need to install the package with no other configurations. We also have the gsap package, a compelling JavaScript animation library. We'd be using it very mildly in this project. The cloudinary package is Cloudinary's NodeJS SDK, which provides many powerful features for interacting with and manipulating the resources in our Cloudinary account. For example, you will use this to get optimized URLs for our images and the predominant color of our image for the cool background color changing effect in the demo video.

Upload Images

Our gallery will contain a couple of images, and you will upload them all to a folder in a Cloudinary account. So sign in to your Cloudinary management console, look for the 'Media Library' tab and create a new folder. We'd be naming the folder cool-gallery. See the image below for more clarity.

cool-image-gallery-cloudinary-post.jpg

You will be using about 20 images to build our gallery. To make stuff easier, here's a link to a zip file containing 20 random images from Unsplash. We will extract the zip file's content to a folder and upload them to the folder we created on Cloudinary. Double-click to open the folder in the Media library console, then select and drag all the images into this folder.

Configure Next.js to allow remote images

You will be using NextJS’ Image Component and you would need to configure Next.js to allow us load images from res.cloudinary.com. Open next.config.js(or create it if it doesn’t exist in the project’s root folder) and paste the following code:

1/** @type {import('next').NextConfig} */
2const nextConfig = {
3 reactStrictMode: true,
4 images: {
5 domains: ["res.cloudinary.com"],
6 },
7};
8
9module.exports = nextConfig;

The above code turns on React's Strict Mode and configures NextJS to let us load images from Cloudinary.

Getting Images from Cloudinary Into our App

We need to configure the cloudinary package with our credentials so that we can fetch assets from our account. The SDK makes this easy by trying to read the CLOUDINARY_URL environment variable. Next, open your Cloudinary management console, find the API environment variable card, and copy its value by clicking on the copy icon. See the image below for clarity:

Cloudinary environment variable

Next, we'll create an env.local file in the root of our project folder and copy our API environment variable into it as below.

1CLOUDINARY_URL=your_copied_api_environment_variable_here

Replace your_copied_api_environment_variable_here with the API environment variable you copied.

You will load your images with the cloudinary package using Next.js's getStaticProps mechanism. The getStaticProps allows you to fetch data into our app at build time and signals Next.js to pre-render the page during the build process. You can learn more about pre-rendering from here.

getStaticProps is a function that will be exported from our page component, in this case *pages/index.js. We'll clear the content in index.js and export our getStaticProps.

1export async function getStaticProps() {}

Then we'll import the cloudinary package at the top index.js

1import { v2 as cloudinary } from "cloudinary";

The v2 import here allows you to use the latest and most stable features of the SDK.

Update the getStaticProps function to get all the uploaded images from the folder we created earlier.

1export async function getStaticProps() {
2 // get 20 images from a folder in cloudinary account
3 const images = await cloudinary.api.resources({
4 type: "upload",
5 prefix: "cool-gallery", // our folder name
6 max_results: 20,
7 });
8}

In the code above, we use the Cloudinary Admin API, which gives us complete control of our uploaded media assets. The images constant receives an object with a resources field which holds an array of objects containing data about our asset like its public ID, dimensions, format, etc.

Next, you need to get the predominant color for each image (for the background color change effect) and also build an optimized URL for each of the images. Update the getStaticProps function by appending the following code:

1const resourcesWithColorInfo = await Promise.all(
2 images.resources.map(async (image) => {
3 // get image data with color info using cloudinary admin API
4 const imageWithColorInfo = await cloudinary.api.resource(
5 image.public_id,
6 { colors: true }
7 );
8
9 // get optimized and resized image url from public ID
10 const url = cloudinary.url(imageWithColorInfo.public_id, {
11 width: 640,
12 quality: "auto:low",
13 });
14
15 // return only relevant data - image url, predominant color
16 const relevantImageData = {
17 url,
18 predominantColor: imageWithColorInfo.colors[0][0],
19 };
20 return relevantImageData;
21 })
22 );

In the code above, we transform the images.resources array using the map() array method to return only the relevant data we need, which are:

  • the image's optimized URL and
  • the image's predominant color.

This involves using the Admin API's resource() method to get data related to a single resource using its public ID. The {colors: true} options object passed to cloudinary.api.resource() allows us to get color information for the current resource. Then we pass the public ID of the resource and an object that describes the transformations we want to apply to the image to the url() method of the cloudinary package. This generates a resized and optimized image URL that we'll load in our app.

An object - relevantImageData is returned which contains the relevant data as highlighted above. The imageWithColorInfo.colors[0][0] value of the predominantColor field returns the color with the highest occurrence within the image, as analyzed by Cloudinary. For context, the colors field/entry looks like this:

1//...other fields
2"colors": [["#162E02",6.7], ["#385B0C",6.3], ["#F3285C",5.0], ["#B3CB6E",5.0], ["#688F1C",4.4], ["#324D07",4.4], ["#8EAA34",4.3], ["#4F6D0D",4.2], ["#789446",4.1], ["#DF1327",3.9], ["#A10B12",3.7], ["#273804",3.4], ["#0D1802",3.4], ["#D5E191",3.2], ["#646E20",3.1], ["#94AF4D",2.9], ["#FB54A9",2.8], ["#48570B",2.7], ["#ACC655",2.7], ["#FCA2D9",2.7], ["#63110A",2.6], ["#E9B327",2.2], ["#6D644D",2.1], ["#6D8D12",2.0], ["#8F9F27",1.9], ["#C3573E",1.8], ["#CFD76E",1.6], ["#A0B058",1.6], ["#FCD0E9",1.6], ["#728F2D",1.4], ["#F958A1",1.4], ["#D1B694",1.0]],

Promise.all() is used with await to ensure that our asynchronous getStaticProps function waits for all the items in the array to get transformed. Promise.all() takes an array of promises here and returns an array of all the resolved values. In this case, your images.resources array is transformed into an array of promises since it receives an asynchronous function as a callback, which all resolve to the value returned (i.e., relevantImageData object).

Finally, in our getStaticProp function, we return an object with a props field whose value should be an object that contains the props(and their values), which we want to pass to our page component.

1return {
2 props: {
3 imagesData: resourcesWithColorInfo,
4 },
5 };

So, our page component would receive an imagesData prop containing an array of objects, with each object containing the url and predominantColor for an image.

Here’s the complete getStaticProp function at the end of our updates:

1export async function getStaticProps() {
2 // get 20 images from a folder in cloudinary account
3 const images = await cloudinary.api.resources({
4 type: "upload",
5 prefix: "cool-gallery", //our folder name
6 max_results: 20,
7 });
8
9 // get transformed and optimized image urls with predominant color
10 const resourcesWithColorInfo = await Promise.all(
11 images.resources.map(async (image) => {
12 // get image data with color info using cloudinary admin API
13 const imageWithColorInfo = await cloudinary.api.resource(
14 image.public_id,
15 { colors: true }
16 );
17
18 // get optimized and resized image url from public ID
19 const url = cloudinary.url(imageWithColorInfo.public_id, {
20 width: 640,
21 quality: "auto:low",
22 });
23
24 // return only relevant data - image url, predominant color
25 const relevantImageData = {
26 url,
27 predominantColor: imageWithColorInfo.colors[0][0],
28 };
29 return relevantImageData;
30 })
31 );
32
33 return {
34 props: {
35 imagesData: resourcesWithColorInfo,
36 },
37 };
38}

Displaying our Images

You need to write the JSX that displays the images in our app.

You create a simple component in our index.js file called GalleryThumb, which renders a single image.

1import Image from "next/image"; // import at the top of pages/index.js
2
3//... other imports
4
5const GalleryThumb = ({ data }) => {
6
7 return (
8 <div className="gallery-image-wrapper">
9 <a href="#" className="gallery-image-link">
10 <Image
11 src={data.url}
12 className="gallery-image"
13 layout="fill"
14 />
15 </a>
16 </div>
17 );
18};
19
20// .... getStaticProps export below

Here we use NextJS’ Image component which provides some improvements over the regular HTML <img> tag. Our component receives a data prop which is an object that contains the url and predominantColor of an image.

Next, we use the <GalleryThumb /> in our page component's output and pass props to it by mapping over the imagesData prop passed into our page component. Add the following code after the <GalleryThumb> function component.

1export default function Home({ imagesData }) {
2
3 return (
4 <div className="gallery-wrapper">
5 <h1 className="gallery-title">gallery</h1>
6 <div className="gallery-grid-wrapper">
7 <div className="gallery-image-grid">
8 {imagesData.map((image, i) => (
9 <GalleryThumb data={image} key={i} />
10 ))}
11 </div>
12 </div>
13 </div>
14 );
15}

Next, you need to add some styling. Now, create a file style.scss at the root of our project folder.

Add some default styling

1html,
2body {
3 padding: 0;
4 margin: 0;
5 font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
6 Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
7 overflow: hidden;
8 transition: background-color 0.5s ease 0.1s;
9}
10
11* {
12 box-sizing: border-box;
13}
14
15.gallery-title {
16 font-weight: 900;
17 font-size: 2rem;
18 transform: rotate(-90deg) translateY(50%);
19 position: fixed;
20 top: 3rem;
21 left: -3rem;
22 z-index: 10000;
23}

In the styles above, you remove the margin and padding from our <body> tag. We also set overflow to hidden to prevent scrolling as your gallery's main container/wrapper would eventually take up more space than the page, and the effect we want to achieve would be disrupted by scroll behavior. We also use the transition property values above to ensure that the background color change fades nicely and then style our gallery title/logo.

Next, we style the wrappers for our gallery. Add the code below to the style.css file

1.gallery-wrapper {
2 width: 100vw;
3 height: 100vh;
4 position: relative;
5 overflow: hidden;
6 content-visibility: auto;
7}
8
9.gallery-grid-wrapper {
10 position: absolute;
11 display: flex;
12 justify-content: center;
13 align-items: center;
14 width: 125vw;
15 height: 200vh;
16 top: calc(calc(100vh - 200vh)/2);
17 left: calc(calc(100vw - 125vw)/2);
18 content-visibility: auto;
19}
20
21.gallery-image-grid {
22 display: grid;
23 grid-template-columns: repeat(5, 1fr);
24 width: 100%;
25 height: 100%;
26 padding: 80px 102px;
27
28 span {
29 display: block;
30 max-width: 100%;
31 }
32
33 img {
34 max-width: 100%;
35 }
36}

In the above code, the .gallery-wrapper is resized to fill the browser's viewport. The .gallery-grid-wrapper wraps around our image grid, is positioned absolutely, and is resized to be bigger than the browser viewport to enable us to pan around. The values for the width and height can be played around with for a different number of images and image sizes.

The top and left values above allow us to position the .gallery-grid-wrapper so that its center is in the center of the viewport. We do this by calculating the portion of the .gallery-grid-wrapper outside the viewport for each axis and moving it upwards and leftward by half that amount.

The declarations for .gallery-image-grid define a grid with five columns. We also stretch the width and height to fill up its parent .gallery-grid-wrapper. Finally, you add padding so that there's space between the images and the edge of our viewport when we move our mouse to the edges. The nested span styles the DOM node returned by Next.js's Image element, and the nested img sets the max-width to 100%, so the image doesn't overflow its container.

You add the following code to style.css for the last set of styles

1.gallery-image-wrapper {
2 position: relative;
3 padding: 32px 46px;
4}
5
6.gallery-image {
7 height: auto;
8 object-fit: contain;
9}
10
11.gallery-image-link {
12 display: flex;
13 justify-content: center;
14 align-items: center;
15 position: relative;
16 height: 100%;
17 width: 100%;
18}

The code above styles the individual images properly within their containers.

Our gallery should now look somewhat like this:

Gallery layout

Adding the Mouse Interactivity

You will add the interactive effect where the gallery moves around as we move our mouse pointer. This interactivity is such that as we move in a direction, the gallery wrapper moves to reveal images in that direction. This involves determining how much to move our gallery wrapper to the movement of the mouse on either axis(x and y).

You will start by referring to the DOM node of the element that wraps directly over the images as you will need its dimensions(width and height) and manipulate its transform style values. But, first, import React's useRef hook.

1import { useRef } from "react"; //import at the top of pages/index.js

Then we create a reference using that hook in the Home page component

1export default function Home({ imagesData }) {
2 const imageGridRef = useRef(null); // our ref
3
4// rest of code...
5}

Then, let's attach that reference to the element that wraps directly over the images, which also has a class name of gallery-image-grid. You will do this by setting the ref attribute of the element to the binding imageGridRef.

1<div className="gallery-image-grid"
2ref={imageGridRef}
3>
4 {imagesData.map((image, i) => (
5 <GalleryThumb data={image} key={i} />
6 ))}
7 </div>

React refs allow us to get the DOM node of a React element. You can read more about React refs if you are unfamiliar with them.

Next, we'll need to set up a one-time side-effect that allows us to get the gallery wrapper's dimensions, set up an event listener for the mousemove event, and manipulate the gallery's wrapper position to achieve our 'cool' interactive effect. The most appropriate way to achieve this(at least now) is using React's useEffect hook.

We'll add our useEffect import to the same line where we previously imported the useRef hook.

1import { useEffect, useRef } from "react"; // add useEffect to import

Now, let's set up our useEffect in the Home page component and get the dimensions of the gallery's wrapper using getBoundingClientRect().

1export default function Home({ imagesData }) {
2const imageGridRef = useRef(null);
3
4 **useEffect(() => {
5 const imageGridDimensions = imageGridRef.current.getBoundingClientRect(); // returns object with {x,y,width,height,top, left, right, bottom }
6 }, [])
7}

Calling getBoundingClientRect() on a DOM node retrieves an object that describes the size and position of a DOM node's rectangle(remember everything in the DOM is a box, box-model) with properties like its width, height, and x and y co-ordinates of its origin within the viewport. We call this on the current property of the imageGridRef because React attaches the DOM node to that property. We also pass an empty dependency array [] as the second argument to useEffect so that it only runs once - after mounting.

Next, we'll set up a mousemove event listener on the body of our document inside of this effect. First, append the following code to the useEffect's callback function.

1// inside of useEffect's callback
2function handleMouseMove(e) {}
3document.querySelector("body").addEventListener("mousemove", handleMouseMove);

You will proceed to wire in the code for responding to the mousemove event. What we want to achieve here is to get the dimensions of the space that the mouse can move around in (i.e. viewport width and height) and also get the percentage of how much of the gallery is clipped out of the viewport. With these, we can then map the mouse position to the translation of the gallery wrapper's position so that moving the mouse from one end of an axis to another(e.g., from the left edge to the right edge) causes the gallery's wrapper to move to reveal one end to another.

Let's get the viewport width and height of our viewport in our handleMouseMove listener

1function handleMouseMove(e) {
2 const veiwportWidth = document.documentElement.clientWidth;
3 const veiwportHeight = document.documentElement.clientHeight;
4}

Remember that we had some styling previously to place the middle of the gallery's wrapper in the middle of the viewport. This resulted in some part of the gallery's wrapper being clipped out. Here we're getting the distance between the edge of the gallery's wrapper(outside the viewport) and the edge of the viewport. Translating the gallery wrapper by the corresponding values for each axis moves the edge of the gallery's wrapper into the viewport.

We'll get the distance between the edges of the gallery's wrapper(on both axes) and the viewport by updating our handleMouseMove function.

1// x(left) and y(top) distance of non-visible portion of image-grid
2 const xOffset = Math.abs(imageGridDimensions.left);
3 const yOffset = Math.abs(imageGridDimensions.top);

Next, we'll convert these to a percentage of the width and height because we'll use percentage values to move the gallery's wrapper around.

1// percentage of x(left) and y(top) distance of non-visible portion of image-grid
2 const xOffsetPercent = (xOffset / imageGridDimensions.width) * 100;
3 const yOffsetPercent = (yOffset / imageGridDimensions.height) * 100;

Next, we'll use a utility function from GSAP's library called mapRange. It's an interesting function used a lot for mouse interactivity animations. GSAP's doc does a great job of defining what mapRange does. Read the definition pulled from their docs below.

Maps a number's relative placement within one range to the equivalent position in another range. For example, given the range of 0 to 100, 50 would be halfway between, so if it were mapped to a range of 0 to 500, it would be 250 (because that's halfway between that range). Think of it like ratios.

Another example would be a range slider that's 200px wide. If you wanted the user to be able to drag the range slider (between 0 and 200) and have it move something across the screen accordingly, such that when the range slider is at its max, the object is at the right edge, of the screen. At its minimum, it's at the far left edge of the screen, mapRange() is perfect for that. You'd be mapping one range, 0-200, to another range, 0-window.innerWidth.

You can visit the doc for more examples and a video that explains it as well.

Import GSAP at the top of the page/index.js file.

1import { gsap } from "gsap";

Now, we need to map our mouse position(x and y) to equivalent values(for both axes) by which to translate/move the gallery's wrapper. Let's get these values with the code below.

1// distance to transform x and y of image grid based on mouse move within viewport
2 const xTransform = gsap.utils.mapRange(
3 0,
4 veiwportWidth,
5 -xOffsetPercent,
6 xOffsetPercent,
7 e.clientX
8 );
9 const yTransform = gsap.utils.mapRange(
10 0,
11 veiwportHeight,
12 -yOffsetPercent,
13 yOffsetPercent,
14 e.clientY
15 );

mapRange takes the lower and upper bound of the initial range as its first two arguments, then as its next two arguments, the lower and upper bound of the target range. The last argument is the value(from the first range) we want to 'map' to the second range.

The initial range here is the distance from one edge of an axis of the viewport to another edge (e.g., from top edge to bottom edge, which is (0 - viewport height)). Then the target range is from one edge of the gallery wrapper to another edge on both axes. Getting to the edges of the gallery's wrapper involves translating/moving it by a distance, which we calculated(in percentages) in the previous code block. This is why the lower bound of the range starts from -xOffsetPercent and ends at xOffsetPercent for the x/horizontal axis and follows in a similar vein for the y/vertical axis.

Next, we'll use the GSAP's to animation method to move the gallery's wrapper by the values mapped from the mouse position. Add the following code to the handleMouseMove function.

1// animate gallery wrappers to mapped values
2gsap.to(imageGridRef.current, {
3 xPercent: -xTransform,
4 yPercent: -yTransform,
5 ease: "expo.out",
6 duration: 2.5,
7 });

We use the gsap.to method here to animate the specified style properties of an element from its current value to the supplied values. As its first argument, it receives a reference to the element's DOM node. Then as a second argument, it receives an object with configurations for the animation. xPercent and yPercent will manipulate the transform style property of the element and translate the element in the corresponding axis, using the supplied value(which is a number) as a percentage value, rather than pixels. The ease of "expo.out" ensures that the values don't just animate linearly but slow down at the end to create a more natural and smooth motion.

By now, we should be able to move around the gallery by moving our mouse pointer.

Add interactivity

Changing the Background Color to Predominant Image Color

Now, to complete the 'cool-ness' of the gallery, we'll add the final effect, which changes the page's background color to the predominant color of any image we hover.

We'll need to edit our <GalleryImage /> component. Let's get the ref for an image's container and attach event handlers for mouseenter and mouseleave

1const GalleryThumb = ({ data }) => {
2 const gridItemRef = useRef(null); //get ref
3
4 return (
5 <div className="gallery-image-wrapper" ref={gridItemRef}>
6 <a href="#" className="gallery-image-link">
7 <Image
8 src={data.url}
9 className="gallery-image"
10 layout="fill"
11 onMouseEnter={handleMouseEnter}
12 onMouseLeave={handleMouseLeave}
13 />
14 </a>
15 </div>
16 );
17};

Here we get our ref into the binding gridItemRef and attach it to the div wrapper.

Next, we'll define a function changeBgOnHover, which receives a color value and changes the page's background color. Again, this function should be defined below the imports but outside any component in the page/index.js file.

1const changeBgOnHover = (color) => {
2 const bodyElement = document.querySelector("body");
3 bodyElement.style.background = color;
4};

Let’s define our handleMouseEnter and handleMouseLeave functions in the <GalleryThumb/> component.

1const GalleryThumb = ({ data }) => {
2 const gridItemRef = useRef(null);
3
4 function handleMouseEnter() {
5 changeBgOnHover(data.predominantColor);
6 gsap.to(gridItemRef.current, {
7 scale: 1.15,
8 delay: 0.3,
9 });
10 }
11 function handleMouseLeave() {
12 changeBgOnHover("#fff");
13 gsap.to(gridItemRef.current, {
14 scale: 1,
15 delay: 0.3,
16 });
17 }
18
19// ...rest of code
20
21}

Here, on mouse enter, we call changeBgOnHover with the image's predominant color, which is part of the object passed as the data prop to the <GalleryThumb/> component. You also reset the background color to white on mouse leave. Finally, you add an extra GSAP animation to scale the image slightly when we hover it(more coolness…).

You should have our complete 'cool' image gallery interaction and effects.

End Result

Conclusion

In this tutorial, we've combined some of the best tools/technologies for animation(GSAP), image hosting and optimization(Cloudinary), and website development(Next.js) to build a somewhat 'cool' image gallery. You have learned to use Cloudinary's API to fetch images and color information into our app, aided by Next.js's getStaticProp. We also touched on GSAP to power our animations and its nice mapRange utility to aid our mouse interaction.

If you'd like a good challenge, you can extend this project to work on touch devices and smaller screens.

Happy Coding!

Useful Resources

Nwokocha Wisdom Maduabuchi

Senior Technical Writer

A software engineer with considerable experience in native Android, Blockchain Technology, Smart Contracts, Etheruem, Developer Advocacy, UIUX Design, Building a static site using Eleventy, MKDocs, Hugo, Jekyll, and Docsify, DITA, Gitbook, AsciiDoc, Readme, confluence, Technical Content Manager, API Documentation, Documentation Engineer, Community building/management, Firebase.
• Developed apps from the ground up and published them successfully on Google Play
• Built open-source library and an open-source contributor/ maintainer at Gnome, Firebase, Kotlin
• Experience modern architecture, and development practices
• Write clean and maintainable codes