Page Sections

Bento Case Studies

An uneven bento testimonial mosaic for React mixing one large featured case, metric tiles, short-quote tiles and partner wordmark tiles; tiles appear in grid order, numbers count up, and a read-the-case link appears on hover.

Turn customer stories into an uneven bento box: a 2×2 featured tile carries a full-bleed portrait or scene (a gradient with an abstract silhouette when no photo is given) and a long quote, mixed with huge-number metric tiles, short quotes with small avatars, and a partner tile holding four text wordmarks. The grid is 4 columns wide on large screens and 2 below md, and smaller tiles back-fill gaps automatically. When the section enters the viewport, tiles scale in one after another by their actual grid position (row-major) and metric tiles then count up from 0; any tile with href is a whole-tile link that zooms its image slightly and reveals "Read the case →" on hover or keyboard focus.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add bento-case-studies.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

70
500
1.05
Active edited usageDefault props
<BentoCaseStudies/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/bento-case-studies.en.json

Or, once registries are configured in components.json, install it as @webberui/bento-case-studies.

Usage

import { BentoCaseStudies } from "@/components/ui/bento-case-studies";

<BentoCaseStudies
  eyebrow="客戶案例"
  title="他們用成果說話"
  items={[
    {
      kind: "feature",
      quote: "現在行銷自己就能把活動頁拼出來,上線速度快了一倍。",
      name: "林語晴",
      role: "品牌行銷經理",
      company: "青嶼咖啡",
      highlight: "上線速度快了一倍",
      href: "/cases/qingyu",
    },
    { kind: "metric", value: "+38%", label: "會員回購率", source: "青嶼咖啡" },
    { kind: "quote", quote: "回報表單終於不用再教第二次。", name: "陳柏翰", company: "北辰物流" },
    { kind: "logo", logos: ["青嶼咖啡", "北辰物流", "拾光學院", "好日選物"] },
  ]}
/>

Props

PropTypeDefaultDescription
eyebrowstring"客戶案例" (Customer stories)Eyebrow above the title
titlestring"他們用成果說話" (Results that speak for themselves)Section title; also the section's accessible name
subtitlestring—Supporting text under the title
itemsBentoCaseStudiesTile[]—Tiles, placed in array order into a 4-column grid (2 columns below md); smaller tiles back-fill gaps
showLogosbooleantrueWhether to show partner wordmark tiles
staggernumber—Entry stagger between tiles (ms); reads --wb-bento-case-studies-stagger when omitted (default 70ms)
durationnumber—Entry duration of one tile (ms); reads --wb-bento-case-studies-duration when omitted (default 500ms)
hoverZoomnumber1.05Image zoom factor on hover/focus (1–1.12)
readLabelstring"閱讀案例" (Read the case)Text of the revealed link
activeIndexnumber | null—Controlled index of the active tile (an items index, null for none); managed internally from hover/focus when omitted
defaultActiveIndexnumber | nullnullInitial active tile index in uncontrolled mode
onActiveIndexChange(index: number | null) => void—Called when the active tile changes
classNamestring—Class names merged onto the root section

BentoCaseStudiesTile

Four tile types keyed by kind. Every tile accepts href (makes the whole tile a link) and span ("1x1", "2x1" or "2x2"; defaults to 2x2 for feature and 1x1 for the rest).

FieldTypeDefaultDescription
kind"feature" | "metric" | "quote" | "logo"—Tile type
hrefstring—When set, the whole tile becomes a link and reveals "Read the case →" on hover/focus
span"1x1" | "2x1" | "2x2"by kindTile size (columns × rows)
quote, name, role, company, avatarHue, highlightsee BentoCaseStudiesItem—Quote and author data for feature and quote tiles
photoSrcstring—feature: URL of a real photo; falls back to a gradient + abstract silhouette placeholder
photoAltstring—feature: photo alt text; treated as decorative when omitted
avatarSrcstring—quote: URL of a real avatar; falls back to initials on a gradient circle
valuestring—metric: number string, may include a prefix/suffix (e.g. +38%, 3.2 倍, 12,000); the numeric part counts up on entry
labelstring—metric: label under the number; logo: accessible name of the wordmark list (default "合作夥伴" (Partners))
sourcestring—metric: one-line source
logosstring[]—logo: partner names (text wordmarks); only the first 4 are used

BentoCaseStudiesItem

FieldTypeDefaultDescription
quotestring—Testimonial body
namestring—Reviewer name
rolestring—Job title
companystring—Company or organisation
ratingnumber—Star rating (1–5); not shown here, kept so data can be shared with other testimonial components
avatarHuenumberby indexAvatar gradient hue (0–360); assigned by index via the golden angle when omitted
highlightstring—Fragment of quote to mark in the accent colour (must match the quote text exactly)
metric{ value: string; label: string }—Result metric; this component shows metrics as separate metric tiles instead

How it works

  • The grid uses grid-flow-row-dense: 4 columns on wide screens, 2 below md, rows at least 10.5rem, 12px gaps and 20px corners; non-featured tiles rotate between neutral-50, neutral-100 and a 10% accent tint.
  • Entry order is not the array index: each tile's actual position is measured and sorted row-major, because dense back-filling and the narrow-screen column change both affect which tile is top-left. Each tile goes from scale 0.96 and y 12px to rest while fading in.
  • Timing is exposed as CSS variables: --wb-bento-case-studies-stagger (default 70ms) and --wb-bento-case-studies-duration (default 500ms). Override them on a wrapper or globally; passing the stagger/duration props writes those variables directly.
  • The accent only flows through --wb-bento-case-studies-accent (default #0d9488, dark #2dd4bf) for the eyebrow, highlighted fragments, accent tiles and the focus ring.
  • Metric tiles count from 0 to the final value (1000ms) after appearing, keeping the original prefix, suffix, decimals and grouping; the first render already outputs the final value, so SSR and no-JS are correct.
  • Hovering or focusing a featured or quote tile zooms its image (or avatar) to hoverZoom over 600ms ease-out, and linked tiles reveal "Read the case →" at the bottom; wordmarks in the partner tile turn from neutral-400 to neutral-900 on hover.
  • Avatars use no photo URLs: initials (last two characters for Chinese names, first letters for Latin names) on a two-tone gradient whose hue follows the golden angle by index — deterministic and SSR-consistent. Pass photoSrc/avatarSrc for real images.

Accessibility

  • The root is a <section aria-labelledby> pointing at the title; without a title the eyebrow becomes its aria-label
  • Tiles with href are whole-tile <a> elements whose aria-label includes the read label, author and company; they are reachable with Tab and show a focus-visible ring, while the revealed link text is visual only (aria-hidden)
  • Tiles without a link are <figure> elements, with quotes rendered as blockquote and figcaption
  • The counting number in metric tiles is hidden from assistive tech, and an sr-only copy provides the final value, so screen readers never read out the count
  • Partner tiles are role="list" with each wordmark as role="listitem"
  • When the user turns on reduce motion, entries become a 150ms fade (no scale or offset), numbers show their final value without counting, and hover does not zoom images