Expandable Card Gallery
Expandable Card Gallery component with smooth animations and modern UI.
Installation
Add this component to your project using the CLI:
terminal
npx vui-registry-cli-v1 add expandable-card-gallerySource Code
expandable-card-gallery.tsx
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { X } from 'lucide-react';
const cards = [
{
title: "Abstract Gradient",
description: "A seamless blend of colors creating a fluid, modern aesthetic.",
src: "https://images.unsplash.com/photo-1557683316-973673baf926?q=80&w=2000&auto=format&fit=crop",
ctaText: "Download",
ctaLink: "#",
content: () => {
return (
<p>
This abstract gradient collection features high-resolution textures suitable for
web backgrounds, UI elements, and digital art. The fluid transitions between
warm and cool tones create a dynamic visual experience.
</p>
);
},
},
{
title: "Neon City",
description: "Cyberpunk-inspired urban photography with glowing lights.",
src: "https://images.unsplash.com/photo-1565626424178-6592f6e5c54c?q=80&w=2000&auto=format&fit=crop",
ctaText: "View Gallery",
ctaLink: "#",
content: () => {
return (
<p>
Dive into the neon-soaked streets of Tokyo and Hong Kong. This collection captures
the energy of nightlife, reflecting the vibrant colors of city lights on wet pavement.
Perfect for futuristic themes.
</p>
);
},
},
{
title: "Minimalist Geometry",
description: "Clean lines and simple shapes for a structured look.",
src: "https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85?q=80&w=2000&auto=format&fit=crop",
ctaText: "Explore",
ctaLink: "#",
content: () => {
return (
<p>
Less is more. This series focuses on architectural details, shadows, and geometric
forms. The stark contrast and balanced compositions evoke a sense of calm and order,
ideal for modern minimalist designs.
</p>
);
},
},
{
title: "Nature's Texture",
description: "Organic patterns found in the wild, from bark to leaves.",
src: "https://images.unsplash.com/photo-1504198266287-1659872e6590?q=80&w=2000&auto=format&fit=crop",
ctaText: "See More",
ctaLink: "#",
content: () => {
return (
<p>
Reconnect with nature through detailed close-ups of organic textures.
From the rough bark of ancient trees to the delicate veins of a leaf,
these images bring the outdoors into your digital space.
</p>
);
},
},
];
export default function ExpandableCardGallery() {
const [active, setActive] = useState<(typeof cards)[number] | boolean | null>(null);
const ref = useRef<HTMLDivElement>(null);
const id = useId();
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setActive(false);
}
}
if (active && typeof active === 'object') {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [active]);
// Simple outside click handler since I can't guarantee the hook exists
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setActive(null);
}
};
if (active) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [active]);
return (
<>
<AnimatePresence>
{active && typeof active === 'object' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/20 h-full w-full z-10"
/>
)}
</AnimatePresence>
<AnimatePresence>
{active && typeof active === 'object' ? (
<div className="fixed inset-0 grid place-items-center z-[100]">
<motion.button
key={`button-${active.title}-${id}`}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.05 } }}
className="flex absolute top-2 right-2 lg:hidden items-center justify-center bg-white rounded-full h-6 w-6"
onClick={() => setActive(null)}
>
<X className="h-4 w-4 text-neutral-500" />
</motion.button>
<motion.div
layoutId={`card-${active.title}-${id}`}
ref={ref}
className="w-full max-w-[500px] h-full md:h-fit md:max-h-[90%] flex flex-col bg-white dark:bg-neutral-900 sm:rounded-3xl overflow-hidden"
>
<motion.div layoutId={`image-${active.title}-${id}`}>
<img
src={active.src}
alt={active.title}
className="w-full h-80 lg:h-80 sm:rounded-tr-lg sm:rounded-tl-lg object-cover object-top"
/>
</motion.div>
<div>
<div className="flex justify-between items-start p-4">
<div className="">
<motion.h3
layoutId={`title-${active.title}-${id}`}
className="font-bold text-neutral-700 dark:text-neutral-200 text-base"
>
{active.title}
</motion.h3>
<motion.p
layoutId={`description-${active.description}-${id}`}
className="text-neutral-600 dark:text-neutral-400 text-base"
>
{active.description}
</motion.p>
</div>
<motion.a
layoutId={`button-${active.title}-${id}`}
href={active.ctaLink}
target="_blank"
className="px-4 py-3 text-sm rounded-full font-bold bg-green-500 text-white"
>
{active.ctaText}
</motion.a>
</div>
<div className="pt-4 relative px-4">
<motion.div
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-neutral-600 text-xs md:text-sm lg:text-base h-40 md:h-fit pb-10 flex flex-col items-start gap-4 overflow-auto dark:text-neutral-400 [mask:linear-gradient(to_bottom,white,white,transparent)] [scrollbar-width:none] [-ms-overflow-style:none] [-webkit-overflow-scrolling:touch]"
>
{typeof active.content === 'function'
? active.content()
: active.content}
</motion.div>
</div>
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
<ul className="max-w-2xl mx-auto w-full gap-4">
{cards.map((card) => (
<motion.div
layoutId={`card-${card.title}-${id}`}
key={`card-${card.title}-${id}`}
onClick={() => setActive(card)}
className="p-4 flex flex-col md:flex-row justify-between items-center hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-xl cursor-pointer transition-colors"
>
<div className="flex gap-4 flex-col md:flex-row items-center md:items-start w-full">
<motion.div layoutId={`image-${card.title}-${id}`}>
<img
src={card.src}
alt={card.title}
className="h-40 w-40 md:h-14 md:w-14 rounded-lg object-cover object-top"
/>
</motion.div>
<div className="flex-1 text-center md:text-left">
<motion.h3
layoutId={`title-${card.title}-${id}`}
className="font-medium text-neutral-800 dark:text-neutral-200 text-center md:text-left"
>
{card.title}
</motion.h3>
<motion.p
layoutId={`description-${card.description}-${id}`}
className="text-neutral-600 dark:text-neutral-400 text-center md:text-left"
>
{card.description}
</motion.p>
</div>
</div>
<motion.button
layoutId={`button-${card.title}-${id}`}
className="px-4 py-2 text-sm rounded-full font-bold bg-gray-100 hover:bg-green-500 hover:text-white text-black mt-4 md:mt-0 transition-colors"
>
{card.ctaText}
</motion.button>
</motion.div>
))}
</ul>
</>
);
}

