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