Page Sections

Filterable Review Grid

A dense React grid of small testimonial cards that reflow with a layout animation when filtered by industry, lifting on hover with a border that lights up along the pointer, and expanding in place on click.

A compact grid that fits a lot of customer testimonials into one section. The category chips under the title are collected automatically from each testimonial's category; when you switch, filtered-out cards shrink and fade away while the rest glide into their new positions with a spring layout animation, and the selected-chip pill slides along with them. Each small card shows a star rating, a quote clamped to three lines, an initials gradient avatar and the role and company. On hover or keyboard focus the card lifts and its border picks up an accent glow that follows the pointer; clicking a card expands the full quote in place, clicking again collapses it, and only one card is open at a time. The current category supports controlled and uncontrolled modes.

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 filterable-review-grid.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.

350
150
Active edited usageDefault props
<FilterableReviewGrid/>

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/filterable-review-grid.en.json

Or, once registries are configured in components.json, install it as @webberui/filterable-review-grid.

Usage

import { FilterableReviewGrid } from "@/components/ui/filterable-review-grid";

<FilterableReviewGrid
  eyebrow="客戶怎麼說"
  title="各行各業都在用"
  columns={4}
  testimonials={[
    {
      quote: "導入後櫃台結帳快了一倍,尖峰時段不再排到門外。",
      name: "林語晴",
      role: "店長",
      company: "青嶼咖啡",
      rating: 5,
      category: "餐飲",
      highlight: "結帳快了一倍",
    },
    {
      quote: "司機端的路線指引很直覺,查件電話少了一大半。",
      name: "王子豪",
      role: "物流主任",
      company: "北辰物流",
      rating: 4,
      category: "物流",
    },
  ]}
  onValueChange={(category) => console.log(category ?? "全部")}
/>

Props

PropTypeDefaultDescription
eyebrowstring—Small label above the title
titlestring—Section title
subtitlestring—Supporting text below the title
testimonialsFilterableReviewGridItem[]—Testimonial data
columns2 | 3 | 44Column count on large screens (reduced automatically on smaller screens)
filterablebooleantrueShow the category filter chips; when off, every testimonial is shown
allLabelstring"All"Label of the "All" chip
valuestring | null—Controlled current category (null means all); managed internally when omitted
defaultValuestring | nullnullInitial category in uncontrolled mode
onValueChange(category: string | null) => void—Called when the category changes, with the new category (null means all)
stiffnessnumber—Spring stiffness of the reflow animation; reads --wb-filterable-review-grid-stiffness (default 350) when omitted
durationnumber—Duration of the card hover lift (ms); reads --wb-filterable-review-grid-duration (default 150ms) when omitted
classNamestring—Class merged into the root section

FilterableReviewGridItem

FieldTypeDefaultDescription
quotestring—Full quote text
namestring—Name
rolestring—Job title
companystring—Company name
ratingnumber—Star rating (0–5); the star row is hidden when omitted
avatarHuenumber—Avatar gradient hue (0–360); defaults to a golden-angle hue based on the index
avatarSrcstring—Avatar image URL; replaces the initials gradient avatar when provided
highlightstring—A fragment of the quote to mark in the accent color
metric{ value: string; label: string }—An optional result metric
categorystring—Industry category; the filter chips are collected automatically from every testimonial's category

How it works

  • The category chips follow the order in which each category first appears in the testimonials array; if the current category no longer exists in the data, it falls back to "all". Switching category collapses any expanded card.
  • The reflow uses motion layout animations with AnimatePresence mode="popLayout": filtered-out cards leave the layout immediately and shrink as they fade, while the remaining cards spring into their new positions at the same time.
  • Border glow: on pointer move the cursor position is written into the card's --x/--y CSS variables (without a React re-render), and the border layer uses a radial-gradient plus a mask so only the 1px outline lights up.
  • Avatars need no photos: CJK names use their last two characters and Latin names their initials, on a two-tone gradient whose hue is assigned by index with the golden angle (137.508°), so the output is deterministic and identical on the server and client.
  • Timing and color can be overridden with CSS variables: --wb-filterable-review-grid-stiffness (reflow spring stiffness, default 350), --wb-filterable-review-grid-duration (hover lift duration, default 150ms) and --wb-filterable-review-grid-accent (accent color, default #0d9488, #2dd4bf in dark mode).

Accessibility

  • The root element is a section labelled by its title through aria-labelledby; each testimonial is rendered as figure/blockquote/figcaption
  • The category chips are a set of aria-pressed buttons inside a role="group"; the number of results is announced through aria-live="polite" (for example "Showing 8 testimonials")
  • Each card is covered by a type="button" expand button with aria-expanded and aria-controls pointing at the quote paragraph; when focused with Tab the card lifts the same way and shows a focus-visible ring
  • The star row is role="img" with an aria-label such as "4 out of 5 stars"; the decorative dot pattern and gradient avatars are hidden from assistive technology
  • When the user has "Reduce motion" turned on in their system settings, layout reflow, scale enter/exit and the hover lift are disabled and cards switch with a 150ms fade instead; filtering and expanding still work