Skip to content

RESOURCES / BLOG

Transform Static Visuals Into Dynamic Banners in Next.js Using Ken Burns-Style Zoompan and Generative Fill

Why It Matters

  • Apply Ken Burns–style Zoompan for cinematic motion and AI-powered Generative Fill to intelligently extend images.
  • Implement secure image uploads to Cloudinary and apply powerful transformations programmatically via URL APIs.
  • Create a split comparison view of generative fill and zoompan effects, and build a gallery to display recent uploads.

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)

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:

  1. Fetch the signature.
  2. Append it to a FormData payload.
  3. 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 GitHubsrc/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 GitHubsrc/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 GitHubsrc/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 GitHubsrc/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 GitHubsrc/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 Demozoompan-banners.vercel.app
GitHub Repogithub.com/musebe/zoompan-banners

Want to take it further? Try adding:

  • Support for other formats like webm or mp4.
  • Custom zoom targets using g_face or g_auto.
  • Caption overlays or branded templates.

And sign up for a free Cloudinary account today to get started.

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free