docsExpandable Bento Grid

Expandable Bento Grid

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

Loading Preview...

Installation

1. Install Dependencies

2. Run the CLI command

npx shadcn@latest add "https://www.vengenceui.com/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.