Layout

Bento Grid

A minimal bento layout primitive with span props, a 40ms blur-rise reveal stagger on first view, and hover isolation that dims sibling cells.

Install

npx shadcn@latest add @paragon/bento-grid

bento-grid.tsx

"use client";

import * as React from "react";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { cn } from "@/lib/utils";

const containerVariants: Variants = {
  hidden: {},
  visible: { transition: { staggerChildren: 0.04 } },
};

export interface BentoGridProps
  extends React.ComponentProps<typeof motion.div> {}

/**
 * The bento layout container: a 3-column grid (1 column on small screens)
 * whose cells reveal on first view with a 40ms blur-rise stagger. On fine
 * pointers, hovering a cell dims its siblings to 0.7 — a CSS transition,
 * so fast pointer moves retarget instead of flickering.
 */
export function BentoGrid({ className, ...props }: BentoGridProps) {
  return (
    <motion.div
      data-slot="bento-grid"
      initial="hidden"
      whileInView="visible"
      viewport={{ once: true, margin: "0px 0px -48px 0px" }}
      variants={containerVariants}
      className={cn(
        "group/bento grid grid-cols-1 gap-4 sm:grid-cols-3",
        className,
      )}
      {...props}
    />
  );
}

const colSpanClasses = {
  1: "sm:col-span-1",
  2: "sm:col-span-2",
  3: "sm:col-span-3",
} as const;

const rowSpanClasses = {
  1: "sm:row-span-1",
  2: "sm:row-span-2",
} as const;

export interface BentoCellProps
  extends React.ComponentProps<typeof motion.div> {
  /** Columns to span at the `sm` breakpoint and up. */
  colSpan?: 1 | 2 | 3;
  /** Rows to span at the `sm` breakpoint and up. */
  rowSpan?: 1 | 2;
}

/**
 * One bento cell: a plain shadow-border card surface with span props.
 * Deliberately minimal — bring your own content; no baked-in effects.
 *
 * Structure note: the hover-dim lives on an outer wrapper while the reveal
 * animation runs on the inner surface — motion leaves an inline `opacity`
 * on whatever it animates, which would otherwise override the dim class.
 * Variants still propagate from BentoGrid through the plain wrapper.
 */
export function BentoCell({
  colSpan = 1,
  rowSpan = 1,
  className,
  ...props
}: BentoCellProps) {
  const reducedMotion = useReducedMotion();

  const cellVariants: Variants = React.useMemo(
    () => ({
      hidden: reducedMotion
        ? { opacity: 0 }
        : { opacity: 0, y: 12, filter: "blur(4px)" },
      visible: {
        opacity: 1,
        y: 0,
        filter: "blur(0px)",
        transition: { type: "spring", duration: 0.4, bounce: 0 },
      },
    }),
    [reducedMotion],
  );

  return (
    <div
      data-slot="bento-cell"
      data-bento-cell=""
      className={cn(
        colSpanClasses[colSpan],
        rowSpanClasses[rowSpan],
        "transition-opacity duration-(--duration-quick) ease-out",
        "[@media(hover:hover)_and_(pointer:fine)]:group-has-[[data-bento-cell]:hover]/bento:[&:not(:hover)]:opacity-70",
      )}
    >
      <motion.div
        variants={cellVariants}
        className={cn(
          "h-full rounded-xl bg-card p-5 text-card-foreground shadow-border",
          className,
        )}
        {...props}
      />
    </div>
  );
}