bottega

sections

Bento Grid

A responsive bento grid of feature tiles. Each tile lifts on hover via a depth-inversion shadow (idle inset shadow flips to a lifted outer shadow — a physical press-to-lift feel with no scale transforms that would disturb neighbour spacing) and an optional conic-gradient border shimmer whose hue orbits the edge through an @property-registered angle. Spans (1x1, 2x1, 1x2, 2x2) are author-controlled through a single `span` prop, never hard-coded class combos. Tiles stagger-fade into view on scroll via an IntersectionObserver, with the per-tile delay expressed purely as a CSS function of the tile index. Polymorphic (div/article/li/a/button), list-semantics aware, and keyboard-operable with a focus-visible ring the glow never suppresses.

Realtime sync

Edits propagate to every client in under 50ms.

Edge cached

SOC2

Version history

Webhooks


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/bento-grid

Usage

Usagetsx
<BentoGrid>
  {/* children */}
</BentoGrid>

Props

PropTypeDefaultDescription
children*React.ReactNodeTiles — typically <BentoGridItem> children.
colsenum3Number of base columns (default 3).
gapstringgap-4Grid gap as a Tailwind gap-* class or any class string (default "gap-4").
asReact.ElementTypePolymorphic root: "div" | "ul" | "ol" | "section" (default "div").
aria-labelstringScreen-reader label for the section.
classNamestringEscape hatch for one-off overrides.

Source

bento-grid.tsxtsx
"use client";

import {
  Children,
  createContext,
  createElement,
  isValidElement,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";
import type React from "react";
import { useReducedMotion } from "motion/react";
import styles from "./bento-grid.module.css";

/* ------------------------------------------------------------------ *
 * BentoGrid — responsive bento layout of feature tiles.
 *
 * Motion is CSS-only: pointer hover drives the depth-inversion shadow and a
 * masked conic-gradient rim shimmer (hue orbits via an @property-registered
 * angle), and an IntersectionObserver toggles a CSS stagger-entrance class
 * whose per-tile delay is a declarative function of its index (no JS timers).
 *
 * Spans are author-controlled through a single `span` prop, mapped to
 * grid-column/grid-row span utilities — never hard-coded class combos.
 * ------------------------------------------------------------------ */

type Cols = 2 | 3 | 4;
type Span = "1x1" | "2x1" | "1x2" | "2x2";

/* Context lets the grid hand each item its in-view sequence index so the
   stagger delay can be a pure CSS function of --tile-index. */
const BentoIndexContext = createContext<number | null>(null);

export type BentoGridProps = {
  /** Tiles — typically <BentoGridItem> children. */
  children: React.ReactNode;
  /** Number of base columns (default 3). */
  cols?: Cols;
  /** Grid gap as a Tailwind gap-* class or any class string (default "gap-4"). */
  gap?: string;
  /** Polymorphic root: "div" | "ul" | "ol" | "section" (default "div"). */
  as?: React.ElementType;
  /** Screen-reader label for the section. */
  "aria-label"?: string;
  /** Escape hatch for one-off overrides. */
  className?: string;
} & Omit<React.HTMLAttributes<HTMLElement>, "color">;

const colSpanClass: Record<Span, string> = {
  "1x1": "col-span-1 row-span-1",
  "2x1": "sm:col-span-2 row-span-1",
  "1x2": "col-span-1 sm:row-span-2",
  "2x2": "sm:col-span-2 sm:row-span-2",
};

export function BentoGrid({
  children,
  cols = 3,
  gap = "gap-4",
  as,
  className = "",
  style: consumerStyle,
  ...props
}: BentoGridProps) {
  const Root = (as ?? "div") as React.ElementType;
  const isList = Root === "ul" || Root === "ol";

  // Hand each direct child its index via context (used for the CSS stagger
  // delay). createElement keeps the polymorphic root from widening `as` over
  // every intrinsic element.
  let i = 0;
  const indexed = mapChildrenWithIndex(children, () => i++);

  const rootStyle: React.CSSProperties & Record<string, string> = {
    ...consumerStyle,
    "--bento-cols": String(cols),
  } as React.CSSProperties & Record<string, string>;

  return createElement(
    Root,
    {
      ...props,
      role: isList ? "list" : props.role,
      className: [styles.grid, gap, className].filter(Boolean).join(" "),
      style: rootStyle,
    },
    indexed,
  );
}

/* Wrap each direct child in an index-providing context without altering the
   child element itself (so span/className/etc. stay on the child). */
function mapChildrenWithIndex(
  children: React.ReactNode,
  next: () => number,
): React.ReactNode {
  return mapChildren(children, (child, key) => {
    if (!isValidEl(child)) return child;
    const idx = next();
    return createElement(
      BentoIndexContext.Provider,
      { value: idx, key },
      child,
    );
  });
}

export type BentoGridItemProps = {
  /** Tile content — icon, headline, body, media. Fully open slot. */
  children: React.ReactNode;
  /** Column × row span in the grid (default "1x1"). */
  span?: Span;
  /** Accent background tint to distinguish hero/featured tiles. */
  highlight?: boolean;
  /** Conic-gradient border shimmer on hover (default true). */
  glow?: boolean;
  /** Polymorphic root: "div" | "article" | "li" | "a" | "button" (default "div"). */
  as?: React.ElementType;
  /** Escape hatch for one-off overrides. */
  className?: string;
} & Omit<React.HTMLAttributes<HTMLElement>, "color">;

export function BentoGridItem({
  children,
  span = "1x1",
  highlight = false,
  glow = true,
  as,
  className = "",
  style: consumerStyle,
  onClick,
  ...props
}: BentoGridItemProps) {
  const indexFromGrid = useContext(BentoIndexContext);
  const ref = useRef<HTMLElement>(null);

  // Mount-gate + null-safe reduced-motion check (contract snippet).
  // useReducedMotion() is null on SSR but a concrete boolean on the client's
  // first render, so we render the static resting state on the server / first
  // client paint and only opt into the stagger entrance after mount for
  // non-reduced users — byte-identical SSR/first render (no React #418).
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced

  // In-view gate: add the entrance class only once the tile scrolls into view.
  const [inView, setInView] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (typeof IntersectionObserver === "undefined") {
      setInView(true);
      return;
    }
    const obs = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            setInView(true);
            obs.disconnect();
            break;
          }
        }
      },
      { threshold: 0.15 },
    );
    obs.observe(el);
    return () => obs.disconnect();
  }, []);

  // Only attach the animated entrance layer when mounted, in view, and the
  // user hasn't asked for reduced motion (reduced users get the static base).
  const animateEntrance = mounted && inView && !reduce;

  // Gate the hover shimmer through the same mount + null-safe reduce check.
  // A pure CSS :hover animation can't be suppressed by useReducedMotion()'s
  // null SSR value, so emit data-glow="true" ONLY after mount for non-reduced
  // users — pre-mount/SSR and reduced-motion users render data-glow="false"
  // and never trigger the conic-gradient orbit or its opacity fade.
  const glowEnabled = glow && mounted && !reduce;

  const Root = (as ?? "div") as React.ElementType;
  const isListItem = Root === "li";

  // A11y: an interactive item (onClick present) on a non-interactive default
  // <div> would be a keyboard/SR trap. Prefer as="button"/as="a" (warn in dev),
  // but ALSO make the rendered div genuinely operable — role="button", focusable,
  // and Enter/Space activation — so the shipped output is accessible regardless.
  const isNativeInteractive =
    Root === "button" || Root === "a" || Root === "input";
  const needsButtonSemantics = Boolean(onClick) && !isNativeInteractive;

  if (
    process.env.NODE_ENV !== "production" &&
    onClick &&
    (as === undefined || as === "div")
  ) {
    // eslint-disable-next-line no-console
    console.warn(
      "[BentoGridItem] An item with onClick is better as `as=\"button\"` or `as=\"a\"`; it has been given button semantics (role/tabIndex/keyboard) so it stays keyboard-operable and announced to screen readers.",
    );
  }

  const handleKeyDown = needsButtonSemantics
    ? (e: React.KeyboardEvent<HTMLElement>) => {
        props.onKeyDown?.(e);
        if (e.defaultPrevented) return;
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          onClick?.(e as unknown as React.MouseEvent<HTMLElement>);
        }
      }
    : props.onKeyDown;

  const itemStyle: React.CSSProperties & Record<string, string> = {
    ...consumerStyle,
    "--tile-index": String(indexFromGrid ?? 0),
  } as React.CSSProperties & Record<string, string>;

  const className2 = [
    styles.tile,
    span === "2x2" && styles.span2x2,
    highlight ? styles.highlight : styles.muted,
    colSpanClass[span],
    animateEntrance && styles.reveal,
    // tile text colors: highlight tiles sit on accent (foreground reads fine);
    // muted tiles use foreground. Both are token utilities.
    "text-foreground p-5",
    "focus-visible:outline-none",
    className,
  ]
    .filter(Boolean)
    .join(" ");

  return createElement(
    Root,
    {
      ref,
      ...props,
      onClick,
      onKeyDown: handleKeyDown,
      // listitem semantics win on <li>; an interactive non-native item gets
      // button semantics + focusability so it is keyboard/SR operable.
      role: isListItem
        ? "listitem"
        : needsButtonSemantics
          ? "button"
          : props.role,
      tabIndex: needsButtonSemantics ? (props.tabIndex ?? 0) : props.tabIndex,
      "data-glow": glowEnabled ? "true" : "false",
      className: className2,
      style: itemStyle,
    },
    createElement("span", { key: "glow", "aria-hidden": "true", className: styles.glow }),
    createElement("div", { key: "content", className: styles.content }, children),
  );
}

/* ---- tiny React.Children helpers ---- */
function mapChildren(
  children: React.ReactNode,
  fn: (child: React.ReactNode, key: number) => React.ReactNode,
) {
  return Children.map(children, fn);
}
function isValidEl(child: React.ReactNode): child is React.ReactElement {
  return isValidElement(child);
}

Dependencies

  • motion
  • @bottega/tokens