Page Sections

Orbiting Customer Avatars

Customer avatars circle slowly on one or two orbits; selecting or focusing an avatar pauses the orbit and crossfades that customer's quote into the centre.

Lay out your testimonials as a small solar system: avatars drift slowly along one or two dashed orbits (the inner ring runs faster and in the opposite direction), while each avatar counter-rotates so it always stays upright. Selecting an avatar pauses every orbit, scales the chosen one up with an accent ring, draws a thin line from the avatar to the centre and crossfades its quote into the middle; click empty space or press Esc to clear the selection and the orbits resume. No photos are needed — avatars are generated from the person's initials and a two-tone gradient whose hue steps by the golden angle, or you can pass avatarSrc for a real image. The selection supports controlled and uncontrolled modes, and an optional auto-advance picks the next person every 5 seconds.

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 orbiting-customer-avatars.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.

520
40
Active edited usageDefault props
<OrbitingCustomerAvatars/>

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/orbiting-customer-avatars.en.json

Or, once registries are configured in components.json, install it as @webberui/orbiting-customer-avatars.

Usage

import { OrbitingCustomerAvatars } from "@/components/ui/orbiting-customer-avatars";

<OrbitingCustomerAvatars
  eyebrow="What customers say"
  title="The hours they got back"
  rings={2}
  testimonials={[
    {
      quote: "Reconciliation used to eat half my week. Now I'm done before lunch.",
      name: "Lin Hsiao-ching",
      role: "Store manager",
      company: "Good Day Goods",
      highlight: "done before lunch",
    },
    {
      quote: "Drivers open their phone and know the next stop. Support calls dropped by half.",
      name: "Chen Po-yu",
      role: "Operations lead",
      company: "Polaris Freight",
    },
    // …ideally 6–8 items for one ring, 9–13 for two
  ]}
  onSelectedChange={(index) => console.log(index)}
/>

Props

PropTypeDefaultDescription
eyebrowReactNode—Small label above the title
titleReactNode—Section heading, also used as the section's accessible name
subtitleReactNode—Supporting text under the heading
testimonialsOrbitingCustomerAvatarsItem[]—Testimonial data (required)
sizenumber520Stage edge length in px; shrinks proportionally to the container on narrow screens
rings1 | 21Number of orbits; with 2, roughly 40% of the items (2–5) are moved to the inner ring
selectednumber | null—Controlled selected index; null means nothing is selected
defaultSelectednumber | nullnullInitial selected index in uncontrolled mode
onSelectedChange(index: number | null) => void—Called when the selection changes; receives null when cleared
autoAdvancebooleanfalseSelect the next person every 5 seconds; shows a pause button when enabled
durationnumber—Seconds per outer-orbit revolution; falls back to --wb-orbiting-customer-avatars-duration (default 40s)
hintstring"Select any avatar to read their review"Hint shown under the centre quote when nothing is selected
sectionLabelstring"Customer reviews"Accessible name for the section when there is no title
pauseLabelstring"Pause auto-advance"Label of the auto-advance pause button
playLabelstring"Resume auto-advance"Label of the auto-advance resume button
classNamestring—Merged into the root section

OrbitingCustomerAvatarsItem

FieldTypeDefaultDescription
quotestring—Full quote text
namestring—Customer name, also used for the avatar initials (last two characters for CJK names, first letters for Latin names)
rolestring—Job title
companystring—Company name
ratingnumber—Rating (0–5); not shown by this component, kept for the shared data shape
avatarHuenumber—Avatar gradient hue (0–360); derived from the index by the 137.508° golden angle when omitted
highlightstring—A fragment of quote to emphasise in the accent colour; must match the text exactly
metric{ value: string; label: string }—Outcome figure shown under the selected quote (when the stage is large enough)
avatarSrcstring—Avatar image URL; falls back to initials on a gradient disc

How it works

  • The motion is pure CSS keyframes: each ring is a container rotating 0→360deg linearly forever, and every avatar counter-rotates on the same period, so avatars always stay upright. The inner ring runs at 0.7× the outer period, in the opposite direction.
  • Timing and colour are exposed as CSS variables: --wb-orbiting-customer-avatars-duration (outer period, default 40s) and --wb-orbiting-customer-avatars-accent (accent, #0d9488 light, #2dd4bf dark).
  • Pausing uses animation-play-state: paused: selecting, mouse-hovering (fine pointers only) or keyboard-focusing any avatar freezes all orbits at their current angle. The connector line lives inside the rotating container, so it points at the avatar whatever angle it stopped at.
  • All geometry is expressed as percentages of the stage, and size only sets the upper bound; on narrower containers everything scales down together, and the centre quote's type size follows the stage width through container queries.
  • Auto-advance skips a beat while the pointer is over an avatar, while an avatar has keyboard focus, or while the tab is hidden.

Accessibility

  • The root is a <section> labelled by its heading via aria-labelledby; without a heading it falls back to sectionLabel
  • Each avatar is a type="button" with an aria-label of "name, role, company" and aria-pressed for its selected state; Tab visits them in order, focus pauses the orbit, and Enter / Space selects or clears
  • Pressing Esc inside the stage clears the selection — the keyboard equivalent of clicking empty space
  • The centre quote area is aria-live="polite", so changes are announced; quotes are marked up as figure / blockquote / figcaption
  • When autoAdvance is on, a visible pause / resume button is always rendered (WCAG 2.2.2)
  • With the system "reduce motion" setting, the orbits stand still with avatars evenly spaced and auto-advance is disabled; selection no longer scales, keeping only the accent ring, the connector line and a 150ms quote fade