Dice UI
Components

Masonry

A responsive masonry layout component for displaying items in a grid.

import { Skeleton } from "@/components/ui/skeleton";
import * as Masonry from "@/components/ui/masonry";
import * as React from "react";
 
const items = [
  {
    id: "1",
    title: "The 900",
    description: "The 900 is a trick where you spin 900 degrees in the air.",
  },
  {
    id: "2",
    title: "Indy Backflip",
    description:
      "The Indy Backflip is a trick where you backflip in the air while grabbing the board with your back hand.",
  },
  {
    id: "3",
    title: "Pizza Guy",
    description:
      "The Pizza Guy is a trick where you flip the board like a pizza.",
  },
  {
    id: "4",
    title: "Rocket Air",
    description:
      "The Rocket Air is a trick where you grab the nose of your board and point it straight up to the sky.",
  },
  {
    id: "5",
    title: "Kickflip Backflip",
    description:
      "The Kickflip Backflip is a trick where you perform a kickflip while doing a backflip simultaneously.",
  },
  {
    id: "6",
    title: "FS 540",
    description:
      "The FS 540 is a trick where you spin frontside 540 degrees in the air.",
  },
];
 
export function MasonryDemo() {
  return (
    <Masonry.Root
      columnCount={3}
      gap={12}
      fallback={<Skeleton className="h-72 w-full" />}
    >
      {items.map((item) => (
        <Masonry.Item key={item.id} asChild>
          <div className="flex flex-col gap-1 rounded-md border bg-card p-4 text-card-foreground shadow-xs">
            <div className="font-medium text-sm leading-tight sm:text-base">
              {item.title}
            </div>
            <span className="text-muted-foreground text-sm">
              {item.description}
            </span>
          </div>
        </Masonry.Item>
      ))}
    </Masonry.Root>
  );
}

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/masonry"
pnpm dlx shadcn@latest add "https://diceui.com/r/masonry"
yarn dlx shadcn@latest add "https://diceui.com/r/masonry"
bun x shadcn@latest add "https://diceui.com/r/masonry"

Manual

Install the following dependencies:

npm install @radix-ui/react-slot
pnpm add @radix-ui/react-slot
yarn add @radix-ui/react-slot
bun add @radix-ui/react-slot

Copy the refs composition utilities into your lib/compose-refs.ts file.

/**
 * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
 */
 
import * as React from "react";
 
type PossibleRef<T> = React.Ref<T> | undefined;
 
/**
 * Set a given ref to a given value
 * This utility takes care of different types of refs: callback refs and RefObject(s)
 */
function setRef<T>(ref: PossibleRef<T>, value: T) {
  if (typeof ref === "function") {
    return ref(value);
  }
 
  if (ref !== null && ref !== undefined) {
    ref.current = value;
  }
}
 
/**
 * A utility to compose multiple refs together
 * Accepts callback refs and RefObject(s)
 */
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  return (node) => {
    let hasCleanup = false;
    const cleanups = refs.map((ref) => {
      const cleanup = setRef(ref, node);
      if (!hasCleanup && typeof cleanup === "function") {
        hasCleanup = true;
      }
      return cleanup;
    });
 
    // React <19 will log an error to the console if a callback ref returns a
    // value. We don't use ref cleanups internally so this will only happen if a
    // user's ref callback returns a value, which we only expect if they are
    // using the cleanup functionality added in React 19.
    if (hasCleanup) {
      return () => {
        for (let i = 0; i < cleanups.length; i++) {
          const cleanup = cleanups[i];
          if (typeof cleanup === "function") {
            cleanup();
          } else {
            setRef(refs[i], null);
          }
        }
      };
    }
  };
}
 
/**
 * A custom hook that composes multiple refs
 * Accepts callback refs and RefObject(s)
 */
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
  // eslint-disable-next-line react-hooks/exhaustive-deps
  return React.useCallback(composeRefs(...refs), refs);
}
 
export { composeRefs, useComposedRefs };

Copy and paste the following code into your project.

"use client";
 
import { useComposedRefs } from "@/lib/compose-refs";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
 
const NODE_COLOR = {
  RED: 0,
  BLACK: 1,
  SENTINEL: 2,
} as const;
 
const NODE_OPERATION = {
  REMOVE: 0,
  PRESERVE: 1,
} as const;
 
type NodeColor = (typeof NODE_COLOR)[keyof typeof NODE_COLOR];
type NodeOperation = (typeof NODE_OPERATION)[keyof typeof NODE_OPERATION];
 
interface ListNode {
  index: number;
  high: number;
  next: ListNode | null;
}
 
interface TreeNode {
  max: number;
  low: number;
  high: number;
  color: NodeColor;
  parent: TreeNode;
  right: TreeNode;
  left: TreeNode;
  list: ListNode;
}
 
interface Tree {
  root: TreeNode;
  size: number;
}
 
function addInterval(treeNode: TreeNode, high: number, index: number): boolean {
  let node: ListNode | null = treeNode.list;
  let prevNode: ListNode | undefined;
 
  while (node) {
    if (node.index === index) return false;
    if (high > node.high) break;
    prevNode = node;
    node = node.next;
  }
 
  if (!prevNode) treeNode.list = { index, high, next: node };
  if (prevNode) prevNode.next = { index, high, next: prevNode.next };
 
  return true;
}
 
function removeInterval(
  treeNode: TreeNode,
  index: number,
): NodeOperation | undefined {
  let node: ListNode | null = treeNode.list;
  if (node.index === index) {
    if (node.next === null) return NODE_OPERATION.REMOVE;
    treeNode.list = node.next;
    return NODE_OPERATION.PRESERVE;
  }
 
  let prevNode: ListNode | undefined = node;
  node = node.next;
 
  while (node !== null) {
    if (node.index === index) {
      prevNode.next = node.next;
      return NODE_OPERATION.PRESERVE;
    }
    prevNode = node;
    node = node.next;
  }
}
 
const SENTINEL_NODE: TreeNode = {
  low: 0,
  max: 0,
  high: 0,
  color: NODE_COLOR.SENTINEL,
  parent: undefined as unknown as TreeNode,
  right: undefined as unknown as TreeNode,
  left: undefined as unknown as TreeNode,
  list: undefined as unknown as ListNode,
};
 
SENTINEL_NODE.parent = SENTINEL_NODE;
SENTINEL_NODE.left = SENTINEL_NODE;
SENTINEL_NODE.right = SENTINEL_NODE;
 
function updateMax(node: TreeNode) {
  const max = node.high;
  if (node.left === SENTINEL_NODE && node.right === SENTINEL_NODE)
    node.max = max;
  else if (node.left === SENTINEL_NODE)
    node.max = Math.max(node.right.max, max);
  else if (node.right === SENTINEL_NODE)
    node.max = Math.max(node.left.max, max);
  else node.max = Math.max(Math.max(node.left.max, node.right.max), max);
}
 
function updateMaxUp(node: TreeNode) {
  let x = node;
 
  while (x.parent !== SENTINEL_NODE) {
    updateMax(x.parent);
    x = x.parent;
  }
}
 
function rotateLeft(tree: Tree, x: TreeNode) {
  if (x.right === SENTINEL_NODE) return;
  const y = x.right;
  x.right = y.left;
  if (y.left !== SENTINEL_NODE) y.left.parent = x;
  y.parent = x.parent;
 
  if (x.parent === SENTINEL_NODE) tree.root = y;
  else if (x === x.parent.left) x.parent.left = y;
  else x.parent.right = y;
 
  y.left = x;
  x.parent = y;
 
  updateMax(x);
  updateMax(y);
}
 
function rotateRight(tree: Tree, x: TreeNode) {
  if (x.left === SENTINEL_NODE) return;
  const y = x.left;
  x.left = y.right;
  if (y.right !== SENTINEL_NODE) y.right.parent = x;
  y.parent = x.parent;
 
  if (x.parent === SENTINEL_NODE) tree.root = y;
  else if (x === x.parent.right) x.parent.right = y;
  else x.parent.left = y;
 
  y.right = x;
  x.parent = y;
 
  updateMax(x);
  updateMax(y);
}
 
function replaceNode(tree: Tree, x: TreeNode, y: TreeNode) {
  if (x.parent === SENTINEL_NODE) tree.root = y;
  else if (x === x.parent.left) x.parent.left = y;
  else x.parent.right = y;
  y.parent = x.parent;
}
 
function fixRemove(tree: Tree, node: TreeNode) {
  let x = node;
  let w: TreeNode;
 
  while (x !== SENTINEL_NODE && x.color === NODE_COLOR.BLACK) {
    if (x === x.parent.left) {
      w = x.parent.right;
 
      if (w.color === NODE_COLOR.RED) {
        w.color = NODE_COLOR.BLACK;
        x.parent.color = NODE_COLOR.RED;
        rotateLeft(tree, x.parent);
        w = x.parent.right;
      }
 
      if (
        w.left.color === NODE_COLOR.BLACK &&
        w.right.color === NODE_COLOR.BLACK
      ) {
        w.color = NODE_COLOR.RED;
        x = x.parent;
      } else {
        if (w.right.color === NODE_COLOR.BLACK) {
          w.left.color = NODE_COLOR.BLACK;
          w.color = NODE_COLOR.RED;
          rotateRight(tree, w);
          w = x.parent.right;
        }
 
        w.color = x.parent.color;
        x.parent.color = NODE_COLOR.BLACK;
        w.right.color = NODE_COLOR.BLACK;
        rotateLeft(tree, x.parent);
        x = tree.root;
      }
    } else {
      w = x.parent.left;
 
      if (w.color === NODE_COLOR.RED) {
        w.color = NODE_COLOR.BLACK;
        x.parent.color = NODE_COLOR.RED;
        rotateRight(tree, x.parent);
        w = x.parent.left;
      }
 
      if (
        w.right.color === NODE_COLOR.BLACK &&
        w.left.color === NODE_COLOR.BLACK
      ) {
        w.color = NODE_COLOR.RED;
        x = x.parent;
      } else {
        if (w.left.color === NODE_COLOR.BLACK) {
          w.right.color = NODE_COLOR.BLACK;
          w.color = NODE_COLOR.RED;
          rotateLeft(tree, w);
          w = x.parent.left;
        }
 
        w.color = x.parent.color;
        x.parent.color = NODE_COLOR.BLACK;
        w.left.color = NODE_COLOR.BLACK;
        rotateRight(tree, x.parent);
        x = tree.root;
      }
    }
  }
 
  x.color = NODE_COLOR.BLACK;
}
 
function minimumTree(node: TreeNode) {
  let current = node;
  while (current.left !== SENTINEL_NODE) {
    current = current.left;
  }
  return current;
}
 
function fixInsert(tree: Tree, node: TreeNode) {
  let current = node;
  let y: TreeNode;
 
  while (current.parent.color === NODE_COLOR.RED) {
    if (current.parent === current.parent.parent.left) {
      y = current.parent.parent.right;
 
      if (y.color === NODE_COLOR.RED) {
        current.parent.color = NODE_COLOR.BLACK;
        y.color = NODE_COLOR.BLACK;
        current.parent.parent.color = NODE_COLOR.RED;
        current = current.parent.parent;
      } else {
        if (current === current.parent.right) {
          current = current.parent;
          rotateLeft(tree, current);
        }
 
        current.parent.color = NODE_COLOR.BLACK;
        current.parent.parent.color = NODE_COLOR.RED;
        rotateRight(tree, current.parent.parent);
      }
    } else {
      y = current.parent.parent.left;
 
      if (y.color === NODE_COLOR.RED) {
        current.parent.color = NODE_COLOR.BLACK;
        y.color = NODE_COLOR.BLACK;
        current.parent.parent.color = NODE_COLOR.RED;
        current = current.parent.parent;
      } else {
        if (current === current.parent.left) {
          current = current.parent;
          rotateRight(tree, current);
        }
 
        current.parent.color = NODE_COLOR.BLACK;
        current.parent.parent.color = NODE_COLOR.RED;
        rotateLeft(tree, current.parent.parent);
      }
    }
  }
  tree.root.color = NODE_COLOR.BLACK;
}
 
interface IntervalTree {
  insert(low: number, high: number, index: number): void;
  remove(index: number): void;
  search(
    low: number,
    high: number,
    onCallback: (index: number, low: number) => void,
  ): void;
  size: number;
}
 
function createIntervalTree(): IntervalTree {
  const tree: Tree = {
    root: SENTINEL_NODE,
    size: 0,
  };
 
  const indexMap: Record<number, TreeNode> = {};
 
  return {
    insert(low, high, index) {
      let x: TreeNode = tree.root;
      let y: TreeNode = SENTINEL_NODE;
 
      while (x !== SENTINEL_NODE) {
        y = x;
        if (low === y.low) break;
        if (low < x.low) x = x.left;
        else x = x.right;
      }
 
      if (low === y.low && y !== SENTINEL_NODE) {
        if (!addInterval(y, high, index)) return;
        y.high = Math.max(y.high, high);
        updateMax(y);
        updateMaxUp(y);
        indexMap[index] = y;
        tree.size++;
        return;
      }
 
      const z: TreeNode = {
        low,
        high,
        max: high,
        color: NODE_COLOR.RED,
        parent: y,
        left: SENTINEL_NODE,
        right: SENTINEL_NODE,
        list: { index, high, next: null },
      };
 
      if (y === SENTINEL_NODE) {
        tree.root = z;
      } else {
        if (z.low < y.low) y.left = z;
        else y.right = z;
        updateMaxUp(z);
      }
 
      fixInsert(tree, z);
      indexMap[index] = z;
      tree.size++;
    },
 
    remove(index) {
      const z = indexMap[index];
      if (z === void 0) return;
      delete indexMap[index];
 
      const intervalResult = removeInterval(z, index);
      if (intervalResult === void 0) return;
      if (intervalResult === NODE_OPERATION.PRESERVE) {
        z.high = z.list.high;
        updateMax(z);
        updateMaxUp(z);
        tree.size--;
        return;
      }
 
      let y = z;
      let originalYColor = y.color;
      let x: TreeNode;
 
      if (z.left === SENTINEL_NODE) {
        x = z.right;
        replaceNode(tree, z, z.right);
      } else if (z.right === SENTINEL_NODE) {
        x = z.left;
        replaceNode(tree, z, z.left);
      } else {
        y = minimumTree(z.right);
        originalYColor = y.color;
        x = y.right;
 
        if (y.parent === z) {
          x.parent = y;
        } else {
          replaceNode(tree, y, y.right);
          y.right = z.right;
          y.right.parent = y;
        }
 
        replaceNode(tree, z, y);
        y.left = z.left;
        y.left.parent = y;
        y.color = z.color;
      }
 
      updateMax(x);
      updateMaxUp(x);
 
      if (originalYColor === NODE_COLOR.BLACK) fixRemove(tree, x);
      tree.size--;
    },
 
    search(low, high, onCallback) {
      const stack = [tree.root];
      while (stack.length !== 0) {
        const node = stack.pop();
        if (!node) continue;
        if (node === SENTINEL_NODE || low > node.max) continue;
        if (node.left !== SENTINEL_NODE) stack.push(node.left);
        if (node.right !== SENTINEL_NODE) stack.push(node.right);
        if (node.low <= high && node.high >= low) {
          let curr: ListNode | null = node.list;
          while (curr !== null) {
            if (curr.high >= low) onCallback(curr.index, node.low);
            curr = curr.next;
          }
        }
      }
    },
 
    get size() {
      return tree.size;
    },
  };
}
 
type CacheKey = string | number | symbol;
type CacheConstructor = (new () => Cache) | Record<CacheKey, unknown>;
 
interface Cache<K = CacheKey, V = unknown> {
  set: (k: K, v: V) => V;
  get: (k: K) => V | undefined;
}
 
function onDeepMemo<T extends unknown[], U>(
  constructors: CacheConstructor[],
  fn: (...args: T) => U,
): (...args: T) => U {
  if (!constructors.length || !constructors[0]) {
    throw new Error("At least one constructor is required");
  }
 
  function createCache(obj: CacheConstructor): Cache {
    let cache: Cache;
    if (typeof obj === "function") {
      try {
        cache = new (obj as new () => Cache)();
      } catch (_err) {
        cache = new Map<CacheKey, unknown>();
      }
    } else {
      cache = obj as unknown as Cache;
    }
    return {
      set(k: CacheKey, v: unknown): unknown {
        cache.set(k, v);
        return v;
      },
      get(k: CacheKey): unknown | undefined {
        return cache.get(k);
      },
    };
  }
 
  const depth = constructors.length;
  const baseCache = createCache(constructors[0]);
 
  let base: Cache | undefined;
  let map: Cache | undefined;
  let node: Cache;
  let i: number;
  const one = depth === 1;
 
  function get(args: unknown[]): unknown {
    if (depth < 3) {
      const key = args[0] as CacheKey;
      base = baseCache.get(key) as Cache | undefined;
      return one ? base : base?.get(args[1] as CacheKey);
    }
 
    node = baseCache;
    for (i = 0; i < depth; i++) {
      const next = node.get(args[i] as CacheKey);
      if (!next) return undefined;
      node = next as Cache;
    }
    return node;
  }
 
  function set(args: unknown[], value: unknown): unknown {
    if (depth < 3) {
      if (one) {
        baseCache.set(args[0] as CacheKey, value);
      } else {
        base = baseCache.get(args[0] as CacheKey) as Cache | undefined;
        if (!base) {
          if (!constructors[1]) {
            throw new Error(
              "Second constructor is required for non-single depth cache",
            );
          }
          map = createCache(constructors[1]);
          map.set(args[1] as CacheKey, value);
          baseCache.set(args[0] as CacheKey, map);
        } else {
          base.set(args[1] as CacheKey, value);
        }
      }
      return value;
    }
 
    node = baseCache;
    for (i = 0; i < depth - 1; i++) {
      map = node.get(args[i] as CacheKey) as Cache | undefined;
      if (!map) {
        const nextConstructor = constructors[i + 1];
        if (!nextConstructor) {
          throw new Error(`Constructor at index ${i + 1} is required`);
        }
        map = createCache(nextConstructor);
        node.set(args[i] as CacheKey, map);
        node = map;
      } else {
        node = map;
      }
    }
    node.set(args[depth - 1] as CacheKey, value);
    return value;
  }
 
  return (...args: T): U => {
    const cached = get(args);
    if (cached === undefined) {
      return set(args, fn(...args)) as U;
    }
    return cached as U;
  };
}
 
const COLUMN_WIDTH = 200;
const GAP = 0;
const ITEM_HEIGHT = 300;
const OVERSCAN = 2;
const SCROLL_FPS = 12;
const DEBOUNCE_DELAY = 300;
 
interface Positioner {
  columnCount: number;
  columnWidth: number;
  set: (index: number, height: number) => void;
  get: (index: number) => PositionerItem | undefined;
  update: (updates: number[]) => void;
  range: (
    low: number,
    high: number,
    onItemRender: (index: number, left: number, top: number) => void,
  ) => void;
  size: () => number;
  estimateHeight: (itemCount: number, defaultItemHeight: number) => number;
  shortestColumn: () => number;
  all: () => PositionerItem[];
}
 
interface PositionerItem {
  top: number;
  left: number;
  height: number;
  columnIndex: number;
}
 
interface UsePositionerOptions {
  width: number;
  columnWidth?: number;
  columnGap?: number;
  rowGap?: number;
  columnCount?: number;
  maxColumnCount?: number;
  linear?: boolean;
}
 
function usePositioner(
  {
    width,
    columnWidth = COLUMN_WIDTH,
    columnGap = GAP,
    rowGap,
    columnCount,
    maxColumnCount,
    linear = false,
  }: UsePositionerOptions,
  deps: React.DependencyList = [],
): Positioner {
  const initPositioner = React.useCallback((): Positioner => {
    function binarySearch(a: number[], y: number): number {
      let l = 0;
      let h = a.length - 1;
 
      while (l <= h) {
        const m = (l + h) >>> 1;
        const x = a[m];
        if (x === y) return m;
        if (x === undefined || x <= y) l = m + 1;
        else h = m - 1;
      }
 
      return -1;
    }
 
    const computedColumnCount =
      columnCount ||
      Math.min(
        Math.floor((width + columnGap) / (columnWidth + columnGap)),
        maxColumnCount || Number.POSITIVE_INFINITY,
      ) ||
      1;
    const computedColumnWidth = Math.floor(
      (width - columnGap * (computedColumnCount - 1)) / computedColumnCount,
    );
 
    const intervalTree = createIntervalTree();
    const columnHeights: number[] = new Array(computedColumnCount).fill(0);
    const items: (PositionerItem | undefined)[] = [];
    const columnItems: number[][] = new Array(computedColumnCount)
      .fill(0)
      .map(() => []);
 
    for (let i = 0; i < computedColumnCount; i++) {
      columnHeights[i] = 0;
      columnItems[i] = [];
    }
 
    return {
      columnCount: computedColumnCount,
      columnWidth: computedColumnWidth,
      set: (index: number, height = 0) => {
        let columnIndex = 0;
 
        if (linear) {
          const preferredColumn = index % computedColumnCount;
 
          let shortestHeight = columnHeights[0] ?? 0;
          let tallestHeight = shortestHeight;
          let shortestIndex = 0;
 
          for (let i = 0; i < columnHeights.length; i++) {
            const currentHeight = columnHeights[i] ?? 0;
            if (currentHeight < shortestHeight) {
              shortestHeight = currentHeight;
              shortestIndex = i;
            }
            if (currentHeight > tallestHeight) {
              tallestHeight = currentHeight;
            }
          }
 
          const preferredHeight =
            (columnHeights[preferredColumn] ?? 0) + height;
 
          const maxAllowedHeight = shortestHeight + height * 2.5;
          columnIndex =
            preferredHeight <= maxAllowedHeight
              ? preferredColumn
              : shortestIndex;
        } else {
          for (let i = 1; i < columnHeights.length; i++) {
            const currentHeight = columnHeights[i];
            const shortestHeight = columnHeights[columnIndex];
            if (
              currentHeight !== undefined &&
              shortestHeight !== undefined &&
              currentHeight < shortestHeight
            ) {
              columnIndex = i;
            }
          }
        }
 
        const columnHeight = columnHeights[columnIndex];
        if (columnHeight === undefined) return;
 
        const top = columnHeight;
        columnHeights[columnIndex] = top + height + (rowGap ?? columnGap);
 
        const columnItemsList = columnItems[columnIndex];
        if (!columnItemsList) return;
        columnItemsList.push(index);
 
        items[index] = {
          left: columnIndex * (computedColumnWidth + columnGap),
          top,
          height,
          columnIndex,
        };
        intervalTree.insert(top, top + height, index);
      },
      get: (index: number) => items[index],
      update: (updates: number[]) => {
        const columns: (number | undefined)[] = new Array(computedColumnCount);
        let i = 0;
        let j = 0;
 
        for (; i < updates.length - 1; i++) {
          const currentIndex = updates[i];
          if (typeof currentIndex !== "number") continue;
 
          const item = items[currentIndex];
          if (!item) continue;
 
          const nextHeight = updates[++i];
          if (typeof nextHeight !== "number") continue;
 
          item.height = nextHeight;
          intervalTree.remove(currentIndex);
          intervalTree.insert(item.top, item.top + item.height, currentIndex);
          columns[item.columnIndex] =
            columns[item.columnIndex] === void 0
              ? currentIndex
              : Math.min(
                  currentIndex,
                  columns[item.columnIndex] ?? currentIndex,
                );
        }
 
        for (i = 0; i < columns.length; i++) {
          const currentColumn = columns[i];
          if (currentColumn === void 0) continue;
 
          const itemsInColumn = columnItems[i];
          if (!itemsInColumn) continue;
 
          const startIndex = binarySearch(itemsInColumn, currentColumn);
          if (startIndex === -1) continue;
 
          const currentItemIndex = itemsInColumn[startIndex];
          if (typeof currentItemIndex !== "number") continue;
 
          const startItem = items[currentItemIndex];
          if (!startItem) continue;
 
          const currentHeight = columnHeights[i];
          if (typeof currentHeight !== "number") continue;
 
          columnHeights[i] =
            startItem.top + startItem.height + (rowGap ?? columnGap);
 
          for (j = startIndex + 1; j < itemsInColumn.length; j++) {
            const currentIndex = itemsInColumn[j];
            if (typeof currentIndex !== "number") continue;
 
            const item = items[currentIndex];
            if (!item) continue;
 
            const columnHeight = columnHeights[i];
            if (typeof columnHeight !== "number") continue;
 
            item.top = columnHeight;
            columnHeights[i] = item.top + item.height + (rowGap ?? columnGap);
            intervalTree.remove(currentIndex);
            intervalTree.insert(item.top, item.top + item.height, currentIndex);
          }
        }
      },
      range: (low, high, onItemRender) =>
        intervalTree.search(low, high, (index: number, top: number) => {
          const item = items[index];
          if (!item) return;
          onItemRender(index, item.left, top);
        }),
      estimateHeight: (itemCount, defaultItemHeight): number => {
        const tallestColumn = Math.max(0, Math.max.apply(null, columnHeights));
 
        return itemCount === intervalTree.size
          ? tallestColumn
          : tallestColumn +
              Math.ceil((itemCount - intervalTree.size) / computedColumnCount) *
                defaultItemHeight;
      },
      shortestColumn: () => {
        if (columnHeights.length > 1)
          return Math.min.apply(null, columnHeights);
        return columnHeights[0] ?? 0;
      },
      size(): number {
        return intervalTree.size;
      },
      all(): PositionerItem[] {
        return items.filter(Boolean) as PositionerItem[];
      },
    };
  }, [
    width,
    columnWidth,
    columnGap,
    rowGap,
    columnCount,
    maxColumnCount,
    linear,
  ]);
 
  const positionerRef = React.useRef<Positioner | null>(null);
  if (positionerRef.current === null) positionerRef.current = initPositioner();
 
  const prevDepsRef = React.useRef(deps);
  const opts = [
    width,
    columnWidth,
    columnGap,
    rowGap,
    columnCount,
    maxColumnCount,
    linear,
  ];
  const prevOptsRef = React.useRef(opts);
  const optsChanged = !opts.every((item, i) => prevOptsRef.current[i] === item);
 
  if (
    optsChanged ||
    !deps.every((item, i) => prevDepsRef.current[i] === item)
  ) {
    const prevPositioner = positionerRef.current;
    const positioner = initPositioner();
    prevDepsRef.current = deps;
    prevOptsRef.current = opts;
 
    if (optsChanged) {
      const cacheSize = prevPositioner.size();
      for (let index = 0; index < cacheSize; index++) {
        const pos = prevPositioner.get(index);
        positioner.set(index, pos !== void 0 ? pos.height : 0);
      }
    }
 
    positionerRef.current = positioner;
  }
 
  return positionerRef.current;
}
 
interface DebouncedWindowSizeOptions {
  containerRef: React.RefObject<RootElement | null>;
  defaultWidth?: number;
  defaultHeight?: number;
  delayMs?: number;
}
 
function useDebouncedWindowSize(options: DebouncedWindowSizeOptions) {
  const {
    containerRef,
    defaultWidth = 0,
    defaultHeight = 0,
    delayMs = DEBOUNCE_DELAY,
  } = options;
 
  const getDocumentSize = React.useCallback(() => {
    if (typeof document === "undefined") {
      return { width: defaultWidth, height: defaultHeight };
    }
    return {
      width: document.documentElement.clientWidth,
      height: document.documentElement.clientHeight,
    };
  }, [defaultWidth, defaultHeight]);
 
  const [size, setSize] = React.useState(getDocumentSize());
  const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
 
  const setDebouncedSize = React.useCallback(
    (value: { width: number; height: number }) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
 
      timeoutRef.current = setTimeout(() => {
        setSize(value);
      }, delayMs);
    },
    [delayMs],
  );
 
  React.useEffect(() => {
    function onResize() {
      if (containerRef.current) {
        setDebouncedSize({
          width: containerRef.current.offsetWidth,
          height: document.documentElement.clientHeight,
        });
      } else {
        setDebouncedSize(getDocumentSize());
      }
    }
 
    window?.addEventListener("resize", onResize, { passive: true });
    window?.addEventListener("orientationchange", onResize);
    window.visualViewport?.addEventListener("resize", onResize);
 
    return () => {
      window?.removeEventListener("resize", onResize);
      window?.removeEventListener("orientationchange", onResize);
      window.visualViewport?.removeEventListener("resize", onResize);
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, [setDebouncedSize, containerRef, getDocumentSize]);
 
  return size;
}
 
type OnRafScheduleReturn<T extends unknown[]> = {
  (...args: T): void;
  cancel: () => void;
};
 
function onRafSchedule<T extends unknown[]>(
  callback: (...args: T) => void,
): OnRafScheduleReturn<T> {
  let lastArgs: T = [] as unknown as T;
  let frameId: number | null = null;
 
  function onCallback(...args: T) {
    lastArgs = args;
 
    if (frameId)
      frameId = requestAnimationFrame(() => {
        frameId = null;
        callback(...lastArgs);
      });
  }
 
  onCallback.cancel = () => {
    if (!frameId) return;
    cancelAnimationFrame(frameId);
    frameId = null;
  };
 
  return onCallback;
}
 
function useResizeObserver(positioner: Positioner) {
  const [, setLayoutVersion] = React.useState(0);
 
  const createResizeObserver = React.useMemo(() => {
    if (typeof window === "undefined") {
      return () => ({
        disconnect: () => {},
        observe: () => {},
        unobserve: () => {},
      });
    }
 
    return onDeepMemo(
      [WeakMap],
      (positioner: Positioner, onUpdate: () => void) => {
        const updates: number[] = [];
        const itemMap = new WeakMap<Element, number>();
 
        const update = onRafSchedule(() => {
          if (updates.length > 0) {
            positioner.update(updates);
            onUpdate();
          }
          updates.length = 0;
        });
 
        function onItemResize(target: ItemElement) {
          const height = target.offsetHeight;
          if (height > 0) {
            const index = itemMap.get(target);
            if (index !== void 0) {
              const position = positioner.get(index);
              if (position !== void 0 && height !== position.height) {
                updates.push(index, height);
              }
            }
          }
          update();
        }
 
        const scheduledItemMap = new Map<
          number,
          OnRafScheduleReturn<[ItemElement]>
        >();
        function onResizeObserver(entries: ResizeObserverEntry[]) {
          for (const entry of entries) {
            if (!entry) continue;
            const index = itemMap.get(entry.target);
 
            if (index === void 0) continue;
            let handler = scheduledItemMap.get(index);
            if (!handler) {
              handler = onRafSchedule(onItemResize);
              scheduledItemMap.set(index, handler);
            }
            handler(entry.target as ItemElement);
          }
        }
 
        const observer = new ResizeObserver(onResizeObserver);
        const disconnect = observer.disconnect.bind(observer);
        observer.disconnect = () => {
          disconnect();
          for (const [, scheduleItem] of scheduledItemMap) {
            scheduleItem.cancel();
          }
        };
 
        return observer;
      },
    );
  }, []);
 
  const resizeObserver = createResizeObserver(positioner, () =>
    setLayoutVersion((prev) => prev + 1),
  );
 
  React.useEffect(() => () => resizeObserver.disconnect(), [resizeObserver]);
 
  return resizeObserver;
}
 
function useScroller({
  offset = 0,
  fps = SCROLL_FPS,
}: {
  offset?: number;
  fps?: number;
} = {}): { scrollTop: number; isScrolling: boolean } {
  const [scrollY, setScrollY] = useThrottle(
    typeof globalThis.window === "undefined"
      ? 0
      : (globalThis.window.scrollY ?? document.documentElement.scrollTop ?? 0),
    { fps, leading: true },
  );
 
  const onScroll = React.useCallback(() => {
    setScrollY(
      globalThis.window.scrollY ?? document.documentElement.scrollTop ?? 0,
    );
  }, [setScrollY]);
 
  React.useEffect(() => {
    if (typeof globalThis.window === "undefined") return;
    globalThis.window.addEventListener("scroll", onScroll, { passive: true });
 
    return () => globalThis.window.removeEventListener("scroll", onScroll);
  }, [onScroll]);
 
  const [isScrolling, setIsScrolling] = React.useState(false);
  const hasMountedRef = React.useRef(0);
 
  React.useEffect(() => {
    if (hasMountedRef.current === 1) setIsScrolling(true);
    let didUnsubscribe = false;
 
    function requestTimeout(fn: () => void, delay: number) {
      const start = performance.now();
      const handle = {
        id: requestAnimationFrame(function tick(timestamp) {
          if (timestamp - start >= delay) {
            fn();
          } else {
            handle.id = requestAnimationFrame(tick);
          }
        }),
      };
      return handle;
    }
 
    const timeout = requestTimeout(
      () => {
        if (didUnsubscribe) return;
        setIsScrolling(false);
      },
      40 + 1000 / fps,
    );
    hasMountedRef.current = 1;
    return () => {
      didUnsubscribe = true;
      cancelAnimationFrame(timeout.id);
    };
  }, [fps]);
 
  return { scrollTop: Math.max(0, scrollY - offset), isScrolling };
}
 
function useThrottle<State>(
  initialState: State | (() => State),
  options: {
    fps?: number;
    leading?: boolean;
  } = {},
): [State, React.Dispatch<React.SetStateAction<State>>] {
  const { fps = 30, leading = false } = options;
  const [state, setState] = React.useState(initialState);
  const latestSetState = React.useRef(setState);
  latestSetState.current = setState;
 
  const ms = 1000 / fps;
  const prevCountRef = React.useRef(0);
  const trailingTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(
    null,
  );
 
  const clearTrailing = React.useCallback(() => {
    if (trailingTimeout.current) {
      clearTimeout(trailingTimeout.current);
    }
  }, []);
 
  React.useEffect(() => {
    return () => {
      prevCountRef.current = 0;
      clearTrailing();
    };
  }, [clearTrailing]);
 
  const throttledSetState = React.useCallback(
    (action: React.SetStateAction<State>) => {
      const perf = typeof performance !== "undefined" ? performance : Date;
      const now = () => perf.now();
      const rightNow = now();
      const call = () => {
        prevCountRef.current = rightNow;
        clearTrailing();
        latestSetState.current(action);
      };
      const current = prevCountRef.current;
 
      if (leading && current === 0) {
        return call();
      }
 
      if (rightNow - current > ms) {
        if (current > 0) {
          return call();
        }
        prevCountRef.current = rightNow;
      }
 
      clearTrailing();
      trailingTimeout.current = setTimeout(() => {
        call();
        prevCountRef.current = 0;
      }, ms);
    },
    [leading, ms, clearTrailing],
  );
 
  return [state, throttledSetState];
}
 
const ROOT_NAME = "MasonryRoot";
const VIEWPORT_NAME = "MasonryViewport";
const ITEM_NAME = "MasonryItem";
 
const MASONRY_ERROR = {
  [ROOT_NAME]: `\`${ROOT_NAME}\` components must be within \`${ROOT_NAME}\``,
  [VIEWPORT_NAME]: `\`${VIEWPORT_NAME}\` components must be within \`${ROOT_NAME}\``,
  [ITEM_NAME]: `\`${ITEM_NAME}\` must be within \`${VIEWPORT_NAME}\``,
} as const;
 
interface DivProps extends React.ComponentPropsWithoutRef<"div"> {}
 
type RootElement = React.ComponentRef<typeof MasonryRoot>;
type ItemElement = React.ComponentRef<typeof MasonryItem>;
 
interface MasonryContextValue {
  positioner: Positioner;
  resizeObserver?: ResizeObserver;
  columnWidth: number;
  onItemRegister: (index: number) => (node: ItemElement | null) => void;
  scrollTop: number;
  windowHeight: number;
  itemHeight: number;
  overscan: number;
  isScrolling?: boolean;
  fallback?: React.ReactNode;
}
 
const MasonryContext = React.createContext<MasonryContextValue | null>(null);
 
function useMasonryContext(name: keyof typeof MASONRY_ERROR) {
  const context = React.useContext(MasonryContext);
  if (!context) {
    throw new Error(MASONRY_ERROR[name]);
  }
  return context;
}
 
const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
 
interface MasonryRootProps extends DivProps {
  columnWidth?: number;
  columnCount?: number;
  maxColumnCount?: number;
  gap?: number | { column: number; row: number };
  itemHeight?: number;
  defaultWidth?: number;
  defaultHeight?: number;
  overscan?: number;
  scrollFps?: number;
  fallback?: React.ReactNode;
  linear?: boolean;
  asChild?: boolean;
}
 
const MasonryRoot = React.forwardRef<HTMLDivElement, MasonryRootProps>(
  (props, forwardedRef) => {
    const {
      columnWidth = COLUMN_WIDTH,
      columnCount,
      maxColumnCount,
      gap = GAP,
      itemHeight = ITEM_HEIGHT,
      defaultWidth,
      defaultHeight,
      overscan = OVERSCAN,
      scrollFps = SCROLL_FPS,
      fallback,
      linear = false,
      asChild,
      children,
      style,
      ...rootProps
    } = props;
 
    const gapValue = typeof gap === "object" ? gap : { column: gap, row: gap };
    const columnGap = gapValue.column;
    const rowGap = gapValue.row;
 
    const containerRef = React.useRef<RootElement | null>(null);
    const composedRef = useComposedRefs(forwardedRef, containerRef);
 
    const size = useDebouncedWindowSize({
      containerRef,
      defaultWidth,
      defaultHeight,
      delayMs: DEBOUNCE_DELAY,
    });
 
    const [containerPosition, setContainerPosition] = React.useState<{
      offset: number;
      width: number;
    }>({ offset: 0, width: 0 });
 
    useIsomorphicLayoutEffect(() => {
      if (!containerRef.current) return;
 
      let offset = 0;
      let container = containerRef.current;
 
      do {
        offset += container.offsetTop ?? 0;
        container = container.offsetParent as RootElement;
      } while (container);
 
      if (
        offset !== containerPosition.offset ||
        containerRef.current.offsetWidth !== containerPosition.width
      ) {
        setContainerPosition({
          offset,
          width: containerRef.current.offsetWidth,
        });
      }
    }, [containerPosition, size]);
 
    const positioner = usePositioner({
      width: containerPosition.width ?? size.width,
      columnWidth,
      columnGap,
      rowGap,
      columnCount,
      maxColumnCount,
      linear,
    });
    const resizeObserver = useResizeObserver(positioner);
    const { scrollTop, isScrolling } = useScroller({
      offset: containerPosition.offset,
      fps: scrollFps,
    });
 
    const itemMap = React.useRef(new WeakMap<ItemElement, number>()).current;
 
    const onItemRegister = React.useCallback(
      (index: number) => (node: ItemElement | null) => {
        if (!node) return;
 
        itemMap.set(node, index);
        if (resizeObserver) {
          resizeObserver.observe(node);
        }
        if (positioner.get(index) === void 0) {
          positioner.set(index, node.offsetHeight);
        }
      },
      [itemMap, positioner, resizeObserver],
    );
 
    const contextValue = React.useMemo<MasonryContextValue>(
      () => ({
        positioner,
        resizeObserver,
        columnWidth: positioner.columnWidth,
        onItemRegister,
        scrollTop,
        windowHeight: size.height,
        itemHeight,
        overscan,
        fallback,
        isScrolling,
      }),
      [
        positioner,
        resizeObserver,
        onItemRegister,
        scrollTop,
        size.height,
        itemHeight,
        overscan,
        fallback,
        isScrolling,
      ],
    );
 
    const RootPrimitive = asChild ? Slot : "div";
 
    return (
      <MasonryContext.Provider value={contextValue}>
        <RootPrimitive
          {...rootProps}
          data-slot="masonry"
          ref={composedRef}
          style={{
            position: "relative",
            width: "100%",
            height: "100%",
            ...style,
          }}
        >
          <MasonryViewport>{children}</MasonryViewport>
        </RootPrimitive>
      </MasonryContext.Provider>
    );
  },
);
 
MasonryRoot.displayName = ROOT_NAME;
 
interface MasonryItemPropsWithRef extends MasonryItemProps {
  ref: React.Ref<ItemElement | null>;
}
 
const MasonryViewport = React.forwardRef<HTMLDivElement, DivProps>(
  (props, forwardedRef) => {
    const { children, style, ...viewportProps } = props;
    const context = useMasonryContext(VIEWPORT_NAME);
    const [layoutVersion, setLayoutVersion] = React.useState(0);
    const rafId = React.useRef<number | null>(null);
    const [mounted, setMounted] = React.useState(false);
 
    useIsomorphicLayoutEffect(() => {
      setMounted(true);
    }, []);
 
    let startIndex = 0;
    let stopIndex: number | undefined;
 
    const validChildren = React.Children.toArray(children).filter(
      (child): child is React.ReactElement<MasonryItemPropsWithRef> =>
        React.isValidElement(child) &&
        (child.type === MasonryItem || child.type === Item),
    );
    const itemCount = validChildren.length;
 
    const shortestColumnSize = context.positioner.shortestColumn();
    const measuredCount = context.positioner.size();
    const overscanPixels = context.windowHeight * context.overscan;
    const rangeStart = Math.max(0, context.scrollTop - overscanPixels / 2);
    const rangeEnd = context.scrollTop + overscanPixels;
    const layoutOutdated =
      shortestColumnSize < rangeEnd && measuredCount < itemCount;
 
    const positionedChildren: React.ReactElement[] = [];
 
    const visibleItemStyle = React.useMemo(
      (): React.CSSProperties => ({
        position: "absolute",
        writingMode: "horizontal-tb",
        visibility: "visible",
        width: context.columnWidth,
        transform: context.isScrolling ? "translateZ(0)" : undefined,
        willChange: context.isScrolling ? "transform" : undefined,
      }),
      [context.columnWidth, context.isScrolling],
    );
 
    const hiddenItemStyle = React.useMemo(
      (): React.CSSProperties => ({
        position: "absolute",
        writingMode: "horizontal-tb",
        visibility: "hidden",
        width: context.columnWidth,
        zIndex: -1000,
      }),
      [context.columnWidth],
    );
 
    context.positioner.range(rangeStart, rangeEnd, (index, left, top) => {
      const child = validChildren[index];
      if (!child) return;
 
      const itemStyle = {
        ...visibleItemStyle,
        top,
        left,
        ...child.props.style,
      };
 
      positionedChildren.push(
        React.cloneElement(child, {
          key: child.key ?? index,
          ref: context.onItemRegister(index),
          style: itemStyle,
        }),
      );
 
      if (stopIndex === undefined) {
        startIndex = index;
        stopIndex = index;
      } else {
        startIndex = Math.min(startIndex, index);
        stopIndex = Math.max(stopIndex, index);
      }
    });
 
    if (layoutOutdated && mounted) {
      const batchSize = Math.min(
        itemCount - measuredCount,
        Math.ceil(
          ((context.scrollTop + overscanPixels - shortestColumnSize) /
            context.itemHeight) *
            context.positioner.columnCount,
        ),
      );
 
      for (
        let index = measuredCount;
        index < measuredCount + batchSize;
        index++
      ) {
        const child = validChildren[index];
        if (!child) continue;
 
        const itemStyle = {
          ...hiddenItemStyle,
          ...child.props.style,
        };
 
        positionedChildren.push(
          React.cloneElement(child, {
            key: child.key ?? index,
            ref: context.onItemRegister(index),
            style: itemStyle,
          }),
        );
      }
    }
 
    React.useEffect(() => {
      if (layoutOutdated && mounted) {
        if (rafId.current) {
          cancelAnimationFrame(rafId.current);
        }
        rafId.current = requestAnimationFrame(() => {
          setLayoutVersion((v) => v + 1);
        });
      }
      return () => {
        if (rafId.current) {
          cancelAnimationFrame(rafId.current);
        }
      };
    }, [layoutOutdated, mounted]);
 
    const estimatedHeight = React.useMemo(() => {
      const measuredHeight = context.positioner.estimateHeight(
        measuredCount,
        context.itemHeight,
      );
      if (measuredCount === itemCount) {
        return measuredHeight;
      }
      const remainingItems = itemCount - measuredCount;
      const estimatedRemainingHeight = Math.ceil(
        (remainingItems / context.positioner.columnCount) * context.itemHeight,
      );
      return measuredHeight + estimatedRemainingHeight;
    }, [context.positioner, context.itemHeight, measuredCount, itemCount]);
 
    const containerStyle = React.useMemo(
      () => ({
        position: "relative" as const,
        width: "100%",
        maxWidth: "100%",
        height: Math.ceil(estimatedHeight),
        maxHeight: Math.ceil(estimatedHeight),
        willChange: context.isScrolling ? "contents" : undefined,
        pointerEvents: context.isScrolling ? ("none" as const) : undefined,
        ...style,
      }),
      [context.isScrolling, estimatedHeight, style],
    );
 
    if (!mounted && context.fallback) {
      return context.fallback;
    }
 
    return (
      <div
        {...viewportProps}
        ref={forwardedRef}
        style={containerStyle}
        data-version={mounted ? layoutVersion : undefined}
      >
        {positionedChildren}
      </div>
    );
  },
);
 
MasonryViewport.displayName = VIEWPORT_NAME;
 
interface MasonryItemProps extends DivProps {
  asChild?: boolean;
}
 
const MasonryItem = React.forwardRef<HTMLDivElement, MasonryItemProps>(
  (props, forwardedRef) => {
    const { asChild, ...itemProps } = props;
 
    const ItemPrimitive = asChild ? Slot : "div";
 
    return (
      <ItemPrimitive
        data-slot="masonry-item"
        {...itemProps}
        ref={forwardedRef}
      />
    );
  },
);
 
MasonryItem.displayName = ITEM_NAME;
 
const Root = MasonryRoot;
const Item = MasonryItem;
 
export {
  MasonryRoot,
  MasonryItem,
  //
  Root,
  Item,
};

Layout

Import the parts, and compose them together.

import * as Masonry from "@/components/ui/masonry";

<Masonry.Root>
  <Masonry.Item />
</Masonry.Root>

Examples

Linear Layout

Set linear to true to maintain item order from left to right.

import { Skeleton } from "@/components/ui/skeleton";
import * as Masonry from "@/components/ui/masonry";
import * as React from "react";
 
const items = [
  {
    id: "1",
    number: 1,
    aspectRatio: "1/1",
  },
  {
    id: "2",
    number: 2,
    aspectRatio: "4/3",
  },
  {
    id: "3",
    number: 3,
    aspectRatio: "3/4",
  },
  {
    id: "4",
    number: 4,
    aspectRatio: "3/2",
  },
  {
    id: "5",
    number: 5,
    aspectRatio: "1/1",
  },
  {
    id: "6",
    number: 6,
    aspectRatio: "1/1",
  },
];
 
export function MasonryLinearDemo() {
  return (
    <Masonry.Root
      gap={10}
      columnWidth={140}
      linear
      fallback={<Skeleton className="h-72 w-full" />}
    >
      {items.map((item) => (
        <Masonry.Item
          key={item.id}
          className="flex items-center justify-center rounded-lg border bg-card text-card-foreground shadow-xs"
          style={{ aspectRatio: item.aspectRatio }}
        >
          <span className="font-medium text-2xl">{item.number}</span>
        </Masonry.Item>
      ))}
    </Masonry.Root>
  );
}

Server Side Rendering

Use defaultWidth and defaultHeight, and item fallback to render items on the server. This is useful for preventing layout shift and hydration errors.

import { Skeleton } from "@/components/ui/skeleton";
import * as Masonry from "@/components/ui/masonry";
 
interface SkateboardTrick {
  id: string;
  title: string;
  description: string;
}
 
function getTricks(): SkateboardTrick[] {
  return [
    {
      id: "1",
      title: "The 900",
      description: "The 900 is a trick where you spin 900 degrees in the air.",
    },
    {
      id: "2",
      title: "Indy Backflip",
      description:
        "The Indy Backflip is a trick where you backflip in the air while grabbing the board with your back hand.",
    },
    {
      id: "3",
      title: "Pizza Guy",
      description:
        "The Pizza Guy is a trick where you flip the board like a pizza.",
    },
    {
      id: "4",
      title: "Rocket Air",
      description:
        "The Rocket Air is a trick where you grab the nose of your board and point it straight up to the sky.",
    },
    {
      id: "5",
      title: "Kickflip",
      description:
        "A kickflip is performed by flipping your skateboard lengthwise using your front foot.",
    },
    {
      id: "6",
      title: "FS 540",
      description:
        "The FS 540 is a trick where you spin frontside 540 degrees in the air.",
    },
  ];
}
 
function TrickCard({ trick }: { trick: SkateboardTrick }) {
  return (
    <div className="flex flex-col gap-2 rounded-md border bg-card p-4 text-card-foreground shadow-xs">
      <div className="font-medium text-sm leading-tight sm:text-base">
        {trick.title}
      </div>
      <span className="text-muted-foreground text-sm">{trick.description}</span>
    </div>
  );
}
 
function SkeletonCard() {
  return (
    <div className="flex flex-col gap-2 rounded-md border bg-card p-4">
      <Skeleton className="h-5 w-24" />
      <Skeleton className="h-4 w-full" />
      <Skeleton className="h-4 w-3/4" />
    </div>
  );
}
 
export function MasonrySSRDemo() {
  const tricks = getTricks();
  const skeletonIds = Array.from(
    { length: 6 },
    () => `skeleton-${Math.random().toString(36).substring(2, 9)}`,
  );
 
  return (
    <Masonry.Root
      columnCount={3}
      gap={{ column: 8, row: 8 }}
      className="w-full"
      fallback={
        <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
          {skeletonIds.map((id) => (
            <SkeletonCard key={id} />
          ))}
        </div>
      }
    >
      {tricks.map((trick) => (
        <Masonry.Item
          key={trick.id}
          className="relative overflow-hidden transition-all duration-300 hover:scale-[1.02]"
        >
          <TrickCard trick={trick} />
        </Masonry.Item>
      ))}
    </Masonry.Root>
  );
}

API Reference

Root

The main container component for the masonry layout.

PropTypeDefault
asChild?
boolean
false
linear?
boolean
false
fallback?
ReactNode
-
scrollFps?
number
12
overscan?
number
2
defaultHeight?
number
-
defaultWidth?
number
-
itemHeight?
number
300
gap?
number | { column: number; row: number; }
0
maxColumnCount?
number
-
columnCount?
number
-
columnWidth?
number
200

Item

Individual item component within the masonry layout.

PropTypeDefault
asChild?
boolean
false