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.
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.
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};89module.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:
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 account3 const images = await cloudinary.api.resources({4 type: "upload",5 prefix: "cool-gallery", // our folder name6 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 API4 const imageWithColorInfo = await cloudinary.api.resource(5 image.public_id,6 { colors: true }7 );89 // get optimized and resized image url from public ID10 const url = cloudinary.url(imageWithColorInfo.public_id, {11 width: 640,12 quality: "auto:low",13 });1415 // return only relevant data - image url, predominant color16 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 fields2"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 account3 const images = await cloudinary.api.resources({4 type: "upload",5 prefix: "cool-gallery", //our folder name6 max_results: 20,7 });89 // get transformed and optimized image urls with predominant color10 const resourcesWithColorInfo = await Promise.all(11 images.resources.map(async (image) => {12 // get image data with color info using cloudinary admin API13 const imageWithColorInfo = await cloudinary.api.resource(14 image.public_id,15 { colors: true }16 );1718 // get optimized and resized image url from public ID19 const url = cloudinary.url(imageWithColorInfo.public_id, {20 width: 640,21 quality: "auto:low",22 });2324 // return only relevant data - image url, predominant color25 const relevantImageData = {26 url,27 predominantColor: imageWithColorInfo.colors[0][0],28 };29 return relevantImageData;30 })31 );3233 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.js23//... other imports45const GalleryThumb = ({ data }) => {67 return (8 <div className="gallery-image-wrapper">9 <a href="#" className="gallery-image-link">10 <Image11 src={data.url}12 className="gallery-image"13 layout="fill"14 />15 </a>16 </div>17 );18};1920// .... 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 }) {23 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}1011* {12 box-sizing: border-box;13}1415.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}89.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}2021.gallery-image-grid {22 display: grid;23 grid-template-columns: repeat(5, 1fr);24 width: 100%;25 height: 100%;26 padding: 80px 102px;2728 span {29 display: block;30 max-width: 100%;31 }3233 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}56.gallery-image {7 height: auto;8 object-fit: contain;9}1011.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:
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 ref34// 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);34 **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 callback2function 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-grid2 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-grid2 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 viewport2 const xTransform = gsap.utils.mapRange(3 0,4 veiwportWidth,5 -xOffsetPercent,6 xOffsetPercent,7 e.clientX8 );9 const yTransform = gsap.utils.mapRange(10 0,11 veiwportHeight,12 -yOffsetPercent,13 yOffsetPercent,14 e.clientY15 );
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 values2gsap.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.
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 ref34 return (5 <div className="gallery-image-wrapper" ref={gridItemRef}>6 <a href="#" className="gallery-image-link">7 <Image8 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);34 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 }1819// ...rest of code2021}
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.
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!