image-gallery

PreviousNext

A image gallery component for shadcn/ui. Easily create a image gallery with a list of images

Docs
shadix-uicomponent

Preview

Loading preview…
registry/new-york/components/image-gallery.tsx
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";

import MorphImage from "@/registry/new-york/components/morph-image";
import { useMasonry } from "@/registry/new-york/hooks/useMasonry";
import { useWindowSize } from "@/registry/new-york/hooks/useWindowSize";
import { cn } from "@/shadcn/lib/utils";

export interface ImageGalleryProps {
    /** @public (required) - Array of images to display in the gallery */
    images: ImageItem[];
    /** @public (optional) - Space between images in pixels (default: 16) */
    gap?: number;
    /** @public (optional) - Columns to display in each screen size */
    columns?: {
        desktop?: number;
        tablet?: number;
        mobile?: number;
    };
    /** @public (optional) - Enable laxy loading of images (default: true) */
    lazyLoading?: boolean;
    /** @public (optional) - class name for the container */
    className?: string;
    /** @public (optional) - Callback when an image is clicked */
    onImageClick?: (image: ImageItem, index: number) => void;
    /** @public (optional) - Callback to filter images before rendering */
    filterImages?: (images: ImageItem, index: number) => boolean;
    /** @public (optional) - Callback sort images */
    sortImages?: (a: ImageItem, b: ImageItem) => number;
}

export interface ImageItem {
    src: string;
    alt: string;
    width?: number;
    height?: number;
}

const observerOptions = {
    root: null,
    rootMargin: "500px",
    threshold: 0.01,
};

const ImageGallery: React.FC<ImageGalleryProps> = ({
    images,
    gap = 16,
    columns = {
        desktop: 3,
        tablet: 2,
        mobile: 1,
    },
    lazyLoading = true,
    className,
    onImageClick,
    filterImages,
    sortImages,
}) => {
    const [containerWidth, setContainerWidth] = useState(0);
    const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());

    const containerRef = useRef<HTMLDivElement>(null);

    const windowSize = useWindowSize();

    useEffect(() => {
        const container = containerRef.current;
        if (!container) return;

        // Set initial width - use a small delay to ensure layout is complete
        const timer = setTimeout(() => {
            setContainerWidth(container.clientWidth);
        }, 100);

        // Create ResizeObserver to track container size changes
        const resizeObserver = new ResizeObserver(() => {
            setContainerWidth(container.clientWidth);
        });

        resizeObserver.observe(container);

        return () => {
            clearTimeout(timer);
            resizeObserver.disconnect();
        };
    }, []);

    const noOfColumns = useMemo(() => {
        if (windowSize.width < 768) return columns.mobile || 1;
        if (windowSize.width < 1024) return columns.tablet || 2;
        return columns.desktop as number;
    }, [windowSize.width, columns.mobile, columns.tablet, columns.desktop]);

    // Randomize dimensions for images that don't have them
    const getRandomDimensions = useCallback(() => {
        const aspectRatios = [0.75, 1, 1.5, 2];
        const randomAspectRatio =
            aspectRatios[Math.floor(Math.random() * aspectRatios.length)];
        const baseWidth = 600;
        const width = baseWidth + Math.floor(Math.random() * 400);
        const height = Math.round(width / randomAspectRatio);
        return { width, height };
    }, []);

    const processedImages = useMemo(() => {
        let result = [...images];

        // Add random dimensions to images that don't have them
        result = result.map((image) => {
            if (!image.width || !image.height) {
                return {
                    ...image,
                    ...getRandomDimensions(),
                };
            }
            return image;
        });

        if (filterImages) {
            result = result.filter((image, index) =>
                filterImages(image, index),
            );
        }

        if (sortImages) {
            result = result.sort((a, b) => sortImages(a, b));
        }

        return result;
    }, [images, filterImages, sortImages, getRandomDimensions]);

    const { layout, totalHeight } = useMasonry(
        processedImages,
        containerWidth,
        {
            gap,
            columns: noOfColumns as number,
        },
    );

    // Load initially visible images + those within buffer to prevent blinking
    useEffect(() => {
        if (layout.length === 0) return;

        let observer: IntersectionObserver | null = null;

        // Use requestAnimationFrame to ensure DOM is rendered before observing
        const rafId = requestAnimationFrame(() => {
            const imageElements =
                containerRef.current?.querySelectorAll("[data-src]");

            // Preload initially visible images + buffer area
            const imagesToLoad = new Set<string>();
            imageElements?.forEach((el) => {
                const src = (el as HTMLElement).dataset.src;
                if (src) {
                    const rect = el.getBoundingClientRect();
                    // Preload images within large buffer to prevent blinking
                    if (
                        rect.top < window.innerHeight + 500 &&
                        rect.bottom > -500
                    ) {
                        imagesToLoad.add(src);
                    }
                }
            });

            if (imagesToLoad.size > 0) {
                setLoadedImages((prev) => {
                    const newSet = new Set(prev);
                    imagesToLoad.forEach((src) => {
                        newSet.add(src);
                    });
                    return newSet;
                });
            }

            // Setup observer for lazy loading remaining images
            observer = new IntersectionObserver((entries) => {
                entries.forEach((entry) => {
                    if (entry.isIntersecting) {
                        const src = (entry.target as HTMLElement).dataset.src;

                        if (src) {
                            // Mark image as loaded when entering viewport
                            setLoadedImages((prev) => {
                                const newSet = new Set(prev);
                                newSet.add(src);
                                return newSet;
                            });
                            if (observer) {
                                observer.unobserve(entry.target);
                            }
                        }
                    }
                });
            }, observerOptions);

            imageElements?.forEach((el) => {
                if (observer) {
                    observer.observe(el);
                }
            });
        });

        return () => {
            cancelAnimationFrame(rafId);
            if (observer) {
                observer.disconnect();
            }
        };
    }, [layout.length]);

    const handleImageClick = useCallback(
        (image: ImageItem, index: number) => {
            onImageClick?.(image, index);
        },
        [onImageClick],
    );

    return (
        <div
            ref={containerRef}
            className={cn("w-full relative", className)}
            style={{
                height: `${totalHeight}px`,
            }}
        >
            {layout.map((item, index) => {
                const shouldLoadImage =
                    !lazyLoading || loadedImages.has(item.src);

                return (
                    <div
                        key={`${item.src}-${index}`}
                        data-src={item.src}
                        className={cn(
                            "rounded-lg",
                            lazyLoading &&
                                !shouldLoadImage &&
                                "bg-muted-foreground/10 animate-pulse",
                        )}
                        style={{
                            position: "absolute",
                            left: `${item.x}px`,
                            top: `${item.y}px`,
                            width: `${item.displayWidth}px`,
                            height: `${item.displayHeight}px`,
                            overflow: "hidden",
                        }}
                    >
                        {shouldLoadImage ? (
                            <MorphImage
                                src={item.src}
                                alt={item.alt}
                                className="w-full h-full"
                                layoutId={`image-gallery-${index}`}
                                onClick={() => handleImageClick(item, index)}
                            />
                        ) : null}
                    </div>
                );
            })}
        </div>
    );
};

export { ImageGallery };

Installation

npx shadcn@latest add @shadix-ui/image-gallery

Usage

import { ImageGallery } from "@/components/image-gallery"
<ImageGallery />