docs / patterns / bento-card

BentoCard

one cell of the wall. it is a Card with a span, translucent enough that the grid’s wash reads through it rather than stopping at its edge.

intensity · structuresrsc · client

provenance

shaped from personal-website

layer

core / patterns

intensity

structures · organizes a region, stays out of the content

composition

size one cell past its neighbours to give the grid a focal pointholds any of the cell parts, or your own markup, or nothing at allspan is not a position. the grid is flow-dense, so narrow cards backfill ahead of a wide oneoutside a BentoGrid it is a plain Card. use Card directly instead

a11y

the spotlight and edge glow are aria-hidden · the shine respects reduced motionjest-axe

dependencies

Card from the same package
live demo · try it out
Focal

The cell you sized.

Everything else backfills around it.

2.4k

active users

94%

hit ratio

12

shipped
customize
spancolumns the cell covers
2
rowSpanrows the cell covers
2
highlightedge treatment on the cell
usagecopy
import { BentoCard, BentoGrid } from "usva/patterns/bento-grid";

<BentoGrid columns={3}>
  <BentoCard span={2} rowSpan={2} highlight="wash">...</BentoCard>
  <BentoCard>...</BentoCard>
  <BentoCard>...</BentoCard>
</BentoGrid>

props

proptypedefaultnotes
spannumbercolumns the cell covers. nothing clamps it, overshoot the grid and the card overflows its track.
rowSpannumberrows the cell covers. rows are minmax, so this raises the floor rather than fixing the height.
highlight"none" | "wash" | "edge" | "ring"inherits the Card highlight vocabulary. one focal cell earns a highlight, a wall of them does not.

get it

npx shadcn add https://usva.build/r/bento-grid.jsoncopy
source · components/ui/bento-grid.tsxexactly what this command copiescopy
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  Card,
  type CardHighlight,
  type CardProps,
} from "./card";

export interface BentoGridProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Force an explicit column count; omit for a responsive auto-fit grid. */
  columns?: number;
}

export const BentoGrid = React.forwardRef<HTMLDivElement, BentoGridProps>(
  ({ className, columns, style, children, ...p }, ref) => {
    const gridRef = React.useRef<HTMLDivElement | null>(null);
    const setRefs = React.useCallback(
      (node: HTMLDivElement | null) => {
        gridRef.current = node;
        if (typeof ref === "function") ref(node);
        else if (ref) ref.current = node;
      },
      [ref],
    );

    React.useEffect(() => {
      const grid = gridRef.current;
      if (!grid) return;
      if (
        typeof window.matchMedia === "function" &&
        window.matchMedia("(prefers-reduced-motion: reduce)").matches
      )
        return;

      let frame = 0;
      let pending: { x: number; y: number } | null = null;

      /* Measuring inside the frame would force a synchronous layout on every
       * pointer move, since the previous frame already wrote inline styles.
       * The geometry only changes on resize or scroll, so it is cached and the
       * reads happen up front, never interleaved with the writes. */
      type Measured = { grid: DOMRect; cards: [HTMLElement, DOMRect][] };
      let cache: Measured | null = null;
      let observed: HTMLElement[] = [];

      const resizeObserver =
        typeof ResizeObserver === "undefined"
          ? null
          : new ResizeObserver(() => {
              cache = null;
            });
      resizeObserver?.observe(grid);

      const measure = (): Measured => {
        const cards = Array.from(
          grid.querySelectorAll<HTMLElement>("[data-bento-card]"),
        );
        const same =
          cards.length === observed.length &&
          cards.every((card, i) => card === observed[i]);
        if (!same && resizeObserver) {
          for (const card of observed) resizeObserver.unobserve(card);
          for (const card of cards) resizeObserver.observe(card);
          observed = cards;
        }
        return {
          grid: grid.getBoundingClientRect(),
          cards: cards.map((card) => [card, card.getBoundingClientRect()]),
        };
      };

      const paint = () => {
        frame = 0;
        const point = pending;
        if (!point) return;
        if (!cache) cache = measure();
        const rects = cache;
        grid.style.setProperty("--bento-x", `${point.x - rects.grid.left}px`);
        grid.style.setProperty("--bento-y", `${point.y - rects.grid.top}px`);
        grid.style.setProperty("--bento-fill-o", "1");
        grid.style.setProperty("--edge-o", "1");
        for (const [card, r] of rects.cards) {
          card.style.setProperty("--edge-x", `${point.x - r.left}px`);
          card.style.setProperty("--edge-y", `${point.y - r.top}px`);
        }
      };

      const invalidate = () => {
        cache = null;
      };

      const onMove = (e: PointerEvent) => {
        pending = { x: e.clientX, y: e.clientY };
        if (!frame) frame = requestAnimationFrame(paint);
      };
      const onLeave = () => {
        if (frame) cancelAnimationFrame(frame);
        frame = 0;
        pending = null;
        grid.style.setProperty("--bento-fill-o", "0");
        grid.style.setProperty("--edge-o", "0");
      };

      grid.addEventListener("pointermove", onMove, { passive: true });
      grid.addEventListener("pointerleave", onLeave, { passive: true });
      window.addEventListener("scroll", invalidate, {
        passive: true,
        capture: true,
      });
      return () => {
        grid.removeEventListener("pointermove", onMove);
        grid.removeEventListener("pointerleave", onLeave);
        window.removeEventListener("scroll", invalidate, { capture: true });
        resizeObserver?.disconnect();
        if (frame) cancelAnimationFrame(frame);
      };
    }, []);

    return (
      <div
        ref={setRefs}
        className={cn(
          "wash-accent group/bento relative isolate grid auto-rows-[minmax(0,auto)] grid-flow-dense gap-3 rounded-3xl p-3 sm:auto-rows-[minmax(9rem,auto)]",
          columns == null &&
            "[grid-template-columns:repeat(auto-fit,minmax(min(100%,15rem),1fr))]",
          className,
        )}
        style={
          columns == null
            ? style
            : {
                gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
                ...style,
              }
        }
        {...p}
      >
        <span aria-hidden className="bento-spotlight" />
        {children}
      </div>
    );
  },
);
BentoGrid.displayName = "BentoGrid";

export interface BentoCardProps extends CardProps {
  /**
   * Column span. Not a position: the grid is `grid-flow-dense`, so a wide card
   * lets narrower ones backfill ahead of it. Nothing clamps this against
   * `columns`, so overshoot and the card overflows its track.
   */
  span?: number;
  /** Row span. Rows are `minmax(9rem,auto)`, so this raises the floor, not the height. */
  rowSpan?: number;
  highlight?: CardHighlight;
}

export const BentoCard = React.forwardRef<HTMLDivElement, BentoCardProps>(
  ({ className, span, rowSpan, style, children, ...p }, ref) => (
    <Card
      ref={ref}
      data-bento-card=""
      className={cn(
        "relative border-border bg-surface/70 transition-tint duration-base ease-soft motion-reduce:transition-none",
        className,
      )}
      style={{
        gridColumn: span != null ? `span ${span}` : undefined,
        gridRow: rowSpan != null ? `span ${rowSpan}` : undefined,
        ...style,
      }}
      {...p}
    >
      <span aria-hidden className="edge-glow" />
      {children}
    </Card>
  ),
);
BentoCard.displayName = "BentoCard";

/**
 * The three cells below are content, not containers. BentoCard supplies the
 * surface, the span, and the edge glow; a cell supplies only padding and fill.
 * They work as a child of any Card, not just a bento cell.
 */

const cellShell = "@container flex h-full flex-col p-6";

/** Mono uppercase label beside an icon tile. Shared by BentoInfo and BentoText. */
function CellLabel({
  icon,
  label,
}: {
  icon?: React.ReactNode;
  label: React.ReactNode;
}) {
  return (
    <div className="mb-4 flex items-center gap-2.5">
      {icon != null && (
        <span className="grid size-8 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-alt [&_svg]:size-4">
          {icon}
        </span>
      )}
      <span className="font-mono text-[0.625rem] uppercase leading-none tracking-[0.18em] text-accent-alt">
        {label}
      </span>
    </div>
  );
}

const metricValueSizes = {
  // Sized against the cell, not the viewport: a display number in a narrow card
  // otherwise renders at its full viewport size and overruns its neighbour.
  md: "text-[clamp(2rem,18cqi,3.5rem)] font-bold text-ink/70",
  lg: "text-[clamp(2.5rem,25cqi,6rem)] font-black text-ink",
} as const;

const NUMERIC = /^-?\d+(\.\d+)?$/;

function parseTarget(value: React.ReactNode): number | null {
  if (typeof value === "number") return Number.isFinite(value) ? value : null;
  if (typeof value === "string" && NUMERIC.test(value)) return Number(value);
  return null;
}

function decimalsOf(value: React.ReactNode): number {
  const text = String(value);
  const dot = text.indexOf(".");
  return dot === -1 ? 0 : text.length - dot - 1;
}

const prefersReducedMotion = () =>
  typeof window !== "undefined" &&
  typeof window.matchMedia === "function" &&
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const COUNT_UP_MS = 900;

/**
 * Counts from zero to `target` on mount using rAF alone. No motion dependency, so the
 * registry-copied source has nothing extra to resolve. Runs in a layout effect so the
 * first paint is already at zero rather than flashing the final value.
 *
 * A hidden or throttled tab never delivers a frame, and a metric stuck at zero is worse
 * than one that never animated, so the count is skipped outright when the document is
 * hidden and snapped to the target if no frame arrives.
 */
function useCountUp(target: number | null, decimals: number, run: boolean) {
  const [display, setDisplay] = React.useState(target);
  const useIsomorphicLayoutEffect =
    typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

  useIsomorphicLayoutEffect(() => {
    const hidden = typeof document !== "undefined" && document.hidden;
    if (!run || target == null || hidden || prefersReducedMotion()) {
      setDisplay(target);
      return;
    }

    setDisplay(0);
    let frame = 0;
    let start = 0;
    let painted = false;

    const step = (now: number) => {
      painted = true;
      if (!start) start = now;
      const progress = Math.min((now - start) / COUNT_UP_MS, 1);
      const eased = 1 - (1 - progress) ** 3;
      setDisplay(Number((target * eased).toFixed(decimals)));
      if (progress < 1) frame = requestAnimationFrame(step);
    };

    frame = requestAnimationFrame(step);
    const rescue = setTimeout(() => {
      if (!painted) setDisplay(target);
    }, COUNT_UP_MS + 300);

    return () => {
      cancelAnimationFrame(frame);
      clearTimeout(rescue);
    };
  }, [target, decimals, run]);

  return display;
}

export interface BentoMetricProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  value: React.ReactNode;
  label: React.ReactNode;
  icon?: React.ReactNode;
  /** Trailing unit on the value, keyed to the alternate accent. */
  suffix?: React.ReactNode;
  /** Aside under the value. Flavour text, a caveat, a comparison. */
  note?: React.ReactNode;
  /** `lg` is the standalone stat treatment: display weight, full-strength ink. */
  size?: keyof typeof metricValueSizes;
  /** Count up from zero on mount. Ignored for non-numeric values. */
  animate?: boolean;
}

export const BentoMetric = React.forwardRef<HTMLDivElement, BentoMetricProps>(
  (
    {
      className,
      value,
      label,
      icon,
      suffix,
      note,
      size = "md",
      animate,
      ...props
    },
    ref,
  ) => {
    const target = parseTarget(value);
    const decimals = decimalsOf(value);
    const counted = useCountUp(target, decimals, animate === true);
    const shown =
      target == null ? value : (counted ?? target).toFixed(decimals);

    return (
      <div
        ref={ref}
        className={cn(cellShell, "justify-between", className)}
        {...props}
      >
        <div>
          <p
            className={cn(
              "leading-none tracking-[-0.04em] tabular-nums",
              metricValueSizes[size],
            )}
          >
            {shown}
            {suffix != null && (
              <span className="text-accent-alt">{suffix}</span>
            )}
          </p>
          {note != null && (
            <p className="mt-3 font-mono text-[0.6875rem] leading-relaxed text-muted">
              {note}
            </p>
          )}
        </div>
        <span className="mt-4 inline-flex w-fit max-w-full items-center gap-2 self-start whitespace-nowrap rounded-full bg-ink/[0.06] px-3 py-1.5 font-mono text-[0.6875rem] tracking-[0.1em] text-muted [&_svg]:size-3">
          {icon}
          {label}
        </span>
      </div>
    );
  },
);
BentoMetric.displayName = "BentoMetric";

export interface BentoInfoProps extends React.HTMLAttributes<HTMLDivElement> {
  label: React.ReactNode;
  icon?: React.ReactNode;
}

/**
 * Label, icon tile, then anything. kajo's `variant="tech-stack"` union is gone:
 * pass Chips as children instead.
 */
export const BentoInfo = React.forwardRef<HTMLDivElement, BentoInfoProps>(
  ({ className, label, icon, children, ...props }, ref) => (
    <div ref={ref} className={cn(cellShell, className)} {...props}>
      <CellLabel icon={icon} label={label} />
      <div className="text-[0.9375rem] leading-relaxed text-ink">
        {children}
      </div>
    </div>
  ),
);
BentoInfo.displayName = "BentoInfo";

export interface BentoTextProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  label?: React.ReactNode;
  icon?: React.ReactNode;
  title: React.ReactNode;
  body?: React.ReactNode;
}

export const BentoText = React.forwardRef<HTMLDivElement, BentoTextProps>(
  ({ className, label, icon, title, body, children, ...props }, ref) => (
    <div ref={ref} className={cn(cellShell, className)} {...props}>
      {label != null && <CellLabel icon={icon} label={label} />}
      <h3 className="text-xl font-semibold tracking-[-0.01em] text-ink">
        {title}
      </h3>
      {body != null && (
        <p className="mt-3 text-[0.9375rem] leading-relaxed text-muted">
          {body}
        </p>
      )}
      {children}
    </div>
  ),
);
BentoText.displayName = "BentoText";