CSS-columns masonry with break-safe items, a staggered first-view reveal, sibling-dimming hover isolation, 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;
/** Dim sibling tiles while one is hovered (fine pointers only). */
hoverIsolate?: boolean;
}
/**
* 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`. Hover isolation
* dims siblings through a per-tile scrim (motion owns each tile's inline
* opacity, so the dim must not touch it) — a CSS transition, retargeting
* cleanly on fast pointer moves.
*/
export function MasonryGrid({
columns = 3,
hoverIsolate = true,
className,
children,
...props
}: MasonryGridProps) {
return (
<div
data-slot="masonry-grid"
data-hover-isolate={hoverIsolate || undefined}
className={cn("gap-4", columnClasses[columns], className)}
{...props}
>
<AnimatePresence mode="popLayout">{children}</AnimatePresence>
</div>
);
}
export interface MasonryItemProps
extends Omit<React.ComponentProps<typeof motion.div>, "children"> {
/** Position in the initial list — drives the 50ms first-view stagger. */
index?: number;
children?: React.ReactNode;
}
/**
* One masonry tile: a card surface that avoids column breaks, enters with
* the house blur-rise (staggered by `index`, capped at 500ms), and exits
* in 150ms.
*/
export function MasonryItem({
index = 0,
className,
children,
...props
}: MasonryItemProps) {
const reducedMotion = useReducedMotion();
return (
<motion.div
data-slot="masonry-item"
data-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: Math.min(index * 0.05, 0.5),
layout: { type: "spring", duration: 0.35, bounce: 0, delay: 0 },
}}
className={cn(
"relative mb-4 break-inside-avoid rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
{children}
{/* Sibling-hover scrim — dims without touching motion's inline opacity. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] bg-background opacity-0 transition-opacity duration-(--duration-quick) ease-out",
"[@media(hover:hover)_and_(pointer:fine)]:[[data-hover-isolate]:has([data-masonry-item]:hover)_[data-masonry-item]:not(:hover)_&]:opacity-30",
)}
/>
</motion.div>
);
}
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>
);
}