Skip to content
ARDENT / MOTION LAB
Library

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→