Layout

Masonry Grid

CSS-columns masonry with break-safe items, a staggered first-view reveal, and filter chips that re-flow items via layout animation with 150ms exits.

Install

npx shadcn@latest add @paragon/masonry-grid

masonry-grid.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const columnClasses = {
  2: "columns-1 sm:columns-2",
  3: "columns-1 sm:columns-2 lg:columns-3",
  4: "columns-1 sm:columns-2 lg:columns-4",
} as const;

export interface MasonryGridProps extends React.ComponentProps<"div"> {
  /** Column count at the largest breakpoint. */
  columns?: 2 | 3 | 4;
}

/**
 * CSS-columns masonry. Items guard against column breaks with
 * `break-inside-avoid`; the grid wraps children in AnimatePresence
 * (popLayout) so filtered-out items exit in 150ms while survivors glide
 * to their new slots via layout animation. Give each MasonryItem a stable
 * `key` and, for the first-view stagger, its `index`.
 */
export function MasonryGrid({
  columns = 3,
  className,
  children,
  ...props
}: MasonryGridProps) {
  return (
    <div
      data-slot="masonry-grid"
      className={cn("gap-4", columnClasses[columns], className)}
      {...props}
    >
      <AnimatePresence mode="popLayout">{children}</AnimatePresence>
    </div>
  );
}

export interface MasonryItemProps
  extends React.ComponentProps<typeof motion.div> {
  /** Position in the initial list — drives the 50ms first-view stagger. */
  index?: number;
}

/**
 * One masonry tile: a card surface that avoids column breaks, enters with
 * the house blur-rise (staggered by `index`), and exits in 150ms.
 */
export function MasonryItem({
  index = 0,
  className,
  ...props
}: MasonryItemProps) {
  const reducedMotion = useReducedMotion();

  return (
    <motion.div
      data-slot="masonry-item"
      layout={!reducedMotion}
      initial={
        reducedMotion
          ? { opacity: 0 }
          : { opacity: 0, y: 12, filter: "blur(4px)" }
      }
      animate={
        reducedMotion
          ? { opacity: 1 }
          : { opacity: 1, y: 0, filter: "blur(0px)" }
      }
      exit={
        reducedMotion
          ? { opacity: 0, transition: { duration: 0.15 } }
          : {
              opacity: 0,
              scale: 0.98,
              filter: "blur(4px)",
              transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
            }
      }
      transition={{
        type: "spring",
        duration: 0.4,
        bounce: 0,
        delay: index * 0.05,
        layout: { type: "spring", duration: 0.35, bounce: 0, delay: 0 },
      }}
      className={cn(
        "mb-4 break-inside-avoid rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    />
  );
}

export interface MasonryFilterChipsProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  options: string[];
  value: string;
  onChange: (value: string) => void;
}

/** The optional filter chip row that drives masonry re-filtering. */
export function MasonryFilterChips({
  options,
  value,
  onChange,
  className,
  ...props
}: MasonryFilterChipsProps) {
  return (
    <div
      data-slot="masonry-filter-chips"
      role="group"
      aria-label="Filter items"
      className={cn("flex flex-wrap gap-1.5", className)}
      {...props}
    >
      {options.map((option) => {
        const selected = option === value;
        return (
          <button
            key={option}
            type="button"
            aria-pressed={selected}
            onClick={() => onChange(option)}
            className={cn(
              "pressable rounded-full px-3 py-1 text-xs font-medium transition-colors duration-150 ease-out",
              selected
                ? "bg-primary text-primary-foreground"
                : "bg-secondary text-secondary-foreground hover:bg-secondary/70",
            )}
          >
            {option}
          </button>
        );
      })}
    </div>
  );
}