shadimage

Bento Gallery

Draggable bento grid of media that expands into a shared lightbox modal with a floating dock switcher.

components/ui/interactive-bento-gallery.tsx

"use client"

import React, { useEffect, useRef, useState } from 'react'
import { motion, AnimatePresence, useReducedMotion } from "motion/react"
import { X } from "lucide-react"
import { MacOsDock as MacOSDock } from "@/components/ui/mac-os-dock"
import { cn } from "@/lib/utils"


// MediaItemType defines the structure of a media item
export interface MediaItemType {
    id: number
    type: string
    title: string
    desc: string
    url: string
    span: string
    poster?: string
}
// MediaItem component renders either a video or image based on item.type
const MediaItem = ({ item, className, onClick }: { item: MediaItemType, className?: string, onClick?: () => void }) => {
    const videoRef = useRef<HTMLVideoElement>(null); // Reference for video element
    const [isInView, setIsInView] = useState(false); // To track if video is in the viewport
    const [isBuffering, setIsBuffering] = useState(true);  // To track if video is buffering

    // Intersection Observer to detect if video is in view and play/pause accordingly
    useEffect(() => {
        const options = {
            root: null,
            rootMargin: '50px',
            threshold: 0.1
        }

        const observer = new IntersectionObserver((entries) => {
            entries.forEach((entry) => {
                setIsInView(entry.isIntersecting); // Set isInView to true if the video is in view
            })
        }, options)

        if (videoRef.current) {
            observer.observe(videoRef.current); // Start observing the video element
        }

        return () => {
            if (videoRef.current) {
                observer.unobserve(videoRef.current); // Clean up observer when component unmounts
            }
        }
    }, [])
    // Handle video play/pause based on whether the video is in view or not
    useEffect(() => {
        let mounted = true

        const handleVideoPlay = async () => {
            if (!videoRef.current || !isInView || !mounted) return; // Don't play if video is not in view or component is unmounted

            try {
                if (videoRef.current.readyState >= 3) {
                    setIsBuffering(false)
                    await videoRef.current.play(); // Play the video if it's ready
                } else {
                    setIsBuffering(true)
                    await new Promise((resolve) => {
                        if (videoRef.current) {
                            videoRef.current.oncanplay = resolve; // Wait until the video can start playing
                        }
                    })
                    if (mounted) {
                        setIsBuffering(false)
                        await videoRef.current.play()
                    }
                }
            } catch (error) {
                console.warn("Video playback failed:", error)
            }
        }

        if (isInView) {
            handleVideoPlay()
        } else if (videoRef.current) {
            videoRef.current.pause()
        }

        return () => {
            mounted = false
            if (videoRef.current) {
                videoRef.current.pause()
                videoRef.current.removeAttribute('src')
                videoRef.current.load()
            }
        }
    }, [isInView])

    // Render either a video or image based on item.type

    if (item.type === 'video') {
        return (
            <div className={cn(className, "relative overflow-hidden")}>
                <video
                    ref={videoRef}
                    className="w-full h-full object-cover"
                    onClick={onClick}
                    playsInline
                    muted
                    loop
                    preload="auto"
                    style={{
                        opacity: isBuffering ? 0.8 : 1,
                        transition: 'opacity 0.2s',
                        transform: 'translateZ(0)',
                        willChange: 'transform',
                    }}
                >
                    <source src={item.url} type="video/mp4" />
                </video>
                {isBuffering && (
                    <div className="absolute inset-0 flex items-center justify-center bg-black/10">
                        <div className="w-6 h-6 border-2 border-foreground/30 border-t-white rounded-full animate-spin" />
                    </div>
                )}
            </div>
        )
    }

    // Poster, heavily blurred + over-scaled so it reads as an ambient color
    // gradient rather than the literal screenshot.
    return (
        <img
            src={item.poster || item.url}
            alt=""
            aria-hidden
            className={cn(className, "scale-150 object-cover blur-[44px] saturate-[1.4] brightness-90")}
            loading="lazy" // Lazy load the image for performance
            decoding="async" // Decode the image asynchronously
        />
    )
}



// GalleryModal component displays the selected media item in a modal
interface GalleryModalProps {
    selectedItem: MediaItemType
    isOpen: boolean
    onClose: () => void
    setSelectedItem: (item: MediaItemType | null) => void
    mediaItems: MediaItemType[]; // Menu of media items to display in the modal
}
export const GalleryModal = ({ selectedItem, isOpen, onClose, setSelectedItem, mediaItems }: GalleryModalProps) => {
    const reduced = useReducedMotion()
    if (!isOpen) return null; // Return null if the modal is not open

    return (
        <>
            {/* Backdrop — click to close */}
            <motion.div
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={reduced ? { duration: 0 } : { duration: 0.2 }}
                /* blur: the gallery grid the modal opened from — the tiles stay
                   recognisable behind the scrim so the modal reads as on top of
                   your place in the grid, not as a new screen. */
                className="fixed inset-0 z-40 bg-black/70 backdrop-blur-xl"
                onClick={onClose}
            />

            {/* Floating macOS dock — pinned to the bottom; switches projects, dot under the selected */}
            <motion.div
                initial={{ y: reduced ? 0 : 16, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={{ y: reduced ? 0 : 16, opacity: 0 }}
                transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 30 }}
                className="fixed bottom-6 left-1/2 z-[60] -translate-x-1/2"
            >
                <MacOSDock
                    items={mediaItems.map((item) => ({
                        id: String(item.id),
                        label: item.title,
                        icon: <img src={item.poster || item.url} alt="" className="h-full w-full rounded-[10px] object-cover" draggable={false} />,
                    }))}
                    activeIds={[String(selectedItem.id)]}
                    onSelect={(app) => {
                        const item = mediaItems.find((m) => String(m.id) === app.id)
                        if (item) setSelectedItem(item)
                    }}
                />
            </motion.div>

            {/* Project details */}
            <div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4">
                <motion.div
                    initial={reduced ? { opacity: 0 } : { scale: 0.96, opacity: 0, y: 12 }}
                    animate={{ scale: 1, opacity: 1, y: 0 }}
                    exit={reduced ? { opacity: 0 } : { scale: 0.96, opacity: 0, y: 12 }}
                    transition={reduced ? { duration: 0 } : { type: 'spring', stiffness: 400, damping: 30 }}
                    onClick={(e) => e.stopPropagation()}
                    className="pointer-events-auto relative w-full max-w-3xl overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
                >
                    {/* Close button */}
                    <button
                        onClick={onClose}
                        aria-label="Close"
                        /* blur: the open media underneath — video keeps playing behind
                           this button, so a flat scrim would read as a dead patch on it. */
                        className="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full border border-foreground/10 bg-black/40 text-foreground backdrop-blur transition-colors hover:bg-black/70"
                    >
                        <X className="h-4 w-4" />
                    </button>

                    {/* Media preview */}
                    <div className="relative aspect-[16/9] w-full bg-black">
                        <AnimatePresence mode="wait">
                            <motion.div
                                key={selectedItem.id}
                                initial={{ opacity: 0 }}
                                animate={{ opacity: 1 }}
                                exit={{ opacity: 0 }}
                                transition={reduced ? { duration: 0 } : { duration: 0.2 }}
                                className="absolute inset-0"
                            >
                                <MediaItem item={selectedItem} className="h-full w-full" />
                            </motion.div>
                        </AnimatePresence>
                    </div>

                    {/* Details */}
                    <div className="p-5 sm:p-7">
                        <AnimatePresence mode="wait">
                            <motion.div
                                key={selectedItem.id}
                                initial={{ opacity: 0, y: reduced ? 0 : 8 }}
                                animate={{ opacity: 1, y: 0 }}
                                exit={{ opacity: 0, y: reduced ? 0 : -8 }}
                                transition={reduced ? { duration: 0 } : { duration: 0.2 }}
                            >
                                <span className="text-[12px] font-medium uppercase tracking-wider text-muted-foreground">
                                    {String(mediaItems.findIndex((m) => m.id === selectedItem.id) + 1).padStart(2, '0')}
                                    {' / '}
                                    {String(mediaItems.length).padStart(2, '0')}
                                </span>
                                <h3 className="mt-1.5 text-2xl font-semibold text-card-foreground sm:text-3xl">
                                    {selectedItem.title}
                                </h3>
                                <p className="mt-2 max-w-2xl text-[14px] leading-relaxed text-muted-foreground sm:text-[16px]">
                                    {selectedItem.desc}
                                </p>
                            </motion.div>
                        </AnimatePresence>
                    </div>
                </motion.div>
            </div>
        </>
    )
}



interface InteractiveBentoGalleryProps {
    mediaItems: MediaItemType[]
    title: string
    description: string

}

export const InteractiveBentoGallery: React.FC<InteractiveBentoGalleryProps> = ({ mediaItems = [], title = "", description = "" }) => {
    const reduced = useReducedMotion()
    const [selectedItem, setSelectedItem] = useState<MediaItemType | null>(null)
    const [items, setItems] = useState(mediaItems)
    const [isDragging, setIsDragging] = useState(false)

    return (
        <div className="container mx-auto px-4 py-8 max-w-5xl">
            <div className="mb-8 text-center">
                <motion.h1
                    className="text-2xl sm:text-3xl md:text-4xl font-bold bg-clip-text text-transparent
                             bg-gradient-to-r from-foreground via-foreground/90 to-foreground"
                    initial={{ opacity: 0, y: reduced ? 0 : 20 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={reduced ? { duration: 0 } : { duration: 0.5 }}
                >
                    {title}
                </motion.h1>
                <motion.p
                    className="mt-2 text-[14px] sm:text-[16px] text-muted-foreground"
                    initial={{ opacity: 0, y: reduced ? 0 : 20 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={reduced ? { duration: 0 } : { duration: 0.5, delay: 0.1 }}
                >
                    {description}
                </motion.p>
            </div>
            <AnimatePresence mode="wait">
                {selectedItem ? (
                    <GalleryModal
                        selectedItem={selectedItem}
                        isOpen={true}
                        onClose={() => setSelectedItem(null)}
                        setSelectedItem={setSelectedItem}
                        mediaItems={items}
                    />
                ) : (
                    <motion.div
                        className="grid grid-cols-1 sm:grid-cols-3 md:grid-cols-4 gap-4 auto-rows-[96px]"
                        initial="hidden"
                        animate="visible"
                        exit="hidden"
                        variants={{
                            hidden: { opacity: 0 },
                            visible: {
                                opacity: 1,
                                transition: { staggerChildren: reduced ? 0 : 0.1 }
                            }
                        }}
                    >
                        {items.map((item, index) => (
                            <motion.div
                                key={item.id}
                                layoutId={`media-${item.id}`}
                                className={`relative overflow-hidden rounded-xl cursor-move ${item.span}`}
                                onClick={() => !isDragging && setSelectedItem(item)}
                                variants={{
                                    hidden: reduced ? { opacity: 0 } : { y: 50, scale: 0.9, opacity: 0 },
                                    visible: {
                                        y: 0,
                                        scale: 1,
                                        opacity: 1,
                                        transition: reduced
                                            ? { duration: 0 }
                                            : {
                                                type: "spring",
                                                stiffness: 350,
                                                damping: 25,
                                                delay: index * 0.05
                                            }
                                    }
                                }}
                                whileHover={reduced ? undefined : { scale: 1.02 }}
                                drag
                                dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
                                dragElastic={1}
                                onDragStart={() => setIsDragging(true)}
                                onDragEnd={(_e, info) => {
                                    setIsDragging(false)
                                    const moveDistance = info.offset.x + info.offset.y
                                    if (Math.abs(moveDistance) > 50) {
                                        const newItems = [...items]
                                        const draggedItem = newItems[index]
                                        const targetIndex = moveDistance > 0 ?
                                            Math.min(index + 1, items.length - 1) :
                                            Math.max(index - 1, 0)
                                        newItems.splice(index, 1)
                                        newItems.splice(targetIndex, 0, draggedItem)
                                        setItems(newItems)
                                    }
                                }}
                            >
                                <MediaItem
                                    item={item}
                                    className="absolute inset-0 w-full h-full"
                                    onClick={() => !isDragging && setSelectedItem(item)}
                                />
                                <motion.div
                                    className="absolute inset-0 flex flex-col justify-end p-2 sm:p-3 md:p-4"
                                    initial={{ opacity: 0 }}
                                    whileHover={{ opacity: 1 }}
                                    transition={reduced ? { duration: 0 } : { duration: 0.2 }}
                                >
                                    <div className="absolute inset-0 flex flex-col justify-end p-2 sm:p-3 md:p-4">
                                        <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
                                        <h3 className="relative text-foreground text-[12px] sm:text-[14px] md:text-[16px] font-medium line-clamp-1">
                                            {item.title}
                                        </h3>
                                        <p className="relative text-foreground/70 text-[10px] sm:text-[12px] md:text-[14px] mt-0.5 line-clamp-2">
                                            {item.desc}
                                        </p>
                                    </div>
                                </motion.div>
                            </motion.div>
                        ))}
                    </motion.div>
                )}
            </AnimatePresence>
        </div>
    )
}


Where it goes
gallerybento-grid
Visual style
bento
Interaction
dragmodal

DependenciesBundle weight · medium

icons
lucide-react
animation
motion