Gemini 2.5 pro - image carousel

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>
1 Like

Love it. fantastic. Did you try others also or just went straight to Gemini?

straight to gemini 2.5 - it’s the best coder out there currently

there’s still a few bugs in the code I’m working out but it got it 100% there and now I just have to make it work in bubble (some issues with onload firing and content stacking)

end result

pan still a bit clunky but it’ll do

1 Like

Looking for this?

Plugin page: Smooth Carousel Plugin | Bubble

Demo: Bubble | No-code apps