Menu

Preview

Live interactive preview.

Installation

Add this component to your project using the CLI:

terminal
npx -y vui-registry-cli-v1@latest add expandable-card-gallery

Source Code

expandable-card-gallery.tsx
'use client'

import { useEffect, useId, useRef, useState, useMemo } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { X, ArrowUpRight, Clock, Calendar } from 'lucide-react'
import { cn } from '@/lib/utils'

type Category = 'all' | 'product' | 'design' | 'space'

type GalleryCard = {
  title: string
  description: string
  src: string
  category: Exclude<Category, 'all'>
  ctaText: string
  ctaLink: string
  readTime: string
  date: string
  tags: string[]
  body: string
}

const cards: GalleryCard[] = [
  {
    title: 'Velocity Design System',
    description: 'Component tokens, motion specs, and accessibility baselines.',
    src: 'https://images.unsplash.com/photo-1503376780353-7e6692767b70?q=80&w=1200&auto=format&fit=crop',
    category: 'design',
    ctaText: 'Open case study',
    ctaLink: '#',
    readTime: '6 min',
    date: 'Mar 2026',
    tags: ['Tokens', 'Motion', 'A11y'],
    body: 'A practical breakdown of how we structure primitives, document interaction states, and ship consistent loading patterns across dashboards and marketing pages.',
  },
  {
    title: 'Checkout Flow Audit',
    description: 'Friction mapping across shipping, payment, and review steps.',
    src: 'https://images.unsplash.com/photo-1508057198894-247b23fe5ade?q=80&w=1200&auto=format&fit=crop',
    category: 'product',
    ctaText: 'View findings',
    ctaLink: '#',
    readTime: '4 min',
    date: 'Feb 2026',
    tags: ['UX', 'Conversion', 'Forms'],
    body: 'We reduced drop-off by clarifying field labels, tightening step transitions, and surfacing trust signals before payment authorization.',
  },
  {
    title: 'Workspace Grid Study',
    description: 'Layout rhythm for dense admin interfaces.',
    src: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?q=80&w=1200&auto=format&fit=crop',
    category: 'space',
    ctaText: 'Read notes',
    ctaLink: '#',
    readTime: '5 min',
    date: 'Jan 2026',
    tags: ['Layout', 'Grid', 'Density'],
    body: 'Explores column breakpoints, sticky regions, and how expandable panels behave when nested inside split views.',
  },
  {
    title: 'Field Photography Kit',
    description: 'Capture guidelines for product and editorial assets.',
    src: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=1200&auto=format&fit=crop',
    category: 'design',
    ctaText: 'See gallery',
    ctaLink: '#',
    readTime: '3 min',
    date: 'Dec 2025',
    tags: ['Photography', 'Brand', 'Assets'],
    body: 'Covers aspect ratios, safe crops for cards, and how thumbnails should read at small sizes inside expandable lists.',
  },
]

const FILTERS: { id: Category; label: string }[] = [
  { id: 'all', label: 'All' },
  { id: 'product', label: 'Product' },
  { id: 'design', label: 'Design' },
  { id: 'space', label: 'Space' },
]

export default function ExpandableCardGallery() {
  const [active, setActive] = useState<GalleryCard | null>(null)
  const [filter, setFilter] = useState<Category>('all')
  const ref = useRef<HTMLDivElement>(null)
  const id = useId()

  const visible = useMemo(
    () => (filter === 'all' ? cards : cards.filter((c) => c.category === filter)),
    [filter]
  )

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setActive(null)
    }
    document.body.style.overflow = active ? 'hidden' : ''
    window.addEventListener('keydown', onKey)
    return () => {
      window.removeEventListener('keydown', onKey)
      document.body.style.overflow = ''
    }
  }, [active])

  return (
    <div className="w-full max-w-2xl mx-auto p-4">
      <div className="mb-5">
        <p className="text-[10px] font-bold uppercase tracking-[0.2em] text-neutral-500">Case studies</p>
        <h2 className="text-lg font-semibold text-neutral-900 dark:text-white mt-1">Expandable gallery</h2>
        <p className="text-xs text-neutral-500 mt-1">Tap a row to expand with shared layout animation.</p>
      </div>

      <div className="flex flex-wrap gap-1.5 mb-4">
        {FILTERS.map((f) => (
          <button
            key={f.id}
            type="button"
            onClick={() => setFilter(f.id)}
            className={cn(
              'px-2.5 py-1 rounded-md text-[10px] font-semibold uppercase tracking-wide transition-colors',
              filter === f.id
                ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border border-emerald-500/25'
                : 'text-neutral-500 border border-transparent hover:text-neutral-800 dark:hover:text-neutral-200'
            )}
          >
            {f.label}
          </button>
        ))}
      </div>

      <AnimatePresence>
        {active && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[90]"
            onClick={() => setActive(null)}
          />
        )}
      </AnimatePresence>

      <AnimatePresence>
        {active && (
          <div className="fixed inset-0 z-[100] grid place-items-center p-4 pointer-events-none">
            <motion.div
              layoutId={`card-${active.title}-${id}`}
              ref={ref}
              className="pointer-events-auto w-full max-w-lg bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-white/10 overflow-hidden shadow-xl"
            >
              <motion.div layoutId={`image-${active.title}-${id}`} className="relative h-48">
                <img src={active.src} alt="" className="w-full h-full object-cover" />
                <button
                  type="button"
                  onClick={() => setActive(null)}
                  className="absolute top-3 right-3 p-1.5 rounded-full bg-black/40 text-white hover:bg-black/60 transition-colors"
                >
                  <X className="w-4 h-4" />
                </button>
              </motion.div>

              <div className="p-5 space-y-4">
                <div>
                  <motion.h3 layoutId={`title-${active.title}-${id}`} className="text-base font-semibold text-neutral-900 dark:text-white">
                    {active.title}
                  </motion.h3>
                  <motion.p layoutId={`desc-${active.description}-${id}`} className="text-sm text-neutral-500 mt-1">
                    {active.description}
                  </motion.p>
                </div>

                <div className="flex items-center gap-4 text-[10px] text-neutral-400 uppercase tracking-wide">
                  <span className="inline-flex items-center gap-1">
                    <Calendar className="w-3 h-3" />
                    {active.date}
                  </span>
                  <span className="inline-flex items-center gap-1">
                    <Clock className="w-3 h-3" />
                    {active.readTime}
                  </span>
                </div>

                <p className="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">{active.body}</p>

                <div className="flex flex-wrap gap-1.5">
                  {active.tags.map((tag) => (
                    <span key={tag} className="px-2 py-0.5 rounded-md bg-neutral-100 dark:bg-white/5 text-[10px] font-medium text-neutral-500">
                      {tag}
                    </span>
                  ))}
                </div>

                <a
                  href={active.ctaLink}
                  className="inline-flex items-center gap-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400 hover:underline"
                >
                  {active.ctaText}
                  <ArrowUpRight className="w-3.5 h-3.5" />
                </a>
              </div>
            </motion.div>
          </div>
        )}
      </AnimatePresence>

      <ul className="space-y-2">
        {visible.map((card) => (
          <motion.li
            key={card.title}
            layoutId={`card-${card.title}-${id}`}
            onClick={() => setActive(card)}
            className="flex gap-3 p-3 rounded-xl border border-neutral-200 dark:border-white/10 bg-white dark:bg-neutral-900 cursor-pointer hover:border-emerald-500/30 transition-colors"
          >
            <motion.div layoutId={`image-${card.title}-${id}`} className="shrink-0">
              <img src={card.src} alt="" className="h-16 w-16 rounded-lg object-cover" />
            </motion.div>
            <div className="flex-1 min-w-0 py-0.5">
              <motion.h3 layoutId={`title-${card.title}-${id}`} className="text-sm font-semibold text-neutral-900 dark:text-white truncate">
                {card.title}
              </motion.h3>
              <motion.p layoutId={`desc-${card.description}-${id}`} className="text-xs text-neutral-500 mt-0.5 line-clamp-2">
                {card.description}
              </motion.p>
              <div className="flex items-center gap-2 mt-2 text-[10px] text-neutral-400">
                <span>{card.date}</span>
                <span>·</span>
                <span>{card.readTime}</span>
              </div>
            </div>
            <ArrowUpRight className="w-4 h-4 text-neutral-300 shrink-0 mt-1" />
          </motion.li>
        ))}
      </ul>
    </div>
  )
}

Props

Component property reference.

NameTypeDefaultDescription
itemsGalleryCard[]demo cardsArray of gallery items with title, image, category, tags, and body.
classNamestringundefinedOptional wrapper class name.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.