Static images are everywhere but in a fast, responsive web, they often get overlooked. Motion and intelligent layout adaptation are now expected, especially in marketing and product design.
With Cloudinary’s Zoompan and Generative Fill, you can:
- Add smooth, cinematic motion with the Ken Burns–style Zoompan effect.
- Extend images automatically using AI-powered Generative Fill.
- Deliver optimized, responsive banners without manual editing.
In this guide, you’ll build a dynamic banner system into a Next.js 15 app using:
- Cloudinary for media transformations.
- Tailwind CSS and shadcn/ui for styling.
- Motion.dev for smooth animations.
- Redis for storing and displaying recent uploads.
Live Demo: zoompan-banners.vercel.app
GitHub Repo: github.com/musebe/zoompan-banners
We’ll build this project from the ground up using Next.js 15 with the App Router, plus Tailwind CSS, Cloudinary, Redis, and Motion.dev.
Before starting, make sure you have:
-
Node.js 18 or newer
We recommend using
nvm
to manage versions:
nvm install 20
nvm use 20
Code language: PHP (php)
- A free Cloudinary account
- A free Redis Cloud account
Create a new app with the App Router enabled and TypeScript support:
npx create-next-app@latest zoompan-banners --app --typescript --tailwind
cd zoompan-banners
Code language: CSS (css)
This sets up the base project with:
- Next.js 15 (App Router)
- Tailwind CSS
- TypeScript
Install the dependencies for image transformation, animation, Redis, and Cloudinary:
npm install @cloudinary/url-gen redis motion sonner clsx lucide-react
Code language: CSS (css)
Also install UI components using shadcn/ui:
npx shadcn-ui@latest init
Code language: CSS (css)
When prompted, you can choose:
- Tailwind CSS as your styling system.
- App Router.
- Your preferred component path (default is fine).
Then install components you’ll need:
npx shadcn-ui@latest add button switch label card dialog
Code language: JavaScript (javascript)
Create a .env.local
file in the project root and add your credentials:
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
NEXT_PUBLIC_CLOUDINARY_FOLDER=marketing-banners
CLOUDINARY_API_KEY=your-api-key
CLOUDINARY_API_SECRET=your-api-secret
REDIS_URL=your-redis-url
At this point, your project is ready for development. Next, we’ll set up image uploads with Cloudinary.
To apply any transformation whether it’s a zoom animation or AI-powered fill, we’ll first need an image hosted on Cloudinary.
Rather than uploading from your server, we’ll upload directly from the browser. This is faster, scales better, and keeps your backend light but it does require a secure signature from your backend for every upload.
In this section, we’ll build:
- A helper to generate a secure upload signature.
- An API route that exposes it to the client.
- A simple uploader UI that POSTs directly to Cloudinary.
Cloudinary requires a signed request before accepting direct uploads. We’ll generate that signature server-side using their api_sign_request
utility, and optionally cache it with Redis.
export async function getUploadSignature(params) {
// 1. Sign params using your Cloudinary API secret
// 2. Return { signature, timestamp }
}
Code language: JavaScript (javascript)
View full helper → src/lib/cloudinary-cache.ts
We expose a route at /api/upload-signature
that returns the signed upload config when called from the client.
import { getUploadSignature } from '@/lib/cloudinary-cache';
export async function GET() {
const params = {
folder: process.env.NEXT_PUBLIC_CLOUDINARY_FOLDER,
use_filename: true,
unique_filename: false,
};
const { signature, timestamp } = JSON.parse(await getUploadSignature(params));
return Response.json({ signature, timestamp, ...params });
}
Code language: JavaScript (javascript)
View the full route here → src/app/api/upload-signature/route.ts
Now the browser can:
- Fetch the signature.
- Append it to a
FormData
payload. - POST directly to Cloudinary’s image upload API.
const sig = await fetch('/api/upload-signature').then(r => r.json());
const form = new FormData();
form.append('file', file);
form.append('api_key', process.env.NEXT_PUBLIC_CLOUDINARY_API_KEY!);
form.append('timestamp', String(sig.timestamp));
form.append('signature', sig.signature);
Object.entries(sig).forEach(([k, v]) => form.append(k, String(v)));
const uploadUrl = `https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}/image/upload`;
const { public_id } = await fetch(uploadUrl, { method: 'POST', body: form }).then(r => r.json());
Code language: JavaScript (javascript)
Once uploaded, the public_id
returned by Cloudinary is all you need to apply Zoompan and Generative Fill.
View full uploader → src/components/ImageUploader.tsx
Once your image is uploaded, it’s just sitting there in Cloudinary. Now it’s time to make it do something expand, animate, and adapt based on where it’s shown.
This section explains how we use Cloudinary’s transformation URL API to turn a single static image into:
- A responsive banner using AI-based Generative Fill.
- A cinematic motion graphic using Zoompan.
Let’s break it down.
Imagine you’ve got a marketing banner that’s 800×800 looks great on Instagram, but now you need it:
- Wider for your homepage hero (e.g., 1600×900).
- Animated for a product landing page.
- Light enough for fast mobile loads.
Instead of opening Photoshop every time, you let Cloudinary do it:
- Extend the image with AI.
- Animate it with Zoompan.
- Compress and format it automatically.
That’s what these helpers do.
Cloudinary’s Generative Fill uses AI to expand your image while preserving its style. Think of it like saying:
“Take this square image and make it 16:9 but don’t stretch it. Fill the extra space with something that looks like it belongs.
export function createGenerativeFillURL(file: string, w: number, h: number) {
return cldClient
.image(toId(file))
.resize(pad().width(w).height(h).background(generativeFill()))
.delivery(quality('auto'))
.delivery(format('auto'))
.toURL();
}
Code language: JavaScript (javascript)
Instead of showing a centered image with whitespace, you get a full-width visual that feels complete, even if it started small.
It’s useful for:
- Making banners responsive.
- Adapting to wider screens.
- Removing manual cropping.
View on GitHub → src/lib/cloudinary-client-utils.ts
Zoompan creates a smooth zooming and panning animation, like what you see in Apple keynotes or documentary intros.
Think:
“Take this image and slowly zoom in, then reset — on a loop.”
No video editor. No rendering. Just a URL.
export function createZoompanGifURL(file: string, opts = {}) {
const {
duration = 6,
loop = true,
fps = 15,
maxZoom = 1.05,
format = 'gif',
} = opts;
const zoompanParams = [
`du_${duration}`,
`maxzoom_${maxZoom}`,
`fps_${fps}`,
].join(';');
const parts = [
`e_zoompan:${zoompanParams}`,
loop ? 'e_loop' : null,
'e_sharpen',
'fl_animated',
'q_auto',
].filter(Boolean);
return `https://res.cloudinary.com/${process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}/image/upload/${parts.join('/')}/${encodeURIComponent(toId(file))}.${format}`;
}
Code language: JavaScript (javascript)
It’s useful for:
- Bringing static content to life.
- Adding motion without video.
- Looping banner animations.
You can output it as .gif
, .webm
, or .mp4
, depending on browser needs.
View on GitHub → src/lib/cloudinary-client-utils.ts
Once you have the public_id
of an image, you now have three versions of it instantly available:
const staticUrl = createOptimisedURL(publicId);
const fillUrl = createGenerativeFillURL(publicId, 1600, 900);
const zoompanUrl = createZoompanGifURL(publicId);
Code language: JavaScript (javascript)
Depending on the toggle state, you use the right one:
const transformed = zoompan
? zoompanUrl
: generativeFill
? fillUrl
: staticUrl;
Code language: JavaScript (javascript)
This logic powers:
-
<Banner />
: to show one enhanced visual. -
<SplitView />
: to compare static vs transformed. -
<UploadGallery />
: to preview recent uploads.
By combining these helpers with just a public_id
, you create a flexible, dynamic banner system all powered by Cloudinary’s media engine.
Once you’ve transformed the image with Generative Fill or Zoompan, you’ll need a flexible way to display it.
The goal here is simple:
Given an uploaded image, decide what to show static, filled, or animated and render it the right way.
This logic lives in a reusable component, where the Cloudinary transformation and render method are chosen based on just two booleans: generativeFill
and zoompan
.
We’ll use three utility functions, each returning a version of the image:
import {
createOptimisedURL,
createGenerativeFillURL,
createZoompanGifURL,
} from '@/lib/cloudinary-client-utils';
Code language: JavaScript (javascript)
Inside the component, we determine the image versions like this:
const { staticUrl, gifUrl } = useMemo(() => {
const staticUrl = generativeFill
? createGenerativeFillURL(id, size.w, size.h)
: createOptimisedURL(id);
const gifUrl = zoompan ? createZoompanGifURL(id) : undefined;
return { staticUrl, gifUrl };
}, [id, generativeFill, zoompan, size]);
Code language: JavaScript (javascript)
What’s happening here:
- If
generativeFill
is true, we’ll generate a version of the image that’s been extended to fit a given width and height using AI. - If
zoompan
is true, we’ll also generate a separate URL for an animated version with the Ken Burns effect. - If both are false, we’ll fall back to a clean, optimized version of the original image.
This logic ensures you’ll never fetch more than you need.
Once we have our URLs, we’ll check if this should be a motion graphic or just an image.
if (zoompan && gifUrl) {
return <motion.img src={gifUrl} ... />;
}
return <Image src={staticUrl} ... />;
Code language: JavaScript (javascript)
- If
zoompan
is true, we’ll render a raw<img>
tag enhanced with Motion’s animation presets, ensuring the GIF or video plays correctly. - If not, we’ll use the framework’s
<Image />
component for lazy loading, size hints, and better performance.
That’s it, all dynamic rendering handled in a few lines.
View on GitHub → src/components/Banner.tsx
This makes your banner logic fully declarative: You toggle features, and the correct transformation and rendering happens automatically.
Once you apply Generative Fill or Zoompan, it’s helpful to show how the image has changed.
This section adds a split comparison view original on the left, transformed on the right using a draggable slider.
To render this comparison, we’ll generate two URLs:
const staticUrl = createOptimisedURL(publicId);
const dynamicUrl = zoompan
? createZoompanGifURL(publicId)
: createGenerativeFillURL(publicId, 1600, 900);
Code language: JavaScript (javascript)
This ensures:
- You always have the untouched original
- You conditionally get the enhanced version
The UI uses a horizontal slider with two overlaid images. When the user drags the handle, they can visually compare the changes side by side.
View on GitHub → src/components/SplitView.tsx
This makes your enhancements feel real, not just processed in the background, but visibly improved in context.
Every time a user uploads a new image, we store its publicId
in Redis so others can see the results live. The gallery page pulls that list and displays each image using the same transformation logic.
This adds a community showcase feel and is instant.
Each time an upload completes:
await fetch('/api/uploads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ publicId }),
});
Code language: JavaScript (javascript)
This writes the publicId
to Redis.
On the gallery page, we’ll pull from Redis using offset + limit (pagination):
const res = await fetch(`/api/uploads?limit=3&offset=${offset}`);
const { uploads } = await res.json();
Code language: JavaScript (javascript)
This gives us the next batch of uploaded images, which we render like so:
const thumbUrl = zoompan
? createZoompanGifURL(pid)
: generativeFill
? createGenerativeFillURL(pid, 800, 450)
: createOptimisedURL(pid);
Code language: JavaScript (javascript)
Each card reuses the same transformation helpers, but now for thumbnails.
View on GitHub → src/components/UploadGallery.tsx
To polish the experience, we’ll add gentle animations using motion.dev. It’s lightweight, composable, and integrates naturally into your component logic.
For example, in the gallery:
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
>
{/* image card here */}
</motion.div>
Code language: HTML, XML (xml)
And for looping zoom animation:
<motion.img
src={zoompanUrl}
variants={zoomInOut}
initial="rest"
animate="zoom"
/>
Code language: HTML, XML (xml)
Motion effects help your AI enhancements feel intentional. They don’t just appear, they arrive.
View animation presets → src/lib/motionPresets.ts
What began as a static image upload evolved into a fully interactive, AI-powered banner engine by using a few Cloudinary transformations and carefully structured components.
By combining:
- Generative Fill for intelligent layout extension,
- Zoompan for subtle motion and cinematic feel,
- Cloudinary for real-time delivery and optimization,
- and Motion.dev, Tailwind CSS, and Redis for UI and state,
you’ve built a modern, responsive banner system that adapts to devices, captures attention, and requires zero manual post-processing.
This isn’t just a demo, it’s a foundation for more dynamic media workflows. You can now plug this system into product pages, landing campaigns, or CMS tools where visuals need to be smart, fast, and engaging.
Explore the full codebase, remix it, or deploy your own:
Live Demo → zoompan-banners.vercel.app
GitHub Repo → github.com/musebe/zoompan-banners
Want to take it further? Try adding:
- Support for other formats like
webm
ormp4
. - Custom zoom targets using
g_face
org_auto
. - Caption overlays or branded templates.
And sign up for a free Cloudinary account today to get started.