DocumentationExpandable Bento Grid

Expandable Bento Grid

A responsive bento grid layout with expandable cards using Framer Motion layout animations.

    Repository

    Version Control

    Connect

    Team Communication

    Reach

    Audience Engagement

    Analytics

    Data Insights

Installation

1. Install Dependencies

2. Run the CLI command

npx shadcn@latest add "https://vengeance-ui.vercel.app/r/undefined.json"

3. Manual Installation

Add the hook

Create a file at src/hooks/use-outside-click.ts and add the following code:

1import React, { useEffect } from 'react'
2
3export const useOutsideClick = (
4 ref: React.RefObject<HTMLDivElement | null>,
5 callback: Function
6) => {
7 useEffect(() => {
8 const listener = (event: any) => {
9 if (!ref.current || ref.current.contains(event.target)) {
10 return
11 }
12 callback(event)
13 }
14
15 document.addEventListener('mousedown', listener)
16 document.addEventListener('touchstart', listener)
17
18 return () => {
19 document.removeEventListener('mousedown', listener)
20 document.removeEventListener('touchstart', listener)
21 }
22 }, [ref, callback])
23}
Add the component

Create a file at src/components/ui/expandable-bento-grid.tsx and copy the source code:

1'use client'
2
3import React, { useEffect, useId, useRef, useState } from 'react'
4import { AnimatePresence, motion } from 'framer-motion'
5import { useOutsideClick } from '@/hooks/use-outside-click'
6import { X } from 'lucide-react'
7
8export interface BentoGridProps {
9 items: {
10 id: string | number
11 title: string
12 subtitle?: string
13 description?: string
14 content: React.ReactNode
15 icon?: React.ReactNode
16 className?: string
17 }[]
18}
19
20export default function ExpandableBentoGrid({ items }: BentoGridProps) {
21 const [active, setActive] = useState<(typeof items)[number] | boolean | null>(null)
22 const ref = useRef<HTMLDivElement>(null)
23 const id = useId()
24
25 useEffect(() => {
26 function onKeyDown(event: KeyboardEvent) {
27 if (event.key === 'Escape') {
28 setActive(false)
29 }
30 }
31
32 if (active && typeof active === 'object') {
33 document.body.style.overflow = 'hidden'
34 } else {
35 document.body.style.overflow = 'auto'
36 }
37
38 window.addEventListener('keydown', onKeyDown)
39 return () => window.removeEventListener('keydown', onKeyDown)
40 }, [active])
41
42 useOutsideClick(ref, () => setActive(null))
43
44 return (
45 <>
46 <AnimatePresence>
47 {active && typeof active === 'object' && (
48 <motion.div
49 initial={{ opacity: 0 }}
50 animate={{ opacity: 1 }}
51 exit={{ opacity: 0 }}
52 className="fixed inset-0 bg-black/20 h-full w-full z-[10000]"
53 />
54 )}
55 </AnimatePresence>
56 <AnimatePresence>
57 {active && typeof active === 'object' ? (
58 <div className="fixed inset-0 grid place-items-center z-[10001]">
59 <motion.button
60 key={`button-${active.title}-${id}`}
61 layout
62 initial={{ opacity: 0 }}
63 animate={{ opacity: 1 }}
64 exit={{ opacity: 0, transition: { duration: 0.05 } }}
65 className="flex absolute top-2 right-2 lg:hidden items-center justify-center bg-white rounded-full h-6 w-6"
66 onClick={() => setActive(null)}
67 >
68 <X className="h-4 w-4 text-black" />
69 </motion.button>
70 <motion.div
71 layoutId={`card-${active.title}-${id}`}
72 ref={ref}
73 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"
74 >
75 <motion.div layoutId={`image-${active.title}-${id}`}>
76 <div className="w-full h-60 lg:h-80 bg-blue-100 dark:bg-blue-900/20 flex items-center justify-center">
77 {active.icon ? (
78 <div className="scale-[2] text-blue-500">{active.icon}</div>
79 ) : (
80 <div className="w-full h-full bg-gray-200" />
81 )}
82 </div>
83 </motion.div>
84
85 <div>
86 <div className="flex justify-between items-start p-4">
87 <div className="">
88 <motion.h3
89 layoutId={`title-${active.title}-${id}`}
90 className="font-bold text-neutral-700 dark:text-neutral-200 text-base"
91 >
92 {active.title}
93 </motion.h3>
94 <motion.p
95 layoutId={`description-${active.title}-${id}`}
96 className="text-neutral-600 dark:text-neutral-400 text-base"
97 >
98 {active.description}
99 </motion.p>
100 </div>
101
102 <motion.a
103 layoutId={`button-${active.title}-${id}`}
104 href="#"
105 target="_blank"
106 className="px-4 py-3 text-sm rounded-full font-bold bg-blue-500 text-white"
107 >
108 Visit
109 </motion.a>
110 </div>
111 <div className="pt-4 relative px-4">
112 <motion.div
113 layout
114 initial={{ opacity: 0 }}
115 animate={{ opacity: 1 }}
116 exit={{ opacity: 0 }}
117 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]"
118 >
119 {active.content}
120 </motion.div>
121 </div>
122 </div>
123 </motion.div>
124 </div>
125 ) : null}
126 </AnimatePresence>
127 <ul className="max-w-4xl mx-auto w-full gap-4 grid grid-cols-1 md:grid-cols-3 items-start">
128 {items.map((item) => (
129 <motion.div
130 layoutId={`card-${item.title}-${id}`}
131 key={item.id}
132 onClick={() => setActive(item)}
133 className="p-4 flex flex-col md:flex-row justify-between items-center hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-xl cursor-pointer bg-blue-50/50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800 transition-colors"
134 >
135 <div className="flex gap-4 flex-col md:flex-row items-center">
136 <motion.div layoutId={`image-${item.title}-${id}`}>
137 <div className="h-14 w-14 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-500">
138 {item.icon}
139 </div>
140 </motion.div>
141 <div className="">
142 <motion.h3
143 layoutId={`title-${item.title}-${id}`}
144 className="font-medium text-neutral-800 dark:text-neutral-200 text-center md:text-left"
145 >
146 {item.title}
147 </motion.h3>
148 <motion.p
149 layoutId={`description-${item.title}-${id}`}
150 className="text-neutral-600 dark:text-neutral-400 text-center md:text-left"
151 >
152 {item.subtitle}
153 </motion.p>
154 </div>
155 </div>
156 </motion.div>
157 ))}
158 </ul>
159 </>
160 )
161}
162

Usage

import ExpandableBentoGrid from "@/components/ui/expandable-bento-grid"
import { Github, Slack } from 'lucide-react'
 
// ... items definition
 
export default function MyPage() {
  return (
    <div className="p-10">
      <ExpandableBentoGrid items={items} />
    </div>
  )
}

Props

BentoGridProps

PropTypeDescription
itemsBentoItem[]Array of items to display in the grid.

BentoItem

PropTypeDescription
idstring | numberUnique identifier for the item.
titlestringTitle of the card.
subtitlestringSubtitle text.
descriptionstringShort description text.
contentReact.ReactNodeContent to display when expanded.
iconReact.ReactNodeIcon to display.
classNamestringOptional CSS class.

Features

  • Smooth Expansion: Cards expand smoothly using Framer Motion layout transitions.
  • Shared Layout: Elements transition seamlessly between the grid and the expanded view.
  • Backdrop Blur: Adds a backdrop when a card is active.
  • Accessibility: Handles Escape key and outside clicks to close the expanded view.
  • Responsive: Adapts to different screen sizes.