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.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<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.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
eyebrow | string | "客戶案例" (Customer stories) | Eyebrow above the title |
title | string | "他們用成果說話" (Results that speak for themselves) | Section title; also the section's accessible name |
subtitle | string | — | Supporting text under the title |
items | BentoCaseStudiesTile[] | — | Tiles, placed in array order into a 4-column grid (2 columns below md); smaller tiles back-fill gaps |
showLogos | boolean | true | Whether to show partner wordmark tiles |
stagger | number | — | Entry stagger between tiles (ms); reads --wb-bento-case-studies-stagger when omitted (default 70ms) |
duration | number | — | Entry duration of one tile (ms); reads --wb-bento-case-studies-duration when omitted (default 500ms) |
hoverZoom | number | 1.05 | Image zoom factor on hover/focus (1–1.12) |
readLabel | string | "閱讀案例" (Read the case) | Text of the revealed link |
activeIndex | number | null | — | Controlled index of the active tile (an items index, null for none); managed internally from hover/focus when omitted |
defaultActiveIndex | number | null | null | Initial active tile index in uncontrolled mode |
onActiveIndexChange | (index: number | null) => void | — | Called when the active tile changes |
className | string | — | 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).
| Field | Type | Default | Description |
|---|---|---|---|
kind | "feature" | "metric" | "quote" | "logo" | — | Tile type |
href | string | — | When set, the whole tile becomes a link and reveals "Read the case →" on hover/focus |
span | "1x1" | "2x1" | "2x2" | by kind | Tile size (columns × rows) |
quote, name, role, company, avatarHue, highlight | see BentoCaseStudiesItem | — | Quote and author data for feature and quote tiles |
photoSrc | string | — | feature: URL of a real photo; falls back to a gradient + abstract silhouette placeholder |
photoAlt | string | — | feature: photo alt text; treated as decorative when omitted |
avatarSrc | string | — | quote: URL of a real avatar; falls back to initials on a gradient circle |
value | string | — | metric: number string, may include a prefix/suffix (e.g. +38%, 3.2 倍, 12,000); the numeric part counts up on entry |
label | string | — | metric: label under the number; logo: accessible name of the wordmark list (default "合作夥伴" (Partners)) |
source | string | — | metric: one-line source |
logos | string[] | — | logo: partner names (text wordmarks); only the first 4 are used |
BentoCaseStudiesItem
| Field | Type | Default | Description |
|---|---|---|---|
quote | string | — | Testimonial body |
name | string | — | Reviewer name |
role | string | — | Job title |
company | string | — | Company or organisation |
rating | number | — | Star rating (1–5); not shown here, kept so data can be shared with other testimonial components |
avatarHue | number | by index | Avatar gradient hue (0–360); assigned by index via the golden angle when omitted |
highlight | string | — | 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 thestagger/durationprops 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
hoverZoomover 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/avatarSrcfor real images.
Accessibility
- The root is a
<section aria-labelledby>pointing at the title; without a title the eyebrow becomes itsaria-label - Tiles with
hrefare whole-tile<a>elements whosearia-labelincludes 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 asblockquoteandfigcaption - 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 asrole="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
One-Line Quote Carousel
A minimal React carousel showing a single large-type sentence; quotes cross-fade vertically with a slight blur, leaving only the author and fine dot indicators below over slowly flowing hairlines.
Social Post Reviews
Testimonials styled as generic social posts in a two-column feed; new posts slide in from the top at intervals and like counts tick up with a small bounce.