Props
Accessibility
Variants
Classic layouts
Masonry offers two "classic" layouts: basic
and basicCentered
. These layouts use a fixed column width and include whitespace (if necessary given the container width) on the right side or both sides of the grid, respectively.
function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } import { useEffect, useId, useRef, useState } from 'react'; import { Box, Flex, Image, Label, Masonry, Text } from 'gestalt'; function getPins() { const pins = [ { color: '#2b3938', height: 316, src: 'https://i.ibb.co/sQzHcFY/stock9.jpg', width: 474, name: 'the Hang Son Doong cave in Vietnam', }, { color: '#8e7439', height: 1081, src: 'https://i.ibb.co/zNDxPtn/stock10.jpg', width: 474, name: 'La Gran Muralla, Pekín, China', }, { color: '#698157', height: 711, src: 'https://i.ibb.co/M5TdMNq/stock11.jpg', width: 474, name: 'Plitvice Lakes National Park, Croatia', }, { color: '#4e5d50', height: 632, src: 'https://i.ibb.co/r0NZKrk/stock12.jpg', width: 474, name: 'Ban Gioc – Detian Falls : 2 waterfalls straddling the Vietnamese and Chinese border.', }, { color: '#6d6368', height: 710, src: 'https://i.ibb.co/zmFd0Dv/stock13.jpg', width: 474, name: 'Border of China and Vietnam', }, ]; const pinList = [...new Array(3)].map(() => [...pins]).flat(); return Promise.resolve(pinList); } function GridComponent({ data }) { return ( <Flex direction="column"> <Image alt={data.name} color={data.color} naturalHeight={data.height} naturalWidth={data.width} src={data.src} /> <Text>{data.name}</Text> </Flex> ); } export default function Example() { const [pins, setPins] = useState([]); const [width, setWidth] = useState(700); const scrollContainerRef = useRef(); const gridRef = useRef(); const labelId = useId(); useEffect(() => { getPins().then((startPins) => { setPins(startPins); }); }, []); useEffect(() => { _optionalChain([ gridRef, 'access', (_) => _.current, 'optionalAccess', (_2) => _2.handleResize, 'call', (_3) => _3(), ]); }, [width]); const updateWidth = ({ target }) => { setWidth(Number(target.value)); }; return ( <Box padding={2}> <Flex direction="column" gap={4}> <Flex alignItems="center" direction="column"> <Flex.Item> <Label htmlFor={labelId}> <Text>Container Width</Text> </Label> </Flex.Item> <input defaultValue={800} id={labelId} max={800} min={200} onChange={updateWidth} step={5} style={{ width: '400px', display: 'block', margin: '10px auto' }} type="range" /> </Flex> <div ref={(el) => { scrollContainerRef.current = el; }} style={{ height: '300px', margin: '0 auto', outline: '3px solid #ddd', overflowY: 'scroll', width: `${width}px`, }} tabIndex={0} > {scrollContainerRef.current && ( <Masonry ref={(ref) => { gridRef.current = ref; }} columnWidth={170} gutterWidth={20} items={pins} layout="basicCentered" minCols={1} renderItem={({ data }) => <GridComponent data={data} />} scrollContainer={() => scrollContainerRef.current} /> )} </div> </Flex> </Box> ); }
Flexible layouts
Masonry offers two layouts with flexible column widths: flexible
and serverRenderedFlexible
. These layouts use columnWidth
as a starting point, but grow or shrink the column width to fill the container width. This creates an immersive, responsive, "full bleed" experience.
serverRenderedFlexible
corrects an issue with rendering a flexible layout on the server. This layout option assumes that you have provided the proper CSS to ensure the layout is correct during SSR.
function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } import { useEffect, useId, useRef, useState } from 'react'; import { Box, Flex, Image, Label, Masonry, Text } from 'gestalt'; function getPins() { const pins = [ { color: '#2b3938', height: 316, src: 'https://i.ibb.co/sQzHcFY/stock9.jpg', width: 474, name: 'the Hang Son Doong cave in Vietnam', }, { color: '#8e7439', height: 1081, src: 'https://i.ibb.co/zNDxPtn/stock10.jpg', width: 474, name: 'La Gran Muralla, Pekín, China', }, { color: '#698157', height: 711, src: 'https://i.ibb.co/M5TdMNq/stock11.jpg', width: 474, name: 'Plitvice Lakes National Park, Croatia', }, { color: '#4e5d50', height: 632, src: 'https://i.ibb.co/r0NZKrk/stock12.jpg', width: 474, name: 'Ban Gioc – Detian Falls : 2 waterfalls straddling the Vietnamese and Chinese border.', }, { color: '#6d6368', height: 710, src: 'https://i.ibb.co/zmFd0Dv/stock13.jpg', width: 474, name: 'Border of China and Vietnam', }, ]; const pinList = [...new Array(3)].map(() => [...pins]).flat(); return Promise.resolve(pinList); } function GridComponent({ data }) { return ( <Flex direction="column"> <Image alt={data.name} color={data.color} naturalHeight={data.height} naturalWidth={data.width} src={data.src} /> <Text>{data.name}</Text> </Flex> ); } export default function Example() { const [pins, setPins] = useState([]); const [width, setWidth] = useState(700); const scrollContainerRef = useRef(); const gridRef = useRef(); const labelId = useId(); useEffect(() => { getPins().then((startPins) => { setPins(startPins); }); }, []); useEffect(() => { _optionalChain([ gridRef, 'access', (_) => _.current, 'optionalAccess', (_2) => _2.handleResize, 'call', (_3) => _3(), ]); }, [width]); const updateWidth = ({ target }) => { setWidth(Number(target.value)); }; return ( <Box padding={2}> <Flex direction="column" gap={4}> <Flex alignItems="center" direction="column"> <Flex.Item> <Label htmlFor={labelId}> <Text>Container Width</Text> </Label> </Flex.Item> <input defaultValue={800} id={labelId} max={800} min={200} onChange={updateWidth} step={5} style={{ width: '400px', display: 'block', margin: '10px auto' }} type="range" /> </Flex> <div ref={(el) => { scrollContainerRef.current = el; }} style={{ height: '300px', margin: '0 auto', outline: '3px solid #ddd', overflowY: 'scroll', width: `${width}px`, }} tabIndex={0} > {scrollContainerRef.current && ( <Masonry ref={(ref) => { gridRef.current = ref; }} columnWidth={170} gutterWidth={20} items={pins} layout="flexible" minCols={1} renderItem={({ data }) => <GridComponent data={data} />} scrollContainer={() => scrollContainerRef.current} /> )} </div> </Flex> </Box> ); }
Uniform row heights
Use the uniformRow
layout to create a grid with uniform row heights. Note that Masonry does not crop or otherwise alter items, so each row will be as tall as the tallest item in that row. Any shorter items within the row will have additional whitespace below them.
import { useEffect, useRef, useState } from 'react'; import { Box, Flex, Image, Masonry, Text } from 'gestalt'; function getPins() { const pins = [ { color: '#2b3938', height: 316, src: 'https://i.ibb.co/sQzHcFY/stock9.jpg', width: 474, name: 'the Hang Son Doong cave in Vietnam', }, { color: '#8e7439', height: 1081, src: 'https://i.ibb.co/zNDxPtn/stock10.jpg', width: 474, name: 'La Gran Muralla, Pekín, China', }, { color: '#698157', height: 711, src: 'https://i.ibb.co/M5TdMNq/stock11.jpg', width: 474, name: 'Plitvice Lakes National Park, Croatia', }, { color: '#4e5d50', height: 632, src: 'https://i.ibb.co/r0NZKrk/stock12.jpg', width: 474, name: 'Ban Gioc – Detian Falls : 2 waterfalls straddling the Vietnamese and Chinese border.', }, { color: '#6d6368', height: 710, src: 'https://i.ibb.co/zmFd0Dv/stock13.jpg', width: 474, name: 'Border of China and Vietnam', }, ]; const pinList = [...new Array(3)].map(() => [...pins]).flat(); return Promise.resolve(pinList); } function GridComponent({ data }) { return ( <Flex direction="column"> <Image alt={data.name} color={data.color} naturalHeight={data.height} naturalWidth={data.width} src={data.src} /> <Text>{data.name}</Text> </Flex> ); } export default function Example() { const [pins, setPins] = useState([]); const scrollContainerRef = useRef(); const gridRef = useRef(); useEffect(() => { getPins().then((startPins) => { setPins(startPins); }); }, []); return ( <Box padding={2}> <div ref={(el) => { scrollContainerRef.current = el; }} style={{ height: '375px', margin: '0 auto', outline: '3px solid #ddd', overflowY: 'scroll', width: `800px`, }} tabIndex={0} > {scrollContainerRef.current && ( <Masonry ref={(ref) => { gridRef.current = ref; }} columnWidth={170} gutterWidth={20} items={pins} layout="uniformRow" minCols={1} renderItem={({ data }) => <GridComponent data={data} />} scrollContainer={() => scrollContainerRef.current} /> )} </div> </Box> ); }
Align
Align options
The align property controls the horizontal alignment of items within the Masonry grid, determining how items are distributed across the available horizontal space of the container. The align options allow you to align items to the start, center, or end of the container.
Align only works when layout='basic'.
import { useEffect, useId, useRef, useState } from 'react'; import { Box, Flex, Image, Label, Masonry, RadioGroup, Text } from 'gestalt'; function getPins() { const pins = [ { color: '#2b3938', height: 316, src: 'https://i.ibb.co/sQzHcFY/stock9.jpg', width: 474, name: 'the Hang Son Doong cave in Vietnam', }, { color: '#8e7439', height: 1081, src: 'https://i.ibb.co/zNDxPtn/stock10.jpg', width: 474, name: 'La Gran Muralla, Pekín, China', }, { color: '#698157', height: 711, src: 'https://i.ibb.co/M5TdMNq/stock11.jpg', width: 474, name: 'Plitvice Lakes National Park, Croatia', }, { color: '#4e5d50', height: 632, src: 'https://i.ibb.co/r0NZKrk/stock12.jpg', width: 474, name: 'Ban Gioc – Detian Falls : 2 waterfalls straddling the Vietnamese and Chinese border.', }, { color: '#6d6368', height: 710, src: 'https://i.ibb.co/zmFd0Dv/stock13.jpg', width: 474, name: 'Border of China and Vietnam', }, ]; const pinList = Array.from({ length: 1 }, () => pins).flat(); return Promise.resolve(pinList); } function GridComponent({ data }) { return ( <Flex direction="column"> <Image alt={data.name} color={data.color} naturalHeight={data.height} naturalWidth={data.width} src={data.src} /> <Text>{data.name}</Text> </Flex> ); } export default function Example() { const [layout, setLayout] = useState('basic'); const [align, setAlign] = useState('center'); const [pins, setPins] = useState([]); const [width, setWidth] = useState(700); const scrollContainerRef = useRef(); const gridRef = useRef(); const labelId = useId(); useEffect(() => { getPins().then((startPins) => { setPins(startPins); }); }, []); useEffect(() => { if (gridRef.current) { gridRef.current.handleResize(); } }, [width]); const updateWidth = ({ target }) => { setWidth(Number(target.value)); }; return ( <Box padding={2}> <Flex direction="column" gap={4}> <Flex alignItems="center" direction="column"> <Flex.Item> <Label htmlFor={labelId}> <Text>Container Width</Text> </Label> </Flex.Item> <input defaultValue={800} id={labelId} max={8000} min={200} onChange={updateWidth} step={5} style={{ width: '400px', display: 'block', margin: '10px auto' }} type="range" /> </Flex> <div ref={(el) => { scrollContainerRef.current = el; }} style={{ height: '300px', margin: '0 auto', outline: '3px solid #ddd', overflowY: 'scroll', width: `${width}px`, }} tabIndex={0} > {scrollContainerRef.current && ( <Masonry ref={(ref) => { gridRef.current = ref; }} align={align} columnWidth={170} gutterWidth={20} items={pins} layout={layout} minCols={1} renderItem={({ data }) => <GridComponent data={data} />} scrollContainer={() => scrollContainerRef.current} /> )} </div> </Flex> <Flex gap={12} justifyContent="center"> <RadioGroup id="layoutOptions" legend="Layout"> <RadioGroup.RadioButton checked={layout === 'basic'} id="basic" label="basic" name="layout" onChange={() => setLayout('basic')} value="basic" /> <RadioGroup.RadioButton checked={layout === 'basicCentered'} id="basicCentered" label="basicCentered" name="layout" onChange={() => setLayout('basicCentered')} value="basicCentered" /> </RadioGroup> <RadioGroup id="alignOptions" legend="Align"> <RadioGroup.RadioButton checked={align === 'start'} id="start" label="Start" name="align" onChange={() => setAlign('start')} value="start" /> <RadioGroup.RadioButton checked={align === 'center'} id="center" label="Center (default)" name="align" onChange={() => setAlign('center')} value="center" /> <RadioGroup.RadioButton checked={align === 'end'} id="end" label="End" name="align" onChange={() => setAlign('end')} value="end" /> </RadioGroup> </Flex> </Box> ); }
How Masonry works
Generally, Masonry renders items in two passes: an initial render off-screen to collect measurements, then an on-screen render with the correct measurements. This is necessary because we need to know the height of each item before we can render it in the correct position. This mental model is necessary to understand
the serverRenderedFlexible
layout, as well as
the common overlap / extra vertical whitespace bug.
Check out this README for more details about how Masonry works. Pinterest employees can also check out this PDocs page to learn more about our Masonry SSR optimizations in Pinboard.
Why is there too much / too little vertical whitespace between items?
As mentioned above, Masonry calculates the height of each item before rendering it. This means that if the height of an item changes after it has been rendered, the items below it will not be repositioned. This can lead to extra whitespace between items if the height of an item decreases, or overlapping items if the height of an item increases.
To avoid this issue, ensure that your items do not change height after their intial render. Common causes of this issue include:
- lazy-loading item content (especially things that increase item height, like Pin footer content)
- placeholder images that don't match the size of the final content (this is particularly common with videos)
- items that grow/shrink based on user interaction (this requires reflowing the entire grid)
Component quality checklist
Quality item | Status | Status description |
---|---|---|
Figma Library | Ready | Component is available in Figma for web and mobile web. |
Responsive Web | Ready | Component responds to changing viewport sizes in web and mobile web. |