11import cn from 'classnames' ;
2- import { useMemo , ReactNode , useEffect , useState } from 'react' ;
2+ import { useMemo , ReactNode , useEffect , useRef , useState } from 'react' ;
33import styles from './masonry-grid.module.css' ;
44
55export interface MasonryGridProps < T > {
@@ -15,65 +15,172 @@ export interface MasonryGridProps<T> {
1515}
1616
1717export function MasonryGrid < T > ( {
18- items,
19- columnCount = 2 ,
20- gap,
21- renderItem,
22- getKey,
23- className,
24- columnClassName,
25- itemClassName,
26- mobileBreakpoint = 808 ,
27- } : MasonryGridProps < T > ) {
18+ items,
19+ columnCount = 2 ,
20+ gap,
21+ renderItem,
22+ getKey,
23+ className,
24+ columnClassName,
25+ itemClassName,
26+ mobileBreakpoint = 808 ,
27+ } : MasonryGridProps < T > ) {
2828 const [ isMobile , setIsMobile ] = useState ( false ) ;
2929
30+ // Container refs for measuring and responsive recalculation
31+ const gridRef = useRef < HTMLDivElement | null > ( null ) ;
32+ const measureWrapperRef = useRef < HTMLDivElement | null > ( null ) ;
33+ const measureItemRefs = useRef < ( HTMLDivElement | null ) [ ] > ( [ ] ) ;
34+
35+ const [ containerWidth , setContainerWidth ] = useState < number > ( 0 ) ;
36+
37+ // Debounce helper
38+ const debounce = ( fn : ( ) => void , ms : number ) => {
39+ let id : number | undefined ;
40+ return ( ) => {
41+ if ( id ) window . clearTimeout ( id ) ;
42+ id = window . setTimeout ( fn , ms ) ;
43+ } ;
44+ } ;
45+
46+ // Track viewport breakpoint and container width
3047 useEffect ( ( ) => {
31- const update = ( ) => {
48+ const handleResize = ( ) => {
3249 if ( typeof window !== 'undefined' ) {
3350 setIsMobile ( window . innerWidth <= mobileBreakpoint ) ;
51+ if ( gridRef . current ) {
52+ setContainerWidth ( gridRef . current . clientWidth ) ;
53+ }
3454 }
3555 } ;
36- update ( ) ;
37- window . addEventListener ( 'resize' , update ) ;
38- return ( ) => window . removeEventListener ( 'resize' , update ) ;
56+ // Debounced to avoid thrashing
57+ const debounced = debounce ( handleResize , 150 ) ;
58+ handleResize ( ) ;
59+ window . addEventListener ( 'resize' , debounced ) ;
60+ return ( ) => window . removeEventListener ( 'resize' , debounced ) ;
3961 } , [ mobileBreakpoint ] ) ;
4062
4163 const effectiveColumnCount = Math . max ( 1 , isMobile ? 1 : columnCount ) ;
4264
43- const columns = useMemo ( ( ) => {
44- const cols : T [ ] [ ] = Array . from ( { length : effectiveColumnCount } , ( ) => [ ] ) ;
65+ // Compute per-column width (used by hidden measuring container)
66+ const columnWidth = useMemo ( ( ) => {
67+ const g = gap ?? 0 ;
68+ if ( ! containerWidth || effectiveColumnCount <= 0 ) return 0 ;
69+ return Math . max ( 0 , Math . floor ( ( containerWidth - g * ( effectiveColumnCount - 1 ) ) / effectiveColumnCount ) ) ;
70+ } , [ containerWidth , effectiveColumnCount , gap ] ) ;
71+
72+ // State with the final greedy distribution
73+ const [ greedyColumns , setGreedyColumns ] = useState < T [ ] [ ] | null > ( null ) ;
74+
75+ // Measure item heights in a hidden container and distribute greedily
76+ useEffect ( ( ) => {
77+ // Avoid running on server or when measurement isn't possible yet
78+ if ( typeof window === 'undefined' ) return ;
79+ if ( effectiveColumnCount <= 0 ) return ;
80+ if ( ! measureWrapperRef . current ) return ;
81+ if ( columnWidth <= 0 ) return ;
82+
83+ // Defer until browser lays out hidden nodes
84+ const id = window . setTimeout ( ( ) => {
85+ // Collect heights for each item (fallback to 0 if not measurable)
86+ const heights = items . map ( ( _ , i ) => measureItemRefs . current [ i ] ?. offsetHeight ?? 0 ) ;
4587
88+ // Greedy placement by current column total heights
89+ const cols : T [ ] [ ] = Array . from ( { length : effectiveColumnCount } , ( ) => [ ] ) ;
90+ const colHeights : number [ ] = Array . from ( { length : effectiveColumnCount } , ( ) => 0 ) ;
91+ const g = gap ?? 0 ;
92+
93+ items . forEach ( ( item , i ) => {
94+ // Find the shortest column
95+ let minIndex = 0 ;
96+ let minValue = colHeights [ 0 ] ;
97+ for ( let c = 1 ; c < effectiveColumnCount ; c ++ ) {
98+ if ( colHeights [ c ] < minValue ) {
99+ minIndex = c ;
100+ minValue = colHeights [ c ] ;
101+ }
102+ }
103+ cols [ minIndex ] . push ( item ) ;
104+ const h = heights [ i ] || 0 ;
105+ // Add gap except for the very first item in a column
106+ colHeights [ minIndex ] += ( colHeights [ minIndex ] > 0 ? g : 0 ) + h ;
107+ } ) ;
108+
109+ setGreedyColumns ( cols ) ;
110+ } , 0 ) ;
111+
112+ return ( ) => window . clearTimeout ( id ) ;
113+ // Recompute when items, columns or width change
114+ } , [ items , effectiveColumnCount , columnWidth , gap ] ) ;
115+
116+ // If we don't have measurements yet, fall back to simple cyclic distribution to avoid empty UI
117+ const cyclicColumns = useMemo ( ( ) => {
118+ const cols : T [ ] [ ] = Array . from ( { length : effectiveColumnCount } , ( ) => [ ] ) ;
46119 items . forEach ( ( item , index ) => {
47120 const columnIndex = index % effectiveColumnCount ;
48121 cols [ columnIndex ] . push ( item ) ;
49122 } ) ;
50-
51123 return cols ;
52124 } , [ items , effectiveColumnCount ] ) ;
53125
126+ const columns = greedyColumns ?? cyclicColumns ;
127+
54128 const style = gap !== undefined ? { gap : `${ gap } px` } : undefined ;
55129
56130 return (
57- < div className = { cn ( styles . grid , className ) } style = { style } >
58- { columns . map ( ( column , columnIndex ) => (
59- < div
60- key = { columnIndex }
61- className = { cn ( styles . column , columnClassName ) }
62- style = { style }
63- >
64- { column . map ( ( item , itemIndex ) => {
65- const originalIndex = columnIndex + itemIndex * effectiveColumnCount ;
66- return (
67- < div
68- key = { getKey ( item , originalIndex ) }
69- className = { cn ( styles . item , itemClassName ) }
70- >
71- { renderItem ( item , originalIndex ) }
72- </ div >
73- ) ;
74- } ) }
131+ < >
132+ { /* Visible grid */ }
133+ < div ref = { gridRef } className = { cn ( styles . grid , className ) } style = { style } >
134+ { columns . map ( ( column , columnIndex ) => (
135+ < div
136+ key = { columnIndex }
137+ className = { cn ( styles . column , columnClassName ) }
138+ style = { style }
139+ >
140+ { column . map ( ( item , itemIndex ) => {
141+ // Preserve stable keys based on original index order
142+ const originalIndex = columnIndex + itemIndex * Math . max ( 1 , effectiveColumnCount ) ;
143+ return (
144+ < div
145+ key = { getKey ( item , originalIndex ) }
146+ className = { cn ( styles . item , itemClassName ) }
147+ >
148+ { /*<div style={{color: 'white'}}>{originalIndex}</div>*/ }
149+ { renderItem ( item , originalIndex ) }
150+ </ div >
151+ ) ;
152+ } ) }
153+ </ div >
154+ ) ) }
155+ </ div >
156+
157+ { /* Hidden measuring container: renders all items at exact column width to get real heights */ }
158+ < div
159+ ref = { measureWrapperRef }
160+ aria-hidden
161+ style = { {
162+ position : 'absolute' ,
163+ visibility : 'hidden' ,
164+ pointerEvents : 'none' ,
165+ left : - 99999 ,
166+ top : 0 ,
167+ width : columnWidth || undefined ,
168+ } }
169+ >
170+ { /* Force the same width as a column so heights match real layout */ }
171+ < div style = { { width : columnWidth } } >
172+ { items . map ( ( item , i ) => (
173+ < div
174+ key = { getKey ( item , i ) }
175+ ref = { ( el ) => ( measureItemRefs . current [ i ] = el ) }
176+ className = { cn ( styles . item , itemClassName ) }
177+ style = { { marginBottom : gap && i < items . length - 1 ? gap : undefined } }
178+ >
179+ { renderItem ( item , i ) }
180+ </ div >
181+ ) ) }
75182 </ div >
76- ) ) }
77- </ div >
183+ </ div >
184+ </ >
78185 ) ;
79186}
0 commit comments