
Image galleries are an excellent way to show off the visuals of our websites. They can be used for portfolios and product pages, or anything else that we want our users to browse through. But a good JavaScript image viewer is more than just a feature that shows off images. It loads them fast and responds snappily to user input.
In this tutorial, we’ll build a JavaScript image viewer we can implement on our own sites if we want to. To start with, we’ll handle navigation and keyboard shortcuts to create smooth transitions. Then we’ll connect it to Cloudinary for optimized delivery with responsive images and transformations that happen on the fly. This will keep our gallery fast, no matter how many images we need to throw at it.
Key Takeaways:
- A viewer manages state and DOM updates, handling user events together
- Preloading adjacent images does away with navigation delays
- URL-based transformations powered by Cloudinary let us resize and crop without needing extra image files
In this article:
- Understanding How a JavaScript Image Viewer Works
- Setting Up the Project Environment
- Creating the HTML and CSS Layout
- Loading Images with JavaScript
- Adding Controls and Interactivity
- Enhancing the Viewer with Cloudinary Delivery
- Applying Cloudinary Transformations for Better Visuals
- Building an Automated Image Pipeline
- Bringing It All Together in Real Web Galleries
Understanding How a JavaScript Image Viewer Works
The best way to think about how an image viewer works is that it tracks which image is being displayed, and then reacts to navigation commands. We keep an array of image sources with an index pointing to the current image, and functions to move forward or backward.
The viewer then updates the DOM when the index changes, which swaps the src attribute and updates thumbnails to trigger transitions. The event listeners handle our inputs like clicks, keyboard presses, and touch screen inputs.
Performance is important for navigation because users want an instant response when they do something on our sites. To do this, we preload the next and previous images before they’ve been asked for. This is a great way to hide network latency while the users spend time looking at each of our images.
Setting Up the Project Environment
We don’t need frameworks or build tools for this example. Three files handle everything:
image-viewer/ ├── index.html ├── styles.css └── viewer.js
For sample images, we’ll use Cloudinary’s demo cloud. These URLs work immediately without signup:

https://res.cloudinary.com/demo/image/upload/sample.jpg

https://res.cloudinary.com/demo/image/upload/cld-sample.jpg
All that is left for us to do is to link the JavaScript at the end of the body so the DOM is ready when our code runs.
Creating the HTML and CSS Layout
The viewer needs a container, a main image area, navigation controls, and an optional thumbnail strip:
<div class="viewer">
<div class="viewer-main">
<button class="nav-btn prev"><</button>
<img class="viewer-image" src="" alt="Gallery image">
<button class="nav-btn next">></button>
</div>
<div class="viewer-counter">1 / 5</div>
<div class="viewer-thumbs"></div>
</div>
CSS helps us here and keeps things responsive. We use object-fit: contain so that the images scale without distorting or rendering in incorrectly:
.viewer-main {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #111;
min-height: 400px;
}
.viewer-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
transition: opacity 0.3s ease;
}
Our navigation buttons sit at the edges thanks to absolute positioning. On mobile, we’ll go ahead and add touch swipe support in JavaScript.
Loading Images with JavaScript
We need to initialize the viewer with an array of image data and then set up the display:
const viewer = {
images: [],
currentIndex: 0,
init(images) {
this.images = images;
this.currentIndex = 0;
this.render();
this.preload();
},
render() {
const img = document.querySelector('.viewer-image');
img.src = this.images[this.currentIndex].src;
this.updateCounter();
this.updateThumbnails();
}
};
Preloading stops delays from happening when users navigate because we load the next and previous images in the background:
preload() {
const preloadIndexes = [
this.currentIndex - 1,
this.currentIndex + 1
].filter(i => i >= 0 && i < this.images.length);
preloadIndexes.forEach(index => {
const img = new Image();
img.src = this.images[index].src;
});
}
The browser caches the images that we preloaded so when users click “next,” the image appears instantly like magic.
Adding Controls and Interactivity
We use navigation functions to update the index and to trigger a re-render:
next() {
if (this.currentIndex < this.images.length - 1) {
this.currentIndex++;
this.render();
this.preload();
}
},
prev() {
if (this.currentIndex > 0) {
this.currentIndex--;
this.render();
this.preload();
}
}
We’ll also have to set up buttons and keyboard shortcuts:
document.querySelector('.next').addEventListener('click', () => viewer.next());
document.querySelector('.prev').addEventListener('click', () => viewer.prev());
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') viewer.next();
if (e.key === 'ArrowLeft') viewer.prev();
});
For smoother transitions we’ll want to fade out of the current image, swap the source, and then fade back in:
async transition(newIndex) {
const img = document.querySelector('.viewer-image');
img.style.opacity = 0;
await new Promise(r => setTimeout(r, 150));
this.currentIndex = newIndex;
img.src = this.images[newIndex].src;
img.onload = () => { img.style.opacity = 1; };
}
Enhancing the Viewer with Cloudinary Delivery
Storing images in Cloudinary gives us reliable URLs and automatic optimization rolled into a single solution. Instead of hardcoding sources, we can build our URLs dynamically:
const cloudName = 'demo';
const images = [
{ publicId: 'sample', alt: 'Sample landscape' },
{ publicId: 'cld-sample', alt: 'Food plate' },
{ publicId: 'samples/animals/cat', alt: 'Cat' }
];
function getImageUrl(publicId, transforms = '') {
const base = `https://res.cloudinary.com/${cloudName}/image/upload`;
return transforms ? `${base}/${transforms}/${publicId}` : `${base}/${publicId}`;
}
For responsive delivery, we use w_auto with dpr_auto to show the right size based on the viewer’s container:
function getResponsiveUrl(publicId, width) {
return getImageUrl(publicId, `w_${width},q_auto,f_auto`);
}
The q_auto and f_auto parameters let Cloudinary choose the best quality and format (such as WebP, AVIF, etc) based on the user’s browser.
Applying Cloudinary Transformations for Better Visuals
Transformations let every image fit the viewer consistently. We can resize and crop while enhancing the images without needing additional files:
// Fit within viewer dimensions getImageUrl(publicId, 'w_800,h_600,c_fit') // Fill exact dimensions with smart cropping getImageUrl(publicId, 'w_800,h_600,c_fill,g_auto') // Thumbnail with face detection getImageUrl(publicId, 'w_100,h_100,c_thumb,g_face')
For the main viewer image we use c_fit to maintain the aspect ratio for us, and for thumbnails we rely on c_thumb with g_face or g_auto to auto-crop for us.
We can also add effects if we want to:
// Slight sharpening for crisp display getImageUrl(publicId, 'w_800,q_auto,f_auto,e_sharpen:50') // Auto-enhance lighting getImageUrl(publicId, 'w_800,q_auto,f_auto,e_improve')
Building an Automated Image Pipeline
For galleries with a lot of images we’ll fetch image data from an API or config file instead of hardcoding all of them in:
async function loadGallery(galleryId) {
// In production, this would fetch from your backend
const response = await fetch(`/api/galleries/${galleryId}`);
const data = await response.json();
const images = data.images.map(img => ({
src: getImageUrl(img.publicId, 'w_1200,q_auto,f_auto'),
thumb: getImageUrl(img.publicId, 'w_100,h_100,c_thumb,g_auto'),
alt: img.caption
}));
viewer.init(images);
}
Coding it this way lets us scale things up cleanly if we ever need to. All we have to do is add new images to Cloudinary and update our data source, and the viewer handles everything else from there.
For very large galleries, we can go ahead and implement lazy loading for thumbnails:
function lazyLoadThumbs() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const thumb = entry.target;
thumb.src = thumb.dataset.src;
observer.unobserve(thumb);
}
});
});
document.querySelectorAll('.thumb[data-src]').forEach(thumb => {
observer.observe(thumb);
});
}
Bringing It All Together in Real Web Galleries
The complete viewer combines all the essential things we need to make it work like state management, DOM updates, event handling, and optimized delivery from Cloudinary. Each piece of the application has a clear responsibility that it needs to handle:
The state (image array and current index) is our source of truth that the app uses to display an image. The render function translates the state into DOM updates, and event handlers modify the state and trigger re-renders for us. Cloudinary URLs handle optimization automatically so we don’t have to worry about that with our example code.
This makes the viewer easy to build on if we ever want to because everything has a clear purpose and is separated from one another. If we want to add new features like fullscreen mode with zoom, or a slideshow autoplay option, it will only mean that we need to add some new methods that interact with the same state and render cycle that we already set up.
Wrapping Up
Building a JavaScript image viewer has taught us some important concepts like state management and event handling – and we also touched on performance optimization. These concepts are important for us to understand because they apply to many other web development projects. The viewer we built can handle navigation well and preloads images intelligently so that we can scale with responsive image delivery all at the same time.
Cloudinary takes a complex asset pipeline and turns it into URL parameters for us to use, which makes it so much easier to manage. We can start building optimized galleries with our own images when we sign up for a free Cloudinary account.
Frequently Asked Questions
How do I add zoom functionality to the viewer?
We need to track a scale factor and apply CSS transforms to the image to get zoom functionality to work. When we zoom in, we need to increase the scale and also use drag-to-pan while tracking the position of the mouse. We can also reset the scale on navigation or by double-clicking. For high-resolution zooming all we need to do is request a larger image from Cloudinary when the user zooms in past 1x.
Can I use this viewer with images from multiple sources?
Yes, the great thing about this example is that it works with any image URL. For using mixed sources we would only need to normalize our image data into a consistent format with src and thumb properties. Cloudinary’s fetch feature can even optimize third-party images by proxying them through the cloud: https://res.cloudinary.com/your-cloud/image/fetch/w_800,q_auto/https://other-site.com/image.jpg.
How do I handle very large galleries with hundreds of images?
For large galleries we can use virtual scrolling for the thumbnail strip, meaning that we only render thumbnails that are currently in view. Then we can load image metadata in batches and use infinite scroll patterns. Cloudinary’s responsive breakpoints reduce bandwidth automatically, but we can also use pagination or category filtering to improve the UX when galleries go over 50 to 100 images.