Galleries · Intermediate
Animated Bento Grid
whileInView handles the entrance; layoutId morphs a tile into a fullscreen detail and back.
Live preview
Select a tile to expand it; Escape or Close returns.
Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
* Bento Grid
* An editorial image grid expands into a native, focus-managed dialog.
* Requires: motion, Tailwind with shadcn semantic tokens.
*/
import { useEffect, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
const items = [
{ title: "Identity", seed: "identity", span: "sm:col-span-2 sm:row-span-2" },
{ title: "Typography", seed: "type", span: "" },
{ title: "Moving image", seed: "film", span: "" },
{ title: "Spatial", seed: "spatial", span: "sm:col-span-2" },
];
export default function BentoGrid({
reduced: reducedProp,
intensity = 1,
}: {
reduced?: boolean;
intensity?: number;
}) {
const systemReduced = useReducedMotion();
const reduced = reducedProp ?? systemReduced ?? false;
const amount = Math.min(1.5, Math.max(0.5, intensity));
const [selected, setSelected] = useState<string | null>(null);
const dialog = useRef<HTMLDialogElement>(null);
const trigger = useRef<HTMLButtonElement | null>(null);
const headingId = useId();
const item = items.find((entry) => entry.title === selected);
useEffect(() => {
const node = dialog.current;
if (selected && node && !node.open) node.showModal?.();
if (!selected && node?.open) node.close();
}, [selected]);
return (
<section className="flex min-h-[80vh] items-center justify-center bg-background px-6 py-20 text-foreground">
<div className="w-full max-w-6xl">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Selected perspectives / 09
</p>
<h2 className="mb-10 mt-5 font-serif text-5xl tracking-tight">A connected practice.</h2>
<div className="grid auto-rows-[210px] gap-4 sm:grid-cols-4">
{items.map((entry, index) => (
<motion.button
key={entry.title}
type="button"
initial={reduced ? false : { opacity: 0, y: 24 * amount }}
animate={{ opacity: 1, y: 0 }}
whileHover={reduced ? {} : { scale: 1.01 }}
transition={{ duration: reduced ? 0 : 0.7, delay: reduced ? 0 : index * 0.08 }}
onClick={(event) => {
trigger.current = event.currentTarget;
setSelected(entry.title);
}}
aria-haspopup="dialog"
className={`group relative overflow-hidden rounded-2xl border border-border bg-card text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 ${entry.span}`}
>
<img
src={`https://picsum.photos/seed/ardent-${entry.seed}/800/600`}
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
<span className="absolute inset-x-0 bottom-0 bg-background p-5">
<span className="font-serif text-3xl tracking-tight">{entry.title}</span>
</span>
</motion.button>
))}
</div>
</div>
<dialog
ref={dialog}
aria-labelledby={headingId}
onCancel={() => setSelected(null)}
onClose={() => {
setSelected(null);
trigger.current?.focus();
}}
onClick={(event) => {
if (event.target === event.currentTarget) setSelected(null);
}}
className="max-h-[90vh] w-[calc(100%-3rem)] max-w-4xl overflow-auto rounded-2xl border border-border bg-card p-0 text-foreground backdrop:bg-background/90"
>
{item && (
<motion.div
key={item.title}
initial={reduced ? false : { opacity: 0, scale: 1 - 0.035 * amount }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: reduced ? 0 : 0.4 }}
>
<div className="flex items-center justify-between gap-5 p-6">
<h3 id={headingId} className="font-serif text-4xl tracking-tight">
{item.title}
</h3>
<button
type="button"
autoFocus
onClick={() => setSelected(null)}
className="rounded-full border border-border px-5 py-3 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4"
>
Close
</button>
</div>
<img
src={`https://picsum.photos/seed/ardent-${item.seed}/1600/1000`}
alt={`${item.title} photographic study from the selected perspectives collection.`}
className="aspect-[16/10] w-full object-cover"
/>
<p className="p-6 text-sm leading-relaxed text-muted-foreground">
One discipline, seen through a different lens. A study in proportion, atmosphere, and
intention.
</p>
</motion.div>
)}
</dialog>
</section>
);
}
Next patternCurtain Page Transition→