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):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/bento-gridUsage
<BentoGrid>
{/* children */}
</BentoGrid>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | React.ReactNode | — | Tiles — typically <BentoGridItem> children. |
| cols | enum | 3 | Number of base columns (default 3). |
| gap | string | gap-4 | Grid gap as a Tailwind gap-* class or any class string (default "gap-4"). |
| as | React.ElementType | — | Polymorphic root: "div" | "ul" | "ol" | "section" (default "div"). |
| aria-label | string | — | Screen-reader label for the section. |
| className | string | Escape hatch for one-off overrides. |
Source
"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