I wanted a nice carousel for my bubble app. I tried building one with the bubble elements and it was ugly and clunky and really struggled to do the functionality I wanted. I looked for custom plugins that were already built and tried a few - some were decent but still not what I wanted exactly.
So I turned to gemini 2.5 to create custom html and got exactly what I wanted in about 30 minutes. This does raise the question though of how effective vibe coding is becoming.
Here’s the html if anyone wants to use it as a starting block for their own image carousel. I added some image url placeholders.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Carousel</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* --- Custom Styles --- */
/* Hide scrollbar */
.carousel-images::-webkit-scrollbar {
display: none; /* Webkit browsers */
}
.carousel-images {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
overflow-x: auto; /* Keep the scroll functionality */
}
/* Ensure smooth scrolling */
.carousel-images {
scroll-behavior: smooth;
}
/* Prevent image dragging ghost */
.carousel-images img, #modalImage {
user-select: none;
-webkit-user-drag: none;
}
/* Style for the enlarged image during panning */
#modalImage.panning {
cursor: grabbing;
}
/* Basic transition for zoom */
#modalImage {
transition: transform 0.2s ease-out;
}
/* Style disabled buttons */
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Prevent hover style on disabled buttons (including background and icon filter) */
button:disabled:hover {
background-color: initial; /* Use initial or specific disabled bg */
}
button:disabled:hover img {
filter: none;
}
/* Specific hover prevention for modal buttons */
#modalPrevBtn:disabled:hover, #modalNextBtn:disabled:hover {
background-color: rgba(255, 255, 255, 0.8);
}
/* Specific hover prevention for carousel/close buttons */
#prevBtn:disabled:hover, #nextBtn:disabled:hover, #closeModal:disabled:hover {
background-color: rgba(255, 255, 255, 0.8);
}
</style>
</head>
<body class="bg-white font-sans">
<div id="carousel" class="relative w-full mx-auto carousel-container group overflow-hidden">
<div id="carouselImages" class="carousel-images flex items-center space-x-2 pb-4 snap-x snap-mandatory scroll-smooth">
</div>
<button id="prevBtn" aria-label="Previous image" class="absolute top-1/2 left-2 transform -translate-y-1/2 bg-white/80 hover:bg-gray-200 rounded-full p-2 shadow-md opacity-0 group-hover:opacity-100 transition-all duration-300 z-10">
<img src="https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/chevron-left.svg" alt="Previous" class="w-6 h-6 text-gray-700"/>
</button>
<button id="nextBtn" aria-label="Next image" class="absolute top-1/2 right-2 transform -translate-y-1/2 bg-white/80 hover:bg-gray-200 rounded-full p-2 shadow-md opacity-0 group-hover:opacity-100 transition-all duration-300 z-10">
<img src="https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/chevron-right.svg" alt="Next" class="w-6 h-6 text-gray-700"/>
</button>
</div>
<div id="imageModal" class="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center p-4 z-[9999] hidden" aria-modal="true" role="dialog">
<div id="modalContent" class="relative bg-white rounded-lg w-full h-full flex items-center justify-center overflow-hidden cursor-grab shadow-xl">
<img id="modalImage" src="" alt="Enlarged view" class="max-w-full max-h-full object-contain block" style="transform-origin: center center;" oncontextmenu="return false;">
<button id="closeModal" aria-label="Close enlarged image view" class="absolute top-2 right-2 bg-white/80 hover:bg-gray-200 rounded-full p-2 shadow-md z-20 transition-all duration-300">
<img src="https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/x.svg" alt="Close" class="w-6 h-6 text-gray-700"/>
</button>
<button id="modalPrevBtn" aria-label="Previous enlarged image" class="absolute top-1/2 left-2 transform -translate-y-1/2 bg-white/80 hover:bg-gray-200 rounded-full p-2 shadow-md z-20 transition-all duration-300">
<img src="https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/chevron-left.svg" alt="Previous" class="w-6 h-6 text-gray-700"/>
</button>
<button id="modalNextBtn" aria-label="Next enlarged image" class="absolute top-1/2 right-2 transform -translate-y-1/2 bg-white/80 hover:bg-gray-200 rounded-full p-2 shadow-md z-20 transition-all duration-300">
<img src="https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/chevron-right.svg" alt="Next" class="w-6 h-6 text-gray-700"/>
</button>
</div>
</div>
<script>
// --- Configuration ---
const imageUrls = [
"https://imgs.search.brave.com/6kQvoQb8wE3C624v4AFUWudcdhhGMpDq1o39SHru_h4/rs:fit:860:0:0:0/g:ce/aHR0cHM6Ly9pbWct/Y2RuLnBpeGxyLmNv/bS9pbWFnZS1nZW5l/cmF0b3IvaGlzdG9y/eS82NTgyMGUzM2Iz/ZjExODE3OTU5YTU4/ZjgvNjU4MjBlMzNi/M2YxMTgxNzk1OWE1/OGY0L21lZGl1bS53/ZWJw", "https://imgs.search.brave.com/iC7SHDXYElim7Vbcy46n9Y0VgaJwch8JxwrhsZ8mizw/rs:fit:500:0:0:0/g:ce/aHR0cHM6Ly93d3cu/Z3N0YXRpYy5jb20v/YWl0ZXN0a2l0Y2hl/bi93ZWJzaXRlL2Nv/bnRlbnQvaW1hZ2Ut/ZngtcGxlYXNlLXNp/Z24taW4ud2VicA", "https://images.pexels.com/photos/31393274/pexels-photo-31393274/free-photo-of-urban-architecture-building-with-yamamoto-kisho-sign.jpeg", "https://pixlr.com/"
];
const scrollAmount = 300; // Base scroll amount
const zoomSensitivity = 0.1;
const minZoom = 1;
const maxZoom = 5;
console.log("Dynamic imageUrls raw:", imageUrls);
// --- Element References ---
const carouselImages = document.getElementById('carouselImages');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
const imageModal = document.getElementById('imageModal');
const modalContent = document.getElementById('modalContent');
const modalImage = document.getElementById('modalImage');
const closeModal = document.getElementById('closeModal');
const modalPrevBtn = document.getElementById('modalPrevBtn');
const modalNextBtn = document.getElementById('modalNextBtn');
// --- State Variables ---
let currentZoom = 1;
let isPanning = false;
let startX, startY, initialTranslateX = 0, initialTranslateY = 0;
let currentTranslateX = 0, currentTranslateY = 0;
let currentModalIndex = 0;
// --- Populate Carousel ---
function populateCarousel() {
carouselImages.innerHTML = '';
imageUrls.forEach((url, index) => {
const img = document.createElement('img');
img.src = url;
img.alt = `Carousel Image ${index + 1}`;
// Set fixed height h-[300px], width auto, object-cover. Removed aspect-video.
img.className = 'h-[300px] w-auto object-cover rounded-lg flex-shrink-0 snap-center cursor-pointer shadow-sm hover:shadow-md transition-shadow duration-200';
img.oncontextmenu = () => false;
img.onerror = () => {
img.src = 'https://placehold.co/300x300/cccccc/ffffff?text=Error'; // Adjusted placeholder size
img.alt = 'Error loading image';
console.error(`Failed to load image: ${url}`);
};
img.addEventListener('click', () => openModal(url, index));
carouselImages.appendChild(img);
});
}
// --- Carousel Navigation ---
// Function to calculate scroll distance based on the first visible image's width + gap
function calculateScrollDistance() {
const firstVisibleImage = Array.from(carouselImages.children).find(img => {
const rect = img.getBoundingClientRect();
const containerRect = carouselImages.getBoundingClientRect();
// Check if the image is at least partially visible within the container's left edge
return rect.left >= containerRect.left && rect.left < containerRect.right;
});
// Use offsetWidth of the first visible image if found, otherwise fallback
const imageWidth = firstVisibleImage ? firstVisibleImage.offsetWidth : (carouselImages.querySelector('img')?.offsetWidth || scrollAmount);
const gap = 8; // Corresponds to space-x-2 (0.5rem = 8px typically)
return imageWidth + gap;
}
prevBtn.addEventListener('click', () => {
carouselImages.scrollLeft -= calculateScrollDistance();
});
nextBtn.addEventListener('click', () => {
carouselImages.scrollLeft += calculateScrollDistance();
});
// --- Modal Logic ---
function openModal(imageUrl, index) {
currentModalIndex = index;
modalImage.src = imageUrl;
resetZoomAndPan();
updateModalNavButtons();
imageModal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeModalHandler() {
imageModal.classList.add('hidden');
document.body.style.overflow = '';
resetZoomAndPan();
}
closeModal.addEventListener('click', closeModalHandler);
imageModal.addEventListener('click', (e) => {
if (e.target === imageModal) {
closeModalHandler();
}
});
// --- Modal Navigation ---
modalPrevBtn.addEventListener('click', () => {
if (currentModalIndex > 0) {
currentModalIndex--;
modalImage.src = imageUrls[currentModalIndex];
resetZoomAndPan();
updateModalNavButtons();
}
});
modalNextBtn.addEventListener('click', () => {
if (currentModalIndex < imageUrls.length - 1) {
currentModalIndex++;
modalImage.src = imageUrls[currentModalIndex];
resetZoomAndPan();
updateModalNavButtons();
}
});
// --- Update Modal Nav Button States ---
function updateModalNavButtons() {
modalPrevBtn.disabled = currentModalIndex === 0;
modalNextBtn.disabled = currentModalIndex === imageUrls.length - 1;
}
// --- Zoom Logic ---
modalContent.addEventListener('wheel', (e) => {
if (e.target.closest('button')) return;
e.preventDefault();
const rect = modalContent.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const delta = -Math.sign(e.deltaY);
const zoomFactor = 1 + delta * zoomSensitivity;
const newZoom = Math.max(minZoom, Math.min(maxZoom, currentZoom * zoomFactor));
if (newZoom === currentZoom) return;
const prevZoom = currentZoom;
currentZoom = newZoom;
if (currentZoom === minZoom) {
currentTranslateX = 0;
currentTranslateY = 0;
} else {
const imageX = (mouseX - currentTranslateX) / prevZoom;
const imageY = (mouseY - currentTranslateY) / prevZoom;
currentTranslateX = mouseX - imageX * currentZoom;
currentTranslateY = mouseY - imageY * currentZoom;
adjustPanBounds();
}
applyTransform();
});
// --- Pan Logic ---
modalContent.addEventListener('mousedown', (e) => {
if (e.target.closest('button')) {
isPanning = false;
return;
}
if (currentZoom > minZoom && e.target === modalImage) {
isPanning = true;
startX = e.clientX;
startY = e.clientY;
initialTranslateX = currentTranslateX;
initialTranslateY = currentTranslateY;
modalImage.classList.add('panning');
modalContent.style.cursor = 'grabbing';
}
});
modalContent.addEventListener('mousemove', (e) => {
if (!isPanning) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
currentTranslateX = initialTranslateX + dx;
currentTranslateY = initialTranslateY + dy;
adjustPanBounds();
applyTransform();
});
window.addEventListener('mouseup', () => {
if (isPanning) {
isPanning = false;
modalImage.classList.remove('panning');
modalContent.style.cursor = 'grab';
}
});
// --- Helper Functions ---
function applyTransform() {
modalImage.style.transform = `translate(${Math.round(currentTranslateX)}px, ${Math.round(currentTranslateY)}px) scale(${currentZoom})`;
}
function adjustPanBounds() {
if (currentZoom <= minZoom) {
currentTranslateX = 0;
currentTranslateY = 0;
return;
}
const containerRect = modalContent.getBoundingClientRect();
const naturalWidth = modalImage.naturalWidth || modalImage.offsetWidth;
const naturalHeight = modalImage.naturalHeight || modalImage.offsetHeight;
const scaledWidth = naturalWidth * currentZoom;
const scaledHeight = naturalHeight * currentZoom;
const overflowX = Math.max(0, scaledWidth - containerRect.width);
const overflowY = Math.max(0, scaledHeight - containerRect.height);
const maxX = overflowX / 2;
const minX = -overflowX / 2;
const maxY = overflowY / 2;
const minY = -overflowY / 2;
currentTranslateX = Math.max(minX, Math.min(maxX, currentTranslateX));
currentTranslateY = Math.max(minY, Math.min(maxY, currentTranslateY));
}
function resetZoomAndPan() {
currentZoom = minZoom;
isPanning = false;
currentTranslateX = 0;
currentTranslateY = 0;
initialTranslateX = 0;
initialTranslateY = 0;
applyTransform();
modalImage.classList.remove('panning');
modalContent.style.cursor = 'grab';
}
function openModal(imageUrl, index) {
if (!imageModal || !modalImage) return; // Guard clause
// ** Portaling Fix **
// Move the modal div to be a direct child of the body tag
document.body.appendChild(imageModal); // <-- ADD THIS LINE
currentModalIndex = index;
modalImage.src = imageUrl;
resetZoomAndPan();
updateModalNavButtons();
imageModal.classList.remove('hidden'); // Show modal *after* moving it
document.body.style.overflow = 'hidden'; // Prevent background scroll
}
// --- Initialization ---
// Remove the window.onload = ... block entirely and use this instead:
setTimeout(() => {
console.log("setTimeout triggered."); // Log that setTimeout fired
// Log the result of finding each element BEFORE the check
const carouselEl = document.getElementById('carouselImages');
const modalEl = document.getElementById('modalContent');
console.log("Element check inside setTimeout - carouselImages:", carouselEl);
console.log("Element check inside setTimeout - modalContent:", modalEl);
// Now perform the check
if (carouselEl && modalEl) {
console.log("Dynamic imageUrls inside setTimeout:", imageUrls);
if (!Array.isArray(imageUrls)) {
console.error("imageUrls is NOT an array!", imageUrls);
return;
}
// Ensure populateCarousel has detailed logging (from previous steps)
populateCarousel();
modalContent.style.cursor = 'grab';
updateModalNavButtons();
console.log("Carousel script executed successfully via setTimeout");
} else {
console.error("Carousel elements not found in setTimeout. Check IDs and timing.");
if (!carouselEl) console.error("Reason: carouselImages element was null.");
if (!modalEl) console.error("Reason: modalContent element was null.");
}
}, 500); // Delay by 500ms (increase to 1000 or 1500 if elements are still null)
</script>
</body>
</html>