docs / patterns / stat-bento

StatBento

a strip of headline numbers, each a big value with its unit in an accent suffix and a short label beneath. a proof strip to sit under a hero or a case study.

intensity · structuresrsc · server

provenance

shaped from personal-website

layer

core / patterns

intensity

structures · organizes a region, stays out of the content

composition

a proof strip on a landing section, under a hero or a CaseStudyHerothree cells reads best. cells take a translucent ink fill, safe inside a Cardnot a dashboard. live metrics with trend and spark are StatCardnever mix cell types. a mixed grid is BentoGrid

a11y

plain text in a grid · icons are decorative and aria-hiddenjest-axe

dependencies

BentoMetric from the same package
live demo · try it out

12M

downloads

180+

contributors

60%

less code
customize
animatecount each value up from zero on mount
usagecopy
import { StatBento } from "usva/patterns/stat-bento";

<StatBento animate
  stats={[
    { value: "12", suffix: "M", label: "downloads" },
    { value: "180", suffix: "+", label: "contributors" },
    { value: "60", suffix: "%", label: "less code" },
  ]}
/>

props

proptypedefaultnotes
statsStatBentoItem[]the cells: { value, label, suffix?, icon? }. the suffix carries the unit in accent-alt.
animatebooleancounts each numeric value up from zero on mount.
asReact.ElementType"div"the element rendered as the grid. pass RevealGroup to stagger the cells: it animates its direct children, so it must be the grid, not wrap it.

get it

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

export interface StatBentoItem {
  value: React.ReactNode;
  label: React.ReactNode;
  /** Trailing unit on the value, keyed to the alternate accent. */
  suffix?: React.ReactNode;
  icon?: React.ReactNode;
}

interface StatBentoOwnProps<T extends React.ElementType> {
  stats: StatBentoItem[];
  /** Count each numeric value up from zero on mount. */
  animate?: boolean;
  /**
   * Element rendered as the grid. Pass `RevealGroup` to stagger the cells: it
   * animates its direct children, so it has to be the grid rather than wrap it.
   */
  as?: T;
}

export type StatBentoProps<T extends React.ElementType = "div"> =
  StatBentoOwnProps<T> &
    Omit<React.ComponentPropsWithoutRef<T>, keyof StatBentoOwnProps<T>>;

/**
 * A standalone strip of headline numbers, not a bento of mixed cells: every child is
 * a display-weight metric. It stays motion-free so the copied registry source has no
 * import to resolve. Polymorphic rather than a forwardRef so `as` can carry the props
 * of whatever element renders the grid.
 *
 * Cells take a translucent ink fill rather than `bg-surface`, which would vanish the
 * moment the strip sits inside a Card that already paints that surface.
 */
export function StatBento<T extends React.ElementType = "div">({
  className,
  stats,
  animate,
  as,
  ...props
}: StatBentoProps<T>) {
  const Comp = (as ?? "div") as React.ElementType;
  return (
    <Comp
      className={cn("grid grid-cols-1 gap-4 sm:grid-cols-3", className)}
      {...props}
    >
      {stats.map((stat, index) => (
        <BentoMetric
          key={typeof stat.label === "string" ? stat.label : index}
          size="lg"
          animate={animate}
          value={stat.value}
          suffix={stat.suffix}
          label={stat.label}
          icon={stat.icon}
          className="rounded-2xl bg-ink/[0.04] sm:p-8"
        />
      ))}
    </Comp>
  );
}
StatBento.displayName = "StatBento";