Image Gallery

Introduction

In the world of culinary delights, a picture is truly worth a thousand words. At Fooderia, they understand the profound impact that visually appealing images can have on their users' cooking and browsing experience. Therefore, they are excited to introduce the Image Gallery, a curated collection of high-quality photos that vividly showcase the beauty and diversity of the recipes. This gallery is not just a feast for the eyes but a source of inspiration, enabling users to visualize the dishes they'll be crafting in their kitchens.

After thorough discussions and meticulous planning, the team at Fooderia reached a pivotal decision to implement a fixed aspect ratio across all images in the Image Gallery feature. This decision was underpinned by multiple factors that directly influence user experience and website aesthetics:

  • Consistency: A uniform aspect ratio ensures a cohesive and appealing presentation
  • Responsiveness: Enhances and simplifies the layout's responsiveness
  • Content Management: Streamlines the content management process
  • Performance: Optimizes image loading and rendering performance

Tutorial

Oh, images, of course they will have images. You get right on it and start with the strategy you will employ on this journey. You think of the ways you will seperate the different aspects of showing images a kind of slider. A component for showing images is a must, and a way to get the list of images to be shown, and someway to showcase and test the component.

Unfortunatly, the team at Fooderia has not yet decided on what image provider to use, so you plan on starting out with getting images from the free image provider picsum.photos. Even though Next.js and their Image component has powerful features for working with images, you decide on getting fixed sized imaged of 600x400 pixels for starters, so you can focus on the layout and the gallery itself at this early stage. And as always you are determined in making the browser do most of the heavy lifting in rendering the gallery.

You finally come up with a basic infrastructure:

  • app
  • image-gallery
  • components
  • ImageGallery
  • Demo.jsx
  • ImageGallery.jsx
  • index.js
  • use-images.js

And the content of the files:

components/ImageGallery/use-images.js
import { useState } from 'react'
// The plan is to create a hook that fetches n number of images
// from the Picsum API with the specified height and width.
export default function usePicsumImages (n, width = 600, height = 400) {
const [images] = useState ([])

return [images]
}
components/ImageGallery/ImageGallery.jsx
// hardcoded width and height
export const WIDTH = 600
export const HEIGHT = 400

export default function ImageGallery ({ images = [] }) {
return (<p>No images yet...</p>)
}
components/ImageGallery/Demo.jsx
import ImageGallery, { WIDTH, HEIGHT } from './ImageGallery'
import useImages from './use-images'

// Tie the useImages hook and the ImageGallery component
// together to showcase the gallery.
export default function Demo ({ n }) {
const [images] = useImages (n, WIDTH, HEIGHT)

return <ImageGallery images={images} />
}
components/ImageGallery/index.js
'use client' // must be client components
export { default } from './ImageGallery'
export { default as Demo } from './Demo'

And a route for display:

app/image-gallery/page.jsx
import { Demo } from '@/components/ImageGallery'

export default function ImageGalleryPage () {
return (
<main className="flex flex-col gap-8 p-4 sm:p-24">
<h1 className="text-xl font-bold">Image Gallery</h1>
<Demo />
</main>
)
}

After verifying that the new components are displayed in the browser, it is time to get to work. You start with the hook so that you have something to populate the image gallery with. Examining the picsum site for awhile you find a suitable endpoint to use : https://picsum.photos/v2/list, where you get a json response in the form of a list of objects with some useful properties. Go ahead and finish the custom hook you have started, ane make use of the picsum.photo api:

components/ImageGallery/use-images.js
import { useEffect, useState } from 'react'

// Hardcoded service endpoints works for now,
// theese are public and open services.
const IMAGE_SERVICE = 'https://picsum.photos'
const IMAGE_LIST_SERVICE = `${IMAGE_SERVICE}/v2/list`

export default function usePicsumImages (n, width = 600, height = 400) {
// add setImages
const [images, setImages] = useState ([])
// since there is going to be an async request to a service
// there is a good reason to add a loading state
const [isLoading, setIsLoading] = useState (false)
// ...and an error state if the request fails
const [isError, setIsError] = useState ()

// In order to fetch the images, we need to make an async request
// inside a useEffect hook. This hook will run when either of the
// dependencies change: n, width or height.
useEffect (() => {
// It is a fairly simple function, so it can be inlined
// and self-invoked
(async () => {
// First set the loading state
setIsLoading (true)

// enclose in a try-catch
try {
// Create a new URL object with the service endpoint
const url = new URL (IMAGE_LIST_SERVICE)

// Set the query parameters, page can be hardcoded
// but limit should be the number of images to fetch
url.searchParams.set ('page', '1')
url.searchParams.set ('limit', n)

// Fetch the list
const response = await fetch (url)

// fetch() only throws an error if the request
// did not generate a valid http response, however
// the response is useless to us if it is not ok
if (!response.ok) throw new Error (response)

// Parse the response as JSON
const result = await response.json ()

// The result is an array of objects, each object
// carries an id, from wich an image url can be
// constructed. There is a need to modify the width
// and height of the image.
setImages (
result.map ((item) => {
const picUrl = new URL (IMAGE_SERVICE)
picUrl.pathname = `id/${item.id}/${width}/${height}`
item.width = width
item.height = height
item.url = picUrl.toString ()
return item
}),
);
} catch (e) {
// Log the error and set it. Note: Be careful with
// what gets logged, in production your logs should
// not leak sensitive information.
console.error (e)
setIsError (e)
} finally {
// Finally, set the loading state, we are done loading
// regardless of the outcome.
setIsLoading (false)
}
})();
}, [n, width, height])

// A bit of preference, returning an array instead of an
// object here I guess...
return [images, isLoading, isError]
}

Now you need to accomodate for the changes when they are used, so you edit the Demo component:

components/ImageGallery/Demo.jsx
import ImageGallery, { WIDTH, HEIGHT } from './ImageGallery'
import useImages from './use-images'

export default function Demo ({ n = 10 }) {
// Add the new states returned from the hook:
const [images, isLoading, isError] = useImages (n, WIDTH, HEIGHT)

// To be able to catch the loading and error states,
// just render them on the page
if (isLoading) return (<p>Loading...</p>)
if (isError) return (<p>Error: {isError.message}</p>)

return <ImageGallery images={images} />
}

And to see what gets delivered to the ImageGallery component now you just do a simple printout of the images object:

components/ImageGallery/ImageGallery.jsx
export const WIDTH = 600
export const HEIGHT = 400

export default function ImageGallery ({ images = [] }) {
// Print the images object array on the page
// to examine the list.
return (<pre>{JSON.stringify (images, null, 2)}</pre>)
}

After a few tries you finally reach the result you were expecting, a printout of an array of ten image objects. You notice that the width, height and url properties are correct.

Before starting to get all nitty gritty on the ImageGallery component you feel like you need to make sure that you can display the images in the first place. You know about the nextjs Image object and use that to render the list of images in the component:

components/ImageGallery/ImageGallery.jsx
import Image from 'next/image'

export const WIDTH = 600
export const HEIGHT = 400

export default function ImageGallery ({ images = [] }) {

return (
// Render the images horizontally in a flex container
<div className="flex">
{/* Do not render anything if there are no images */}
{images.length > 0 &&
// Render each image, passing the necessary props
images.map(({ id, url, author, height, width }) => (
<Image
// As usual, react needs a key to keep track
// of the items
key={id}
// Images must have an alt attribute, since
// no desctiption of the image exists,
// the author field is used.
alt={`Picture by ${author}`}
// Yay, the url we constructed in the useImages
// hook
src={url}
// The height and width of the image, the <Image>
// component generally requires these to be set.
height={height}
width={width}
/>
))}
</div>
)
}

Wot? You expected the images to show up stacked nicely across your screen, instead the whole freaking app crashed with some error message:

Error message

Sigh, you learn more about the error message by following the link in the error message. Hm, as it turns out, in Nextjs you have to be very explicit about where the application is allowed to load images from. This is for security reasons. Well, well, that is a good thing, so you go ahead and configure your app: open the next.config.js file and add the magic config

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [{
protocol: 'https',
hostname: 'picsum.photos',
port: '',
}],
},
};

export default nextConfig;

After a refresh of the browser tab, you are able to see ten beautiful images lined up horizontally on the screen. You get excited, if only you could try to create like a view box on top of the images that is the size of one image and render the images inside it you would be almost done right? It is worth a shot:

components/ImageGallery/ImageGallery.jsx
...
export default function ImageGallery ({ images = [] }) {
return (
// This is the "view-box". Since images are fetched with a
// fixed width and height, set the max width to not be wider
// than the image width.
// The overflow is set to auto to make it scrollable, this will
// hide the "overflow".
// The 'snap-x' and 'snap-mandatory' classes are used to make the
// container content "snappable" in order to give it the
// carousel feel when scrolling.
// The 'aspect-[3/2]' class is used to set the aspect ratio of
// element to be the same as the images
<div className="flex max-w-[600px] overflow-auto snap-x snap-mandatory rounded-lg aspect-[3/2]">
{images.length > 0 &&
images.map(({ id, url, author }) => (
// To make the image always fit in the view box the <Image>
// is set to 'fill' its parent container. So wrap the <Image>
// in a div that act as that parent, this container represent
// the items in the gallery.
// The 'key' property is moved to the parent div.
// The snap-center and snap-always classes are used to center
// the image in the view box and make it always snap to the
// center when scrolling.
// The 'relative' class sets the positioning context for the
// container, it is required for the <Image> to be positioned
// correctly, using the 'fill' property.
// object-contain is used to make the image fit in the container
// without distorting it when resized.
<div key={id} className="snap-center snap-always rounded-lg aspect-[3/2] relative object-contain">
<Image
// key={id} // <- Move the key prop to parent div
alt={`Picture by ${author}`}
src={url}
// height={height}
// width={width}
fill // <- Replace 'height' and 'width' with 'fill'
/>
</div>
))}
</div>
)
}

Wow, you might be home by dinner tonight! Now you need to get rid of that ugly scroll-bar (you notice it when you use a mouse pointer device), and add previous/next buttons to be able to navigate the gallery better. Tailwind CSS does not have any good class to hide scrollbars, so you must add it yourself. There is a globals.css file in the app folder that works as the entry point to Tailwind, open it and add the hide scrollbar magic:

app/globals.css
/* ... */
@layer utilities {
.text-balance {
text-wrap: balance;
}

/* Add this utility: */
.scrollbar-none::-webkit-scrollbar {
display: none;
}
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
}

Now you can hide the scrollbar and add previous/next buttons to the gallery:

components/ImageGallery/ImageGallery.jsx
...
// Represents the direction of the navigation, mostly for
// readability...
const NEXT = true
const PREV = false

export default function ImageGallery ({ images = [] }) {
// A ref to be used to get a referrence to the scroll container
// element, do not forget to import useRef from react at the top.
const ref = useRef ()

// The function that will let us navigate to the next or previou
// image
const goTo = (isNext = false) => {
// How much to scroll is determined by the actual width of the
// container, and based on the direction we want to go.
const distance = isNext ? ref?.current.offsetWidth : -ref?.current.offsetWidth
// The actual scrolling is done here.
ref?.current.scrollBy({
left: distance,
behavior: 'smooth',
})
}

return (
// To remove the visible scrollbar add the class scrollbar-none
// To disable the ability to user-select the images that in some
// cases can be annoying, add the class select-none.
// --------------------------
<div ref={ref} className="scrollbar-none select-none flex max-w-[600px] overflow-auto snap-x snap-mandatory rounded-lg aspect-[3/2]">
{images.length > 0 &&
images.map(({ id, url, author }) => (
<div key={id} className="snap-center snap-always rounded-lg aspect-[3/2] relative object-contain">
<Image
alt={`Picture by ${author}`}
src={url}
fill
/>
{/* Add next and previous buttons, using the new goTo function, they are
absolute positioned to the middle left and middle right of the images. */}
<button onClick={() => goTo (PREV)} className="absolute left-5 top-[50%] opacity-60 inline-flex justify-center items-center border w-[1.5em] h-[1.5em] rounded-full bg-black text-xl">
<span>↢</span>
</button>
<button onClick={() => goTo (NEXT)} className="absolute right-5 top-[50%] opacity-60 inline-flex justify-center items-center border w-[1.5em] h-[1.5em] rounded-full bg-black text-xl">
<span>↣</span>
</button>
</div>
))}
</div>
)
}

Oh, okay, it works at least. You figure it is time for an assessment of the component as it stands now: The previous and next buttons are now rendered on every image, that looks and feels a bit strange. You decide you have to add an extra 'layer' for the controls, especially since you are planning on adding more controls to the gallery. Also, when displaying the first and last image it is odd that there is a previous/next button there that does not do anything, you recon that just has to be fixed. You realise that in order to do that you will have to keep track of what image is currently in display, you need an index.

components/ImageGallery/ImageGallery.jsx
import { useRef, useState } from 'react' // <- import useState
import Image from 'next/image'

export const WIDTH = 600
export const HEIGHT = 400

const NEXT = true
const PREV = false

export default function ImageGallery ({ images = [] }) {
const ref = useRef ()

// Add a new state to keep track of the current index
const [index, setIndex] = useState (0)

const goTo = (isNext = false) => {
const distance = isNext ? ref?.current.offsetWidth : -ref?.current.offsetWidth
ref?.current.scrollBy({
left: distance,
behavior: 'smooth',
})
}

// add a new function to handle the scroll event, that will
// calculate the current index based on the scroll position
const onScroll = (event) => {
// the ref.current is the viewport of the scroll container
// and also the width of one item in the gallery
const width = ref?.current?.offsetWidth || 0
// left is how much the scroll container has been scrolled
const left = event.target.scrollLeft
// The index can now easily be calculated by dividing the
// scroll position by the width of one item:
setIndex (Math.round (left / width))
}

return (
// Wrap the scroll container div in a new div, that will contain
// the images and the controls. Move the control buttons to be
// on the same leval as the scroll container.
// Make it relative so the contents can be positioned correctly
<div className="relative rounded-lg max-w-[600px] aspect-[3/2]">
<div ref={ref}
// Add the onScroll event listener to the scroll container
onScroll={onScroll}
className="scrollbar-none select-none flex max-w-[600px] overflow-auto snap-x snap-mandatory rounded-lg aspect-[3/2]"
>
{images.length > 0 &&
images.map(({ id, url, author }) => (
<div key={id} className="snap-center snap-always rounded-lg aspect-[3/2] relative object-contain">
<Image
alt={`Picture by ${author}`}
src={url}
fill
/>
{/* Move to outermost div
<button onClick={() => goTo (PREV)} className="absolute left-5 top-[50%] opacity-60 inline-flex justify-center items-center border w-[1.5em] h-[1.5em] rounded-full bg-black text-xl">
<span>↢</span>
</button>
<button onClick={() => goTo (NEXT)} className="absolute right-5 top-[50%] opacity-60 inline-flex justify-center items-center border w-[1.5em] h-[1.5em] rounded-full bg-black text-xl">
<span>↣</span>
</button> */}
</div>
))}
</div>
{/* Add a condition so it wont render the buttons if they are not leading anywhere */}
{index != 0 && (<button onClick={() => goTo (PREV)} className="absolute left-5 top-[50%] opacity-60 inline-flex justify-center items-center border w-[1.5em] h-[1.5em] rounded-full bg-black text-xl">
<span>↢</span>
</button>)}
{index !== images.length - 1 && (<button onClick={() => goTo (NEXT)} className="absolute right-5 top-[50%] opacity-60 inline-flex justify-center items-center border w-[1.5em] h-[1.5em] rounded-full bg-black text-xl">
<span>↣</span>
</button>)}
</div>
)
}

OMG, you are thinking of calling it a day, but a final touch beckons to truly round off the project: implementing a classic "dot"-navigation at the bottom of the component. This elegant solution not only adds a subtle yet sophisticated aesthetic touch but also significantly enhances user navigation. A dot-navigation system offers an intuitive, visually minimalistic way for users to understand their position within the gallery and effortlessly move between images. By seamlessly integrating this feature, the aim is to provide an even more polished and user-friendly interface, ensuring that Fooderia's website visitors can smoothly browse through the culinary delights on offer, thereby elevating their overall experience with the website to new heights of interactive elegance. It's the perfect finishing touch to complement the robust functionality and appealing design of the Image Gallery component.

To accomplish this you need to add the dots to the component at the same levels as the previous/next buttons, and add a function to handle dot navigation:

components/ImageGallery/ImageGallery.jsx
import { useRef, useState } from 'react'
import Image from 'next/image'

export const WIDTH = 600
export const HEIGHT = 400

const NEXT = true
const PREV = false

export default function ImageGallery ({ images = [] }) {
const ref = useRef ()
const [index, setIndex] = useState (0)

const goTo = (isNext = false) => {
const distance = isNext ? ref?.current.offsetWidth : -ref?.current.offsetWidth
ref?.current.scrollBy({
left: distance,
behavior: 'smooth',
})
}

const onScroll = (event) => {
const width = ref?.current?.offsetWidth || 0
const left = event.target.scrollLeft
setIndex (Math.round (left / width))
}

// When a dot is clicked we scroll to the corresponding image,
// in this case we want to do it instantly rather than smoothly.
const instantScrollTo = (index) => {
if (ref.current) {
// "Turning off" smooth scrolling was a bit tricky due to
// the way the browser handles scrollBehavior. First it
// needs to be set to 'auto', then the overflow needs to
// be set to 'hidden' to prevent the browser from showing
// the scroll bar.
ref.current.style.scrollBehavior = 'auto'
ref.current.style.overflow = 'hidden'
// The actual scolling is straight forward.
ref.current.scrollTo ({
left: index * ref.current.offsetWidth,
behaviour: 'instant',
})
// After the scrolling is done we need to reset the to
// nothing for the stylesheet styles to kick in again.
ref.current.style.overflow = ''
ref.current.style.scrollBehavior = ''
// Finally we update the index to reflect the new position.
setIndex (index)
}
}

return (
<div className="relative rounded-lg max-w-[600px] aspect-[3/2]">
<div ref={ref}
onScroll={onScroll}
className="scrollbar-none scroll-smooth select-none flex max-w-[600px] overflow-auto snap-x snap-mandatory rounded-lg aspect-[3/2]"
>
{images.length > 0 &&
images.map(({ id, url, author }) => (
<div key={id} className="snap-center snap-always rounded-lg aspect-[3/2] relative object-contain">
<Image
alt={`Picture by ${author}`}
src={url}
fill
/>
</div>
))}
</div>
{index != 0 && (<button onClick={() => goTo (PREV)} className="absolute left-5 top-[50%] opacity-60 inline-flex justify-center items-center border w-[1.5em] h-[1.5em] rounded-full bg-black text-xl">
<span>↢</span>
</button>)}
{index !== images.length - 1 && (<button onClick={() => goTo (NEXT)} className="absolute right-5 top-[50%] opacity-60 inline-flex justify-center items-center border w-[1.5em] h-[1.5em] rounded-full bg-black text-xl">
<span>↣</span>
</button>)}
{/* Dot navigation, absolute positioned at the bottom of the view box */}
<div className="absolute bottom-0 w-full py-2 text-center">
{/* Create an array of ●:s with the same length as the number
of images. Render them with a different color depending on
the current index. Call the handler when clicked. */}
{Array(images.length)
.fill('')
.map((dot, i) => (
<button
className={`mx-0.5 ${index === i ? "text-slate-200" : "text-slate-700"}`}
onClick={() => instantScrollTo(i)}
key={i}
>
{dot}
</button>
))}
</div>
</div>
)
}

Finally, with a grin of satisfaction, you commit and push the latest updates. The anticipation of the Foodorians' reaction to this masterpiece adds an extra spark to your smile. Amidst the thoughts of what tonight's dinner will entail, you sneak a quick glance at the task board, curious about tomorrow’s adventures.