expo-masonry-layout
A high-performance masonry layout component for React Native and Expo applications
⨠Used in production by WiSaw - a location-based photo sharing app
⨠Features
- š High Performance: Uses VirtualizedList for optimal performance with large datasets
- š± Responsive: Automatically adapts to screen size and orientation changes
- šØ Flexible: Supports custom aspect ratios and layout configurations
- š Interactive: Built-in pull-to-refresh and infinite scroll support
- š Dual Layout Modes: Row-based masonry with justified alignment and column-based masonry with shortest-column placement
- š Responsive Columns: Column mode supports responsive breakpoints for adaptive column counts
- š Extra Height:
getExtraHeight callback for adding dynamic per-item content (captions, badges, buttons) below images
- š Inline Expand: Expand items to full-width detail view inline, with multi-expand support and automatic height measurement
- š Dynamic Height Measurement: Expanded items auto-measure their rendered height ā supports dynamic content like accordions, comment lists, and reply forms
- ļæ½ Shadow-Friendly: Item containers don't clip overflow, so shadows, badges, and decorations render correctly
- ļæ½šÆ TypeScript: Full TypeScript support with comprehensive types
- ā” Optimized: Minimal re-renders with memoized calculations
š Real-world Usage
This component is actively used in production by:
- WiSaw - A location-based photo sharing mobile app that displays thousands of user-generated photos in a beautiful masonry layout. WiSaw demonstrates the component's ability to handle large datasets with smooth scrolling and optimal performance.
The screenshot above is taken directly from the WiSaw app, showcasing real-world usage with actual user photos.
š Installation
npm install expo-masonry-layout
yarn add expo-masonry-layout
```tsx
import React from 'react';
import { View, Image, Text } from 'react-native';
import ExpoMasonryLayout from 'expo-masonry-layout';
const MyMasonryGrid = () => {
const data = [
{ id: '1', uri: 'https://example.com/image1.jpg', width: 300, height: 400 },
{ id: '2', uri: 'https://example.com/image2.jpg', width: 400, height: 300 },
{ id: '3', uri: 'https://example.com/image3.jpg', width: 300, height: 300 },
// ... more items
];
const renderItem = ({ item, dimensions }) => (
<View style={{ width: dimensions.width, height: dimensions.height }}>
<Image
source={{ uri: item.uri }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
/>
</View>
);
return (
<ExpoMasonryLayout
data={data}
renderItem={renderItem}
spacing={6}
keyExtractor={item => item.id}
/>
);
};
š¼ļø Using with Expo Cached Image
For better performance with remote images, we recommend using expo-cached-image alongside the masonry layout:
npm install expo-cached-image
yarn add expo-cached-image
Here's how to integrate it:
import React from 'react';
import { View, Dimensions } from 'react-native';
import ExpoMasonryLayout from 'expo-masonry-layout';
import { CachedImage } from 'expo-cached-image';
const CachedMasonryGrid = () => {
const data = [
{ id: '1', uri: 'https://example.com/image1.jpg', width: 300, height: 400 },
{ id: '2', uri: 'https://example.com/image2.jpg', width: 400, height: 300 },
{ id: '3', uri: 'https://example.com/image3.jpg', width: 300, height: 300 }
];
const renderItem = ({ item, dimensions }) => (
<View style={{ width: dimensions.width, height: dimensions.height }}>
<CachedImage
source={{ uri: item.uri }}
style={{ width: '100%', height: '100%' }}
resizeMode="cover"
cacheKey={`masonry-${item.id}`} // Unique cache key
placeholderContent={
<View
style={{
flex: 1,
backgroundColor: '#f0f0f0',
justifyContent: 'center',
alignItems: 'center'
}}
/>
}
/>
</View>
);
return (
<ExpoMasonryLayout
data={data}
renderItem={renderItem}
spacing={6}
keyExtractor={(item) => item.id}
/>
);
};
Benefits of Using Expo Cached Image:
- Automatic Caching: Images are cached locally after first load
- Placeholder Support: Shows placeholder while loading
- Better Performance: Reduces network requests for repeated views
- Memory Management: Efficient image memory handling
- Progressive Loading: Smooth loading experience
Performance Tips with Cached Images:
- Use Unique Cache Keys: Ensure each image has a unique
cacheKey prop
- Optimize Image Sizes: Use appropriately sized images for your layout
- Implement Placeholders: Provide placeholder content for better UX
- Clear Cache When Needed: Implement cache clearing for updated content
import { CachedImage } from 'expo-cached-image';
const clearImageCache = async () => {
await CachedImage.clearCache();
};
const clearSpecificCache = async (imageId) => {
await CachedImage.clearCache(`masonry-${imageId}`);
};
ļæ½ Column Layout Mode
Column-based masonry places items into columns of equal width, filling the shortest column first ā the standard Pinterest-style layout:
import ExpoMasonryLayout from 'expo-masonry-layout';
const ColumnMasonryGrid = () => (
<ExpoMasonryLayout
data={data}
renderItem={renderItem}
layoutMode="column"
columns={3}
spacing={8}
/>
);
Responsive Column Count
Use breakpoints to adapt column count to screen width. Breakpoints are width ceilings evaluated from smallest up:
import ExpoMasonryLayout, { ColumnsConfig } from 'expo-masonry-layout';
const columns: ColumnsConfig = {
default: 3,
768: 2,
400: 1,
};
const ResponsiveGrid = () => (
<ExpoMasonryLayout
data={data}
renderItem={renderItem}
layoutMode="column"
columns={columns}
spacing={8}
/>
);
Add dynamic content below each item's image area using the getExtraHeight callback. Works in both row and column modes:
import ExpoMasonryLayout, { MasonryItem, MasonryRenderItemInfo } from 'expo-masonry-layout';
const getExtraHeight = (item: MasonryItem, computedWidth: number): number => {
if (!item.caption) return 0;
const charsPerLine = Math.floor(computedWidth / 8);
const numLines = Math.ceil((item.caption as string).length / charsPerLine);
return numLines * 18 + 16;
};
const renderItem = ({ item, dimensions, extraHeight }: MasonryRenderItemInfo) => {
const imageHeight = dimensions.height - extraHeight;
return (
<View style={{ width: dimensions.width, height: dimensions.height }}>
<Image
source={{ uri: item.imageUrl }}
style={{ width: dimensions.width, height: imageHeight }}
resizeMode="cover"
/>
{item.caption ? (
<Text style={{ padding: 8 }}>{item.caption as string}</Text>
) : null}
</View>
);
};
const CaptionGrid = () => (
<ExpoMasonryLayout
data={data}
renderItem={renderItem}
layoutMode="column"
columns={2}
spacing={8}
getExtraHeight={getExtraHeight}
/>
);
How it works:
- In row mode: row height becomes
max(imageHeight + extraHeight) across all items in the row. Item widths are computed first (two-pass), then getExtraHeight is called with the final width.
- In column mode: each item's total height is
scaledImageHeight + extraHeight, stacked independently per column. Width is known upfront (screenWidth / numColumns).
extraHeight is passed in MasonryRenderItemInfo so your renderItem knows the image/extra split.
š Inline Expand
Expand items to a full-width detail view inline within the layout. Works in both column mode and row mode. Multiple items can be expanded simultaneously ā the layout recalculates on each expand/collapse.
Expanded items are automatically measured after rendering, so getExpandedHeight is optional ā provide it for a better initial estimate, but the layout self-corrects once the content renders:
import React, { useState, useCallback } from 'react';
import { Image, Text, View, TouchableOpacity } from 'react-native';
import ExpoMasonryLayout, { MasonryItem, MasonryRenderItemInfo } from 'expo-masonry-layout';
const getExpandedHeight = (item: MasonryItem, fullWidth: number): number => {
const imageHeight = Math.floor(fullWidth / ((item.width ?? 300) / (item.height ?? 300)));
return imageHeight + 200;
};
const InlineExpandGrid = () => {
const [expandedIds, setExpandedIds] = useState<string[]>([]);
const toggleExpand = useCallback((id: string) => {
setExpandedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
}, []);
const renderItem = useCallback(
({ item, dimensions, isExpanded }: MasonryRenderItemInfo) => {
if (isExpanded) {
const imageH = Math.floor(
dimensions.width / ((item.width ?? 300) / (item.height ?? 300))
);
return (
<TouchableOpacity onPress={() => toggleExpand(item.id)}>
<Image
source={{ uri: item.imageUrl }}
style={{ width: dimensions.width, height: imageH }}
/>
<View style={{ padding: 12 }}>
<Text style={{ fontSize: 18 }}>{item.title}</Text>
<Text>{item.description}</Text>
</View>
</TouchableOpacity>
);
}
return (
<TouchableOpacity onPress={() => toggleExpand(item.id)}>
<Image
source={{ uri: item.imageUrl }}
style={{ width: dimensions.width, height: dimensions.height }}
/>
</TouchableOpacity>
);
},
[toggleExpand]
);
return (
<ExpoMasonryLayout
data={data}
renderItem={renderItem}
layoutMode="column"
columns={3}
spacing={8}
expandedItemIds={expandedIds}
getExpandedHeight={getExpandedHeight}
autoScrollOnExpand
/>
);
};
How it works:
- In column mode: all columns flush to the waterline (max column height), the item spans full width, then columns resume below.
- In row mode: the current row is flushed, the expanded item becomes a solo full-width row, then row packing resumes.
isExpanded is passed in MasonryRenderItemInfo so renderItem can branch between collapsed/expanded views.
- When expanded,
dimensions.width is the full grid width and dimensions.height comes from auto-measurement (or the getExpandedHeight estimate before measurement).
- Auto-measurement: Expanded items are rendered without a fixed height constraint and measured via
onLayout. The layout automatically recalculates when the measured height differs from the estimate. This means dynamic content (accordions, comment threads, reply forms) triggers re-layout automatically.
getExpandedHeight is optional ā it provides an initial estimate before measurement. When omitted, screenWidth is used as the default estimate.
getExtraHeight is not applied to expanded items ā the expanded height replaces the normal layout entirely.
autoScrollOnExpand automatically scrolls to items when they are expanded or collapsed. Pass { animated: false } to disable animation, or { viewOffset: 50 } to add padding above the item.
- Use
onExpandedItemLayout for custom scroll logic, or a ref with scrollToItem(id) for full control.
Imperative Height Notification
For cases where you need to programmatically notify the layout of a height change (e.g., after an async data load completes), use the notifyHeightChanged method on the ref:
const ref = useRef<ExpoMasonryLayoutHandle>(null);
const onCommentsLoaded = (itemId: string, newHeight: number) => {
ref.current?.notifyHeightChanged(itemId, newHeight);
};
In most cases auto-measurement handles everything ā notifyHeightChanged is only needed when you know the height before the next onLayout fires.
ļæ½š§ Advanced Usage
Here's a comprehensive example inspired by the WiSaw app implementation:
import React, { useState, useCallback } from 'react';
import { TouchableOpacity, Image, Text, View } from 'react-native';
import ExpoMasonryLayout, { MasonryRenderItemInfo } from 'expo-masonry-layout';
const PhotoMasonryGrid = () => {
const [photos, setPhotos] = useState(initialPhotos);
const [refreshing, setRefreshing] = useState(false);
const [loading, setLoading] = useState(false);
const renderPhotoItem = useCallback(
({ item, dimensions }: MasonryRenderItemInfo) => (
<TouchableOpacity
style={{
width: dimensions.width,
height: dimensions.height,
borderRadius: 12,
overflow: 'hidden',
backgroundColor: '#f0f0f0',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3
}}
onPress={() => handlePhotoPress(item)}
activeOpacity={0.9}
>
<Image
source={{ uri: item.imageUrl }}
style={{
width: '100%',
height: '85%'
}}
resizeMode="cover"
loadingIndicatorSource={require('./placeholder.png')}
/>
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
padding: 8
}}
>
<Text
style={{
color: 'white',
fontSize: 12,
fontWeight: '600'
}}
numberOfLines={1}
>
š {item.location}
</Text>
<Text
style={{
color: 'rgba(255,255,255,0.8)',
fontSize: 10,
marginTop: 2
}}
>
ā¤ļø {item.likes} ⢠š¤ {item.username}
</Text>
</View>
</TouchableOpacity>
),
[]
);
const handlePhotoPress = useCallback((photo) => {
console.log('Photo pressed:', photo.id);
}, []);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
const freshPhotos = await fetchLatestPhotos();
setPhotos(freshPhotos);
} catch (error) {
console.error('Error refreshing photos:', error);
} finally {
setRefreshing(false);
}
}, []);
const handleLoadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
try {
const morePhotos = await fetchMorePhotos(photos.length);
setPhotos((prevPhotos) => [...prevPhotos, ...morePhotos]);
} catch (error) {
console.error('Error loading more photos:', error);
} finally {
setLoading(false);
}
}, [photos.length, loading]);
return (
<ExpoMasonryLayout
data={photos}
renderItem={renderPhotoItem}
spacing={8}
maxItemsPerRow={2} // WiSaw uses 2 columns for optimal photo viewing
baseHeight={200}
keyExtractor={(item) => item.id}
refreshing={refreshing}
onRefresh={handleRefresh}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.2}
aspectRatioFallbacks={[0.7, 1.0, 1.3, 1.6]} // Common photo ratios
style={{ backgroundColor: '#f8f9fa' }}
contentContainerStyle={{ padding: 8 }}
showsVerticalScrollIndicator={false}
initialNumToRender={8}
maxToRenderPerBatch={10}
windowSize={15}
/>
);
};
š§ VirtualizedList Pass-Through
The component now supports passing any VirtualizedList prop directly to the underlying implementation. This gives you full control over scrolling behavior, performance tuning, and platform-specific features:
import React, { useCallback } from 'react';
import ExpoMasonryLayout from 'expo-masonry-layout';
const AdvancedMasonryGrid = () => {
const handleScroll = useCallback((event) => {
console.log('Scroll position:', event.nativeEvent.contentOffset.y);
}, []);
const handleScrollBeginDrag = useCallback(() => {
console.log('User started scrolling');
}, []);
return (
<ExpoMasonryLayout
data={photos}
renderItem={renderPhotoItem}
spacing={8}
maxItemsPerRow={2}
{/* VirtualizedList props passed through */}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
scrollEventThrottle={16}
showsVerticalScrollIndicator={true}
bounces={true}
scrollEnabled={true}
nestedScrollEnabled={true} // Android
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 100,
}}
{/* Performance tuning */}
initialNumToRender={10}
maxToRenderPerBatch={5}
windowSize={10}
removeClippedSubviews={true}
updateCellsBatchingPeriod={50}
{/* Infinite scroll */}
onEndReached={loadMoreData}
onEndReachedThreshold={0.2}
{/* Pull to refresh */}
refreshing={isRefreshing}
onRefresh={handleRefresh}
/>
);
};
š API Reference
Props
The component extends React Native's VirtualizedListProps and accepts all VirtualizedList properties in addition to the masonry-specific props below:
Masonry-Specific Props
data | MasonryItem[] | required | Array of items to display |
renderItem | (info: MasonryRenderItemInfo) => ReactElement | required | Function to render each item |
layoutMode | 'row' | 'column' | 'row' | Layout mode: row-based or column-based masonry |
columns | number | ColumnsConfig | 2 | Number of columns or responsive breakpoint config (column mode only) |
getExtraHeight | (item: MasonryItem, computedWidth: number) => number | undefined | Calculate extra height below image area per item |
expandedItemIds | string[] | undefined | Item IDs currently expanded to full width |
getExpandedHeight | (item: MasonryItem, fullWidth: number) => number | undefined | Optional initial height estimate for expanded items; auto-measurement corrects after render |
spacing | number | 6 | Space between items in pixels |
maxItemsPerRow | number | 6 | Maximum number of items per row (row mode only) |
baseHeight | number | 100 | Base height for layout calculations (row mode only) |
aspectRatioFallbacks | number[] | [0.56, 0.67, 0.75, 1.0, 1.33, 1.5, 1.78] | Fallback aspect ratios |
preserveItemDimensions | boolean | false | Whether to respect exact item dimensions when provided |
getItemDimensions | (item: MasonryItem, index: number) => { width: number; height: number } | null | undefined | Function to calculate custom dimensions for items |
keyExtractor | (item: MasonryItem, index: number) => string | (item, index) => item.id || index | Extract unique key for each item |
onItemLayout | (info: MasonryRenderItemInfo) => void | undefined | Callback when an item's layout dimensions are calculated |
autoScrollOnExpand | boolean | { animated?: boolean; viewOffset?: number } | undefined | Auto-scroll to items when expanded or collapsed (column mode only) |
onExpandedItemLayout | (info: { item, index, dimensions, isExpanded }) => void | undefined | Callback fired for each item whose expand/collapse state changed |
VirtualizedList Props
All VirtualizedList props are supported and passed through to the underlying implementation, including:
- Performance:
initialNumToRender, maxToRenderPerBatch, windowSize, updateCellsBatchingPeriod, removeClippedSubviews
- Scrolling:
onScroll, onScrollBeginDrag, onScrollEndDrag, onMomentumScrollBegin, onMomentumScrollEnd, scrollEventThrottle
- Interaction:
onEndReached, onEndReachedThreshold, refreshing, onRefresh, scrollEnabled, bounces
- Styling:
style, contentContainerStyle, showsVerticalScrollIndicator
- Platform:
nestedScrollEnabled (Android), scrollIndicatorInsets (iOS)
š· Types
MasonryItem
interface MasonryItem {
id: string;
width?: number;
height?: number;
preserveDimensions?: boolean;
[key: string]: any;
}
MasonryRenderItemInfo
interface MasonryRenderItemInfo {
item: MasonryItem;
index: number;
dimensions: {
width: number;
height: number;
left: number;
top: number;
};
extraHeight: number;
columnIndex?: number;
isExpanded: boolean;
}
ColumnsConfig
type ColumnsConfig = number | { default: number; [breakpoint: number]: number };
ExpoMasonryLayoutHandle (Ref API)
The component supports React.forwardRef. Use a ref to access imperative scroll methods:
import { useRef } from 'react';
import ExpoMasonryLayout, { ExpoMasonryLayoutHandle } from 'expo-masonry-layout';
const ref = useRef<ExpoMasonryLayoutHandle>(null);
<ExpoMasonryLayout ref={ref} ... />
ref.current?.scrollToItem('item-5', { animated: true, viewOffset: 50 });
ref.current?.scrollToOffset(500, { animated: true });
scrollToItem | (id: string, options?: { animated?: boolean; viewOffset?: number }) => void | Scroll to an item by ID. No-op if ID not found. |
scrollToOffset | (offset: number, options?: { animated?: boolean }) => void | Scroll to an absolute offset. Works in both layout modes. |
notifyHeightChanged | (id: string, newHeight: number) => void | Programmatically update the measured height of an expanded item, triggering re-layout. |
š Custom Dimensions
The library supports multiple ways to override the automatic dimension calculation:
1. Per-Item Dimension Preservation
Set preserveDimensions: true on individual items to use their exact width and height:
const dataWithExactSizes = [
{
id: '1',
width: 300,
height: 200,
preserveDimensions: true,
imageUrl: 'https://example.com/image1.jpg'
},
{
id: '2',
width: 400,
height: 300,
imageUrl: 'https://example.com/image2.jpg'
}
];
2. Global Dimension Preservation
Use the preserveItemDimensions prop to respect exact dimensions for all items that have width and height:
<ExpoMasonryLayout data={data} preserveItemDimensions={true} renderItem={renderItem} />
3. Custom Dimension Function
Use getItemDimensions for dynamic dimension calculation:
const getCustomDimensions = (item, index) => {
if (index % 5 === 0) {
return { width: 300, height: 150 };
}
if (item.featured) {
return { width: 250, height: 200 };
}
return null;
};
<ExpoMasonryLayout data={data} getItemDimensions={getCustomDimensions} renderItem={renderItem} />;
4. Mixed Layout Strategy
Combine all approaches for maximum flexibility:
const mixedData = [
{
id: '1',
width: 200,
height: 300,
preserveDimensions: true
},
{
id: '2',
featured: true
},
{
id: '3',
width: 400,
height: 300
}
];
<ExpoMasonryLayout
data={mixedData}
preserveItemDimensions={false}
getItemDimensions={(item, index) => {
if (item.featured) return { width: 250, height: 200 };
return null;
}}
renderItem={renderItem}
/>;
Priority Order:
getItemDimensions function result (highest priority)
preserveDimensions: true on item + item's width/height
preserveItemDimensions: true prop + item's width/height
- Auto-calculated from aspect ratio (lowest priority)
ļæ½šÆ Performance Tips
- Provide Image Dimensions: Include
width and height in your data items for optimal layout calculation
- Memoize Render Function: Use
useCallback for your renderItem function
- Optimize Images: Use appropriate image sizes and consider lazy loading
- Key Extractor: Provide a stable
keyExtractor function
- Batch Size: Adjust
maxToRenderPerBatch based on your item complexity
š§® Layout Algorithm
The component supports two layout algorithms:
Row Mode (default)
- Row Filling: Items are added to rows based on available width
- Height Normalization: All items in a row are scaled to the same height
- Width Justification: The entire row is scaled to fill the available width
- Extra Height Pass: If
getExtraHeight is provided, row height becomes max(imageHeight + extraHeight)
- Expanded Items: When an item is expanded, the current row is flushed, the expanded item occupies a solo full-width row, and packing resumes in a new row below
- Vertical Positioning: Items are vertically centered within their row
Column Mode
- Column Width: Calculated as
(screenWidth - spacing) / numColumns
- Shortest-Column Placement: Each item is placed in the column with the smallest cumulative height
- Height Calculation: Image height derived from aspect ratio, plus
extraHeight if provided
- Natural Band Boundaries: Items are sliced into horizontal bands for VirtualizedList rendering. Band boundaries are placed at y-coordinates where all columns have a gap between items, ensuring no item is split across bands. This eliminates touch dead zones and visible gaps between bands.
- Shadow Support: Item containers do not clip overflow, allowing shadows and decorations to render outside item bounds
Both modes ensure:
- Optimal use of screen space
- Predictable layout behavior
- Excellent performance with virtualization
š License
MIT
š¤ Contributing
Contributions are welcome! Please read our contributing guidelines and submit pull requests to our repository.
š Support
Made with ā¤ļø by Echowaves Corp.
Powering beautiful photo experiences in WiSaw and beyond
