Skip to content
Dashboard

Building a fast, animated image gallery with Next.js

Senior Developer Advocate

How to use the Next.js Image component to automatically optimize hundreds of image.

Copy link to headingRendering hundreds of images performantly

Area #1 of the Image Gallery: Homepage with a grid of images
Area #1 of the Image Gallery: Homepage with a grid of images
Area #2 of the Image Gallery: Modal with one large image and a carousel of smaller images
Area #2 of the Image Gallery: Modal with one large image and a carousel of smaller images
const Home: NextPage = () => {
const [loading, setLoading] = useState<boolean>(false);
const [bio, setBio] = useState<String>("");
const [vibe, setVibe] = useState<VibeType>("Professional");
const [generatedBios, setGeneratedBios] = useState<String>("");
const prompt = `Generate 2 ${vibe} twitter bios with no hashtags and clearly labeled "1." and "2.". Make sure each generated bio is at least 14 words and at max 20 words and base them on this context: ${bio}`;
const generateBio = async (e: any) => {
// call serverless function
};
return (
<Layout>
<Header />
<main className="flex flex-1 w-full flex-col items-center justify-center text-center px-4 mt-12 sm:mt-20">
<a
className="flex max-w-fit items-center justify-center space-x-2 rounded-full border border-gray-300 bg-white px-4 py-2 text-sm text-gray-600 shadow-md transition-colors hover:bg-gray-100 mb-5"
href="https://github.com/Nutlope/twitterbio"
target="_blank"
rel="noopener noreferrer"
>
<Github />
<p>Star on GitHub</p>
</a>
<h1 className="sm:text-6xl text-4xl max-w-2xl font-bold text-slate-900">
Generate your next Twitter bio in seconds
</h1>
<p className="text-slate-500 mt-5">18,167 bios generated so far.</p>
<div className="max-w-xl">
<div className="flex mt-10 items-center space-x-3">
<Image
src="/1-black.png"
width={30}
height={30}
alt="1 icon"
className="mb-5 sm:mb-0"
/>
<p className="text-left font-medium">
Copy your current bio{" "}
<span className="text-slate-500">
(or write a few sentences about yourself).
</span>
</p>
</div>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={4}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-black focus:ring-black my-5"
placeholder={
"e.g. Senior Developer Advocate @vercel. Tweeting about web development, AI, and React / Next.js. Writing nutlope.substack.com."
}
/>
<div className="flex mb-5 items-center space-x-3">
<Image src="/2-black.png" width={30} height={30} alt="1 icon" />
<p className="text-left font-medium">Select your vibe.</p>
</div>
<div className="block">
<DropDown vibe={vibe} setVibe={(newVibe) => setVibe(newVibe)} />
</div>
{!loading && (
<button
className="bg-black rounded-xl text-white font-medium px-4 py-2 sm:mt-10 mt-8 hover:bg-black/80 w-full"
onClick={(e) => generateBio(e)}
>
Generate your bio &rarr;
</button>
)}
{loading && (
<button
className="bg-black rounded-xl text-white font-medium px-4 py-2 sm:mt-10 mt-8 hover:bg-black/80 w-full"
disabled
>
<LoadingDots color="white" style="large" />
</button>
)}
</div>
<hr className="h-px bg-gray-700 border-1 dark:bg-gray-700" />
<div className="space-y-10 my-10">
{generatedBios && (
<>
<div>
<h2 className="sm:text-4xl text-3xl font-bold text-slate-900 mx-auto">
Your generated bios
</h2>
</div>
<div className="space-y-8 flex flex-col items-center justify-center max-w-xl mx-auto">
{generatedBios
.substring(generatedBios.indexOf("1") + 3)
.split("2.")
.map((generatedBio) => {
return (
<div
className="bg-white rounded-xl shadow-md p-4 hover:bg-gray-100 transition cursor-copy border"
key={generatedBio}
>
<p>{generatedBio}</p>
</div>
);
})}
</div>
</>
)}
<Footer />
</Layout>
</div>
);
};
export default Home;

Area #3 of the Image Gallery: Individual dynamic routes that showed one image
Area #3 of the Image Gallery: Individual dynamic routes that showed one image

Copy link to headingGenerating image placeholders for optimal UX

import imagemin from "imagemin";
import imageminJpegtran from "imagemin-jpegtran";
export async function getBase64ImageUrl(imageUrl: string) {
// fetch image and convert it to base64
const response = await fetch(imageUrl);
const buffer = await response.arrayBuffer();
const minified = await imagemin.buffer(Buffer.from(buffer), {
plugins: [imageminJpegtran()],
});
const base64 = Buffer.from(minified).toString("base64")
return `data:image/jpeg;base64,${base64}`;
}

Copy link to headingUsing the Next.js Image component

<Image
alt={caption}
style={{ transform: "translate3d(0, 0, 0)" }}
className="transform rounded-lg brightness-90 transition group-hover:brightness-110"
placeholder="blur"
blurDataURL={blurDataUrl}
src={`https://res.cloudinary.com/...`}
width={720}
height={480}
loading={id < 4 ? "eager" : "lazy"}
sizes="(max-width: 640px) 100vw,
(max-width: 1280px) 50vw,
(max-width: 1536px) 33vw,
25vw"
/>

Copy link to headingImplementing smooth animations

Copy link to headingAccessibility wins with Headless UI and auto-generated alt tags

import cloudinary from "../../utils/cloudinary";
export default async function getAltText() {
const results = await cloudinary.v2.search
.expression(`folder:${process.env.CLOUDINARY_FOLDER}/*`)
.sort_by("public_id", "desc")
.max_results(400)
.execute();
for (let result of results.resources) {
const imageUrl = result.url;
const response = await fetch(
`https://alt-text-generator.vercel.app/generate?imageUrl=${imageUrl}`
);
const altText = await response.text();
const finalAltText = altText.split("Caption: ")[1];
await cloudinary.v2.api.update(
result.public_id,
{ type: "upload", context: { caption: finalAltText } },
function (_, result) {
console.log({ result });
console.log(`${result.public_id} done`);
}
);
}
}

Copy link to headingRestoring scroll when navigating back to the grid

import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { useLastViewedPhoto } from "../utils/useLastViewedPhoto";
export default function GridImage() {
const router = useRouter();
const { photoId } = router.query;
const [lastViewedPhoto, setLastViewedPhoto] = useLastViewedPhoto();
const lastViewedPhotoRef = useRef<HTMLAnchorElement>(null);
useEffect(() => {
// This effect keeps track of the last viewed photo in the modal
if (lastViewedPhoto && !photoId) {
lastViewedPhotoRef.current.scrollIntoView({ block: "center" });
setLastViewedPhoto(null);
}
}, [photoId, lastViewedPhoto, setLastViewedPhoto]);
return (
<Link
key={id}
href={`/?photoId=${id}`}
as={`/p/${id}`}
ref={id === lastViewedPhoto ? lastViewedPhotoRef : null}
shallow
className="..."
>
<Image src="..." width={720} height={480} />
</Link>
);
}

Copy link to headingFinal performance

Lighthouse score for the image gallery site.
Lighthouse score for the image gallery site.

Copy link to headingClone and deploy today

Ready to deploy?