Grid Bento Dense

Grid

A composable bento grid where activating a tile genuinely promotes it: the tile takes a 2x2 slot and every other tile re-packs around it via CSS grid-auto-flow: dense, FLIP-animated so the reflow reads as tiles sliding into new slots rather than jumping.

Install
npx shadcn add https://design.helpmarq.com/r/grid-bento-dense.json
Source
registry/core/grid-bento-dense/component.tsx
"use client";

import { useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";

export type BentoSize = "1x1" | "2x1" | "1x2";

export interface BentoCell {
  /** Stable id — also the roving-tabindex key and the FLIP measurement key. */
  id: string;
  title: string;
  meta?: string;
  body?: ReactNode;
  /** Resting footprint. Ignored while this cell is featured (always 2x2). */
  size?: BentoSize;
}

export interface BentoGridProps {
  cells: BentoCell[];
  cols?: number;
  defaultFeaturedId?: string;
  className?: string;
}

const SPAN: Record<BentoSize, { c: number; r: number }> = {
  "1x1": { c: 1, r: 1 },
  "2x1": { c: 2, r: 1 },
  "1x2": { c: 1, r: 2 },
};

const ROW_PX = 108;

function spanFor(cell: BentoCell, isFeatured: boolean, cols: number) {
  if (isFeatured) return { c: Math.min(2, cols), r: 2 };
  return SPAN[cell.size ?? "1x1"];
}

// Worst-case row count across every possible featured cell — computed once
// from the cell list, not the live state — so the grid's own box height
// never jumps as different cells get promoted. Dense packing may leave a
// little trailing slack in the non-worst states; it never overflows.
function computeRows(cells: BentoCell[], cols: number): number {
  let worst = 0;
  for (const candidate of cells) {
    let units = 0;
    for (const cell of cells) {
      const { c, r } = spanFor(cell, cell.id === candidate.id, cols);
      units += c * r;
    }
    worst = Math.max(worst, units);
  }
  return Math.max(1, Math.ceil(worst / cols));
}

/**
 * A bento grid where cells reflow for real: activating one promotes it to a
 * 2x2 slot and every other cell re-packs around it via CSS `grid-auto-flow:
 * dense` — the browser's own layout engine, not a fixed grid-template-areas
 * arrangement. The reflow is FLIP-animated (translate only, size changes
 * instantly) so it reads as tiles sliding into new slots rather than
 * teleporting or smearing under a scale transform. Arrow keys move focus
 * spatially between tiles by their actual on-screen geometry, not DOM order.
 */
export function BentoGrid({ cells, cols = 4, defaultFeaturedId, className = "" }: BentoGridProps) {
  const initial = defaultFeaturedId ?? cells[0]?.id ?? "";
  const [featuredId, setFeaturedId] = useState(initial);
  const [activeId, setActiveId] = useState(initial);
  const [announce, setAnnounce] = useState("");
  const refs = useRef(new Map<string, HTMLDivElement>());
  const pendingFirst = useRef<Map<string, DOMRect> | null>(null);

  const rows = useMemo(() => computeRows(cells, cols), [cells, cols]);

  const activate = (id: string) => {
    setActiveId(id);
    if (id === featuredId) return;
    const reduce =
      typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (!reduce) {
      const first = new Map<string, DOMRect>();
      refs.current.forEach((el, key) => first.set(key, el.getBoundingClientRect()));
      pendingFirst.current = first;
    }
    setFeaturedId(id);
    const cell = cells.find((c) => c.id === id);
    if (cell) setAnnounce(`${cell.title} featured`);
  };

  // FLIP: positions were captured synchronously before the state write above;
  // once the new layout has painted, invert each moved cell back to its old
  // position with transitions off, then release it into a transform
  // transition back to zero. Size changes (1x1 -> 2x2) happen instantly —
  // scaling a bordered, rounded card is what smears its border and text.
  useLayoutEffect(() => {
    const first = pendingFirst.current;
    pendingFirst.current = null;
    if (!first) return;
    refs.current.forEach((el, key) => {
      const from = first.get(key);
      if (!from) return;
      const to = el.getBoundingClientRect();
      const dx = from.left - to.left;
      const dy = from.top - to.top;
      if (!dx && !dy) return;
      el.style.transition = "none";
      el.style.transform = `translate(${dx}px, ${dy}px)`;
      void el.getBoundingClientRect(); // force reflow before releasing
      requestAnimationFrame(() => {
        el.style.transition = "transform 380ms cubic-bezier(0.22, 1, 0.36, 1)";
        el.style.transform = "";
      });
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [featuredId]);

  // Spatial nav: candidates are filtered to the pressed half-plane by their
  // actual rendered centre, then scored by distance along the pressed axis
  // plus double the perpendicular drift — reading order plays no part.
  const move = (dir: "up" | "down" | "left" | "right") => {
    const cur = refs.current.get(activeId)?.getBoundingClientRect();
    if (!cur) return;
    const cx = cur.left + cur.width / 2;
    const cy = cur.top + cur.height / 2;
    let bestId: string | null = null;
    let bestScore = Infinity;
    refs.current.forEach((el, id) => {
      if (id === activeId) return;
      const r = el.getBoundingClientRect();
      const x = r.left + r.width / 2 - cx;
      const y = r.top + r.height / 2 - cy;
      let primary: number;
      let secondary: number;
      if (dir === "right") {
        if (x <= 1) return;
        primary = x;
        secondary = Math.abs(y);
      } else if (dir === "left") {
        if (x >= -1) return;
        primary = -x;
        secondary = Math.abs(y);
      } else if (dir === "down") {
        if (y <= 1) return;
        primary = y;
        secondary = Math.abs(x);
      } else {
        if (y >= -1) return;
        primary = -y;
        secondary = Math.abs(x);
      }
      const score = primary + secondary * 2;
      if (score < bestScore) {
        bestScore = score;
        bestId = id;
      }
    });
    if (bestId) {
      setActiveId(bestId);
      refs.current.get(bestId)?.focus();
    }
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>, id: string) => {
    switch (e.key) {
      case "ArrowRight":
        e.preventDefault();
        move("right");
        break;
      case "ArrowLeft":
        e.preventDefault();
        move("left");
        break;
      case "ArrowDown":
        e.preventDefault();
        move("down");
        break;
      case "ArrowUp":
        e.preventDefault();
        move("up");
        break;
      case "Enter":
      case " ":
        e.preventDefault();
        activate(id);
        break;
      default:
        break;
    }
  };

  return (
    <div className="w-full">
      <div
        role="group"
        aria-label="Bento grid. Arrow keys move between tiles, Enter or Space features one."
        className={`grid gap-3 ${className}`}
        style={{
          gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
          gridTemplateRows: `repeat(${rows}, ${ROW_PX}px)`,
          gridAutoFlow: "dense",
        }}
      >
        {cells.map((cell) => {
          const isFeatured = cell.id === featuredId;
          const { c, r } = spanFor(cell, isFeatured, cols);
          return (
            <div
              key={cell.id}
              ref={(el) => {
                if (el) refs.current.set(cell.id, el);
                else refs.current.delete(cell.id);
              }}
              role="button"
              tabIndex={cell.id === activeId ? 0 : -1}
              aria-pressed={isFeatured}
              aria-label={cell.title}
              data-cell-id={cell.id}
              data-featured={String(isFeatured)}
              onClick={() => activate(cell.id)}
              onKeyDown={(e) => onKeyDown(e, cell.id)}
              onFocus={() => setActiveId(cell.id)}
              className="group relative flex cursor-pointer flex-col justify-between overflow-hidden rounded-md border border-border bg-surface p-4 text-left transition-colors duration-150 hover:border-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
              style={{ gridColumn: `span ${c}`, gridRow: `span ${r}` }}
            >
              {isFeatured && (
                <span aria-hidden className="absolute right-3 top-3 h-1.5 w-1.5 rounded-full bg-accent" />
              )}
              <div>
                {cell.meta && (
                  <p className="mb-1 font-mono text-[10px] uppercase tracking-widest text-muted">{cell.meta}</p>
                )}
                <h3 className={`font-medium text-foreground ${isFeatured ? "text-base" : "text-sm"}`}>
                  {cell.title}
                </h3>
              </div>
              {cell.body && <div className="mt-2 flex-1 text-sm text-muted">{cell.body}</div>}
              <span
                aria-hidden
                className="pointer-events-none mt-2 block text-right font-mono text-[10px] uppercase tracking-widest text-muted opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
              >
                {isFeatured ? "featured" : "feature this"}
              </span>
            </div>
          );
        })}
      </div>
      <span role="status" aria-live="polite" className="sr-only">
        {announce}
      </span>
    </div>
  );
}
Use when

A dashboard or overview grid where one tile should be able to earn the large slot — the caller passes arbitrary cells (any count, any content) and lets whoever's looking at it decide which one matters right now, rather than a designer fixing that in a template. Reach for grid-magnetic-lattice instead when the point is ambient cursor-reactive motion over a fixed card layout, not an actual change of which tile is largest.

Build spec

A bento grid built as a real layout primitive rather than a fixed grid-template-areas arrangement: it takes a `cells` array of arbitrary length and content (`{ id, title, meta?, body?, size? }`, size one of 1x1/2x1/1x2 as its resting footprint) and a `cols` count, and lays every cell out with native CSS `grid-auto-flow: dense` — the browser's own packing algorithm, not JS-computed positions. Clicking, or Enter/Space on, any tile ACTIVATES it: that tile's span becomes 2x2 regardless of its resting size, the previously-featured tile drops back to its own resting size, and dense re-runs the pack — every other tile can shift to fill the gap, which is what makes this a primitive the rest of a dashboard composes into rather than a decoration. The reflow is FLIP-animated: before the state write, every tile's current getBoundingClientRect is captured; after the new layout has painted, each tile that moved is inverted back to its old screen position with transitions off, forced to reflow, then released into a 380ms transform transition back to zero — so the motion is a real position change riding a transform, not a fake. Size changes happen INSTANTLY, never scaled: animating a bordered, rounded tile's width/height via scale is what smears its border and distorts its text, so only translation is animated and the grid-column/row spans swap in the same frame as the state update. The container's own height never changes across activations — `computeRows` evaluates every cell as the hypothetical featured one, takes the worst-case total area, and fixes `grid-template-rows` to that count once, so a dashboard embedding this grid never has its neighbors jump when someone taps a different tile. ARROW KEYS move focus by actual on-screen geometry, not DOM order: from the focused tile's centre, candidates in the pressed half-plane are scored by distance along that axis plus double the perpendicular drift, and the closest wins — a tile below and slightly right of the current one is `ArrowDown`, not `ArrowRight`, which flat DOM-order roving tabindex gets wrong the moment tiles vary in size. Every tile is `role=button` with roving tabindex (exactly one at 0), `aria-pressed` reflecting featured state, and an `aria-label` from its title; a visually-hidden `role=status` region announces which tile just got featured. Hover and keyboard focus both lift the border from --border to --muted and reveal a small 'feature this' hint (opacity 0 to 1), and the featured tile alone carries a small --accent dot — the only place accent appears. `prefers-reduced-motion` skips the FLIP capture entirely: the new layout still applies, cells still move, there's just no transform animating the transition. All color from --background/--foreground/--muted/--border/--accent tokens via Tailwind utility classes, zero canvas, zero dependencies.

Tags
gridlayoutbentodashboardkeyboard-navigationreflowaccessibility