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.
npx shadcn@latest add @paragon/masonry-grid"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>
);
}