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.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<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.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
eyebrow | ReactNode | — | Small label above the title |
title | ReactNode | — | Section heading, also used as the section's accessible name |
subtitle | ReactNode | — | Supporting text under the heading |
testimonials | OrbitingCustomerAvatarsItem[] | — | Testimonial data (required) |
size | number | 520 | Stage edge length in px; shrinks proportionally to the container on narrow screens |
rings | 1 | 2 | 1 | Number of orbits; with 2, roughly 40% of the items (2–5) are moved to the inner ring |
selected | number | null | — | Controlled selected index; null means nothing is selected |
defaultSelected | number | null | null | Initial selected index in uncontrolled mode |
onSelectedChange | (index: number | null) => void | — | Called when the selection changes; receives null when cleared |
autoAdvance | boolean | false | Select the next person every 5 seconds; shows a pause button when enabled |
duration | number | — | Seconds per outer-orbit revolution; falls back to --wb-orbiting-customer-avatars-duration (default 40s) |
hint | string | "Select any avatar to read their review" | Hint shown under the centre quote when nothing is selected |
sectionLabel | string | "Customer reviews" | Accessible name for the section when there is no title |
pauseLabel | string | "Pause auto-advance" | Label of the auto-advance pause button |
playLabel | string | "Resume auto-advance" | Label of the auto-advance resume button |
className | string | — | Merged into the root section |
OrbitingCustomerAvatarsItem
| Field | Type | Default | Description |
|---|---|---|---|
quote | string | — | Full quote text |
name | string | — | Customer name, also used for the avatar initials (last two characters for CJK names, first letters for Latin names) |
role | string | — | Job title |
company | string | — | Company name |
rating | number | — | Rating (0–5); not shown by this component, kept for the shared data shape |
avatarHue | number | — | Avatar gradient hue (0–360); derived from the index by the 137.508° golden angle when omitted |
highlight | string | — | 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) |
avatarSrc | string | — | 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, default40s) and--wb-orbiting-customer-avatars-accent(accent,#0d9488light,#2dd4bfdark). - 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
sizeonly 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 viaaria-labelledby; without a heading it falls back tosectionLabel - Each avatar is a
type="button"with anaria-labelof "name, role, company" andaria-pressedfor 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 asfigure/blockquote/figcaption - When
autoAdvanceis 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
Numbered Quote Slider
Large 01/02/03 numerals on the left roll digit by digit while the quote on the right slides in from the direction of travel, with drag, arrow-key navigation and a progress fraction.
Avatar Tab Quotes
A testimonial section React component: a vertical column of avatars and names acts as tabs, the selection marker slides along the column, and the quote on the right switches with a blurred crossfade while the avatar scales up.