Masonry creates a deterministic grid layout, positioning items based on available vertical space. It contains performance optimizations like virtualization and support for infinite scrolling.

also known as Grid, Image List

Figma:

Responsive:

Adaptive:

Props

Component props
Name
Type
Default
items
Required
ReadonlyArray<T>
-

An array of items to display that contains the data to be rendered by renderItem.

renderItem
Required
(arg1: {
  readonly data: T;
  readonly itemIdx: number;
  readonly isMeasuring: boolean;
}) => React.Node
-

A function that renders the item you would like displayed in the grid. This function is passed three props: the item's data, the item's index in the grid, and a flag indicating if Masonry is currently measuring the item.

If present, heightAdjustment indicates the number of pixels this item needs to grow/shrink to accommodate a 2-column item in the grid. Items must respond to this prop by adjusting their height or layout issues will occur.

align
"start" | "center" | "end"
"center"

Controls the horizontal alignment of items within the Masonry grid. The align property determines how items are aligned along the main-axis (horizontally) across multiple columns.
start: Aligns items to the start of the Masonry container. This is the default behavior where items are placed starting from the left side of the container.
center: Centers items in the Masonry grid. This will adjust the spacing on either side of the grid to ensure that the items are centered within the container.
end: Aligns items to the end of the Masonry container. Items will be placed starting from the right, moving leftwards, which may leave space on the left side of the container.
Using the align property can help control the visual balance and alignment of the grid, especially in responsive layouts or when dealing with varying item widths.

Note that layout='basic' must be set for align to take effect.

columnWidth
number
236

The preferred/target item width in pixels. If layout="flexible" is set, the item width will
grow to fill column space, and shrink to fit if below the minimum number of columns.

gutterWidth
number
-

The amount of vertical and horizontal space between each item, specified in pixels.

layout
"basic"
| "basicCentered"
| "flexible"
| "serverRenderedFlexible"
| "uniformRow"
"basic"

basic: Left-aligned, fixed-column-width masonry layout.
basicCentered: Center-aligned, fixed-column-width masonry layout.
flexible: Item width grows to fill column space and shrinks to fit if below the minimum number of columns.
serverRenderedFlexible: Item width grows to fill column space and shrinks to fit if below the minimum number of columns. Main differerence with flexible is that we do not store the initial measurement. More context in #2084
uniformRow: Items are laid out in a single row, with all items having the same height. Note that Masonry does not crop or alter items in any way — rows will take the height of the tallest item in the row, with additional whitespace shown below any shorter items.

loadItems
(
  arg1?:
    | {
        from: number;
      }
    | null
    | undefined,
) => void
-

A callback fired when the user scrolls past a given threshold, based on the height of the container. The callback should update the state of the items, which must be reflected in the items prop.

Note that scrollContainer must be specified.

measurementStore
typeof MeasurementStore
-

Masonry internally caches item heights using a measurement store. If measurementStore is provided, Masonry will use it as its cache and will keep it updated with future measurements. This is often used to prevent re-measurement when users navigate away from and back to a grid. Create a new measurement store with Masonry.createMeasurementStore().

minCols
number
3

Minimum number of columns to display, regardless of the container width.

positionStore
Cache<T, Position>
-

Masonry internally caches positions using a position store. If positionStore is provided, Masonry will use it as its cache and will keep it updated with future positions.

scrollContainer
() => HTMLElement
-

A function that returns a DOM node that Masonry uses for scroll event subscription. This DOM node is intended to be the most immediate ancestor of Masonry in the DOM that will have a scroll bar; in most cases this will be the window itself, although sometimes Masonry is used inside containers that have overflow: auto. scrollContainer is optional, although it is required for features such as virtualize and loadItems.

This is required if the grid is expected to be scrollable.

virtualBoundsBottom
number
-

If virtualize is enabled, Masonry will only render items that fit in the viewport, plus some buffer. virtualBoundsBottom allows customization of the buffer size below the viewport, specified in pixels.

virtualBoundsTop
number
-

If virtualize is enabled, Masonry will only render items that fit in the viewport, plus some buffer. virtualBoundsTop allows customization of the buffer size above the viewport, specified in pixels.

virtualBufferFactor
number
0.7

If virtualize is enabled, Masonry will only render items that fit in the viewport, plus some buffer. virtualBufferFactor allows customization of the buffer size, specified as a multiplier of the container height. It specifies the amount of extra buffer space for populating visible items. For example, if virtualBufferFactor is 2, then Masonry will render items that fit in the viewport, plus 2x the viewport height.

virtualize
boolean
false

Specifies whether or not Masonry dynamically adds/removes content from the grid based on the user's viewport and scroll position. Note that scrollContainer must be specified when virtualization is used.

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

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.

Internal documentation