Testimonial wall
Many short voices at once, so the amount itself is the proof: masonry with filters, three columns that drift, an average with its distribution and large quotes whose key phrase lights up.
Many voices at once
A testimonial wall shows many short voices at once, the sheer number becomes the proof. The four versions here keep that number readable, with filters, a calm drift, an honest distribution of ratings and key phrases that stand out.
Masonry with filters lays cards of different heights in columns with the key phrase marked in each, chips with counts filter by group and a button shows more without leaving the page. Drifting columns lets three columns drift upward at their own pace and fade at the edges, they pause under the pointer, on focus and with a button and stand still with reduced motion. Rating with distribution shows the average large with stars and the share per star as bars, beside rated quotes that sort by newest or highest rated. Highlights while scrolling alternates large quotes left and right, the key phrase lights up as each one reaches the middle of the screen.
Mark one key phrase per quote, people scan a wall rather than read it. Show the full distribution of ratings, a few lower ones make the average believable. Filter by the groups your buyers belong to, so each visitor finds people like them.
Variants
Details
What Social proof wall brings along, read from the registry and the source.
- Category
- Testimonials
- Area
- Marketing
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/social-proof-wall.tsx
- npm packages
- None
- Languages
- English and German, 13 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SocialProofWallProps interface from components/ui/social-proof-wall.tsx, with the comments from the code.
variantoptionalSocialProofWallVariantmasonry-filtercolumns-driftrating-wallhighlight-scroll
eyebrowoptionalReact.ReactNodetitlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodeitemsrequiredProofQuote[]initialoptionalnumbermasonry-filter: cards shown before "show more"
ratingoptional{ value: number; count: number; distribution: number[] }rating-wall: the average, the count and the share per star from 5 to 1
statsoptional{ value: string; label: string }[]highlight-scroll: figures placed between the quotes
ctaoptional{ label: string; href: string }
Built-in texts
Social proof wall writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 13 texts
| Key | English | German |
|---|---|---|
| all | English All | German Alle |
| filter | English Filter by group | German Nach Gruppe filtern |
| more | English Show {n} more | German {n} weitere zeigen |
| pause | English Pause the wall | German Wand anhalten |
| play | English Play the wall | German Wand abspielen |
| outOf | English out of 5 | German von 5 |
| reviews | English {n} reviews | German {n} Bewertungen |
| stars | English {n} stars | German {n} Sterne |
| sort | English Sort | German Sortieren |
| newest | English Newest | German Neueste |
| best | English Highest rated | German Beste Bewertung |
| rated | English Rated {n} out of 5 | German Mit {n} von 5 bewertet |
| shown | English {n} of {t} voices shown | German {n} von {t} Stimmen sichtbar |
Accessibility
Read from the source of the component.
- Social proof wall handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-label, aria-labelledby and aria-pressed, so screen readers announce purpose and state.
- Uses the roles group, label, radio, radiogroup and status.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 7 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 469 lines
'use client';
import { useId, useMemo, useState } from 'react';
import { cn, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
/*
* A wall of voices: many short quotes at once, so the amount itself is the
* proof. People are drawn as monograms unless you pass a portrait. Mark the
* phrase that matters with *stars* in the quote. Four layouts:
*
* masonry-filter cards in columns of different heights, filter chips, show more
* columns-drift three columns that drift upward at their own pace, pausable
* rating-wall the average with its distribution beside short rated quotes, sortable
* highlight-scroll large quotes whose key phrase lights up while scrolling, with figures between
*/
export type SocialProofWallVariant = 'masonry-filter' | 'columns-drift' | 'rating-wall' | 'highlight-scroll';
export interface ProofQuote {
/** The quote. *Words between stars* are highlighted. */
quote: string;
name: string;
role?: string;
/** 1 to 5 */
rating?: number;
/** Used by the filter, e.g. Agencies */
tag?: string;
/** As text, e.g. 2 weeks ago */
date?: string;
/** Sort key for newest first, larger is newer */
order?: number;
/** Where it was said, e.g. Review, Email, Survey */
source?: string;
avatar?: React.ReactNode;
}
export interface SocialProofWallProps {
variant?: SocialProofWallVariant;
eyebrow?: React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
items: ProofQuote[];
/** masonry-filter: cards shown before "show more" */
initial?: number;
/** rating-wall: the average, the count and the share per star from 5 to 1 */
rating?: { value: number; count: number; distribution: number[] };
/** highlight-scroll: figures placed between the quotes */
stats?: { value: string; label: string }[];
cta?: { label: string; href: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
all: 'All',
filter: 'Filter by group',
more: 'Show {n} more',
pause: 'Pause the wall',
play: 'Play the wall',
outOf: 'out of 5',
reviews: '{n} reviews',
stars: '{n} stars',
sort: 'Sort',
newest: 'Newest',
best: 'Highest rated',
rated: 'Rated {n} out of 5',
shown: '{n} of {t} voices shown',
},
de: {
all: 'Alle',
filter: 'Nach Gruppe filtern',
more: '{n} weitere zeigen',
pause: 'Wand anhalten',
play: 'Wand abspielen',
outOf: 'von 5',
reviews: '{n} Bewertungen',
stars: '{n} Sterne',
sort: 'Sortieren',
newest: 'Neueste',
best: 'Beste Bewertung',
rated: 'Mit {n} von 5 bewertet',
shown: '{n} von {t} Stimmen sichtbar',
},
};
type Texts = (typeof TEXTS)['en'];
type ViewProps = SocialProofWallProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const delay = (ms: number) => ({ '--kit-delay': `${ms}ms` }) as React.CSSProperties;
const initials = (n: string) =>
n
.split(/\s+/)
.map((w) => w[0])
.slice(0, 2)
.join('')
.toUpperCase();
/** Text with *marks*. lit lights the marks (used while scrolling), always lit otherwise. */
function Quote({ text, lit = true, className }: { text: string; lit?: boolean; className?: string }) {
const parts = text.split(/\*([^*]+)\*/g);
return (
<span className={className}>
{parts.map((s, i) =>
i % 2 ? (
<mark
key={i}
className="rounded-[0.2em] bg-transparent bg-[linear-gradient(color-mix(in_oklab,var(--primary)_22%,transparent),color-mix(in_oklab,var(--primary)_22%,transparent))] bg-no-repeat px-[0.08em] text-foreground transition-[background-size] duration-1000 ease-[var(--ease-kit)] [box-decoration-break:clone] motion-reduce:transition-none"
style={{ backgroundSize: lit ? '100% 100%' : '0% 100%' }}
>
{s}
</mark>
) : (
s
),
)}
</span>
);
}
function Person({ q, size = 'md' }: { q: ProofQuote; size?: 'md' | 'lg' }) {
const tone = [16, 26, 36, 22, 30][q.name.length % 5];
return (
<span className="flex min-w-0 items-center gap-3">
<span
aria-hidden
className={cn('grid shrink-0 place-items-center overflow-hidden rounded-full font-semibold text-foreground [&>*]:size-full [&>*]:object-cover', size === 'lg' ? 'size-12 text-[14px]' : 'size-9 text-[12px]')}
style={{ background: `color-mix(in oklab, var(--primary) ${tone}%, var(--card))` }}
>
{q.avatar ?? initials(q.name)}
</span>
<span className="min-w-0">
<span className="block truncate text-[14px] font-semibold text-foreground">{q.name}</span>
{q.role && <span className="block truncate text-[12.5px] text-muted-foreground">{q.role}</span>}
</span>
</span>
);
}
function Stars({ value, size = 14, label }: { value: number; size?: number; label?: string }) {
return (
<span className="flex gap-0.5" role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
{Array.from({ length: 5 }, (_, i) => {
const f = Math.max(0, Math.min(1, value - i));
return (
<span key={i} className="relative" style={{ width: size, height: size }}>
<Icon name="star" size={size} className="absolute inset-0 text-[color-mix(in_oklab,var(--foreground)_20%,transparent)]" />
<span data-clip-ok className="absolute inset-0 overflow-hidden" style={{ width: `${round(f * 100)}%` }}>
<Icon name="star" size={size} className="fill-current text-[color-mix(in_oklab,var(--warning)_85%,var(--foreground))]" />
</span>
</span>
);
})}
</span>
);
}
function Head({ eyebrow, title, description, center, className }: Pick<SocialProofWallProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean; className?: string }) {
return (
<div className={cn('max-w-[46rem]', center && 'mx-auto max-w-[42rem] text-center', className)}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
{description && (
<p className={cn('kit-rise mt-5 max-w-[36rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')} style={delay(80)}>
{description}
</p>
)}
</div>
);
}
function Card({ q, t, className, style }: { q: ProofQuote; t: Texts; className?: string; style?: React.CSSProperties }) {
return (
<figure className={cn('rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] transition-[box-shadow,border-color] duration-300 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)]', className)} style={style}>
{q.rating !== undefined && <Stars value={q.rating} label={fill(t.rated, { n: q.rating })} />}
<blockquote className={cn('text-pretty text-[15px] leading-relaxed text-foreground', q.rating !== undefined && 'mt-3')}>
<Quote text={q.quote} />
</blockquote>
<figcaption className="mt-4 flex items-center justify-between gap-3">
<Person q={q} />
{(q.source || q.date) && <span className="shrink-0 text-right text-[12px] text-subtle-foreground">{q.date ?? q.source}</span>}
</figcaption>
</figure>
);
}
/* ------------------------------------------------------------------ */
/* masonry-filter */
/* ------------------------------------------------------------------ */
function MasonryFilter(p: ViewProps) {
const { t } = p;
const tags = useMemo(() => [...new Set(p.items.map((i) => i.tag).filter(Boolean))] as string[], [p.items]);
const [tag, setTag] = useState<string | null>(null);
const [shown, setShown] = useState(p.initial ?? 9);
const list = p.items.filter((i) => !tag || i.tag === tag);
const visible = list.slice(0, shown);
const rest = list.length - visible.length;
return (
<Section className={p.className}>
<Head {...p} center />
{tags.length > 0 && (
<div role="group" aria-label={t.filter} className="kit-rise mt-10 flex flex-wrap justify-center gap-2" style={delay(120)}>
{[null, ...tags].map((x) => (
<button
key={x ?? 'all'}
type="button"
aria-pressed={tag === x}
onClick={() => setTag(x)}
className={cn(
'inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full border px-4 text-[14px] font-medium transition-[background-color,border-color,color] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary sm:min-h-9',
tag === x ? 'border-foreground bg-foreground text-background' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground',
)}
>
{x ?? t.all}
<span className="text-[12px] tabular-nums opacity-70">{x ? p.items.filter((i) => i.tag === x).length : p.items.length}</span>
</button>
))}
</div>
)}
<p role="status" className="sr-only">
{fill(t.shown, { n: visible.length, t: list.length })}
</p>
<div className="mt-10 columns-1 gap-4 sm:columns-2 lg:columns-3">
{visible.map((q, i) => (
<Card key={`${tag}-${q.name}-${i}`} q={q} t={t} className="kit-rise mb-4 break-inside-avoid" style={delay(Math.min(i, 8) * 50)} />
))}
</div>
{(rest > 0 || p.cta) && (
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
{rest > 0 && (
<Button variant="outline" icon="chevron-down" onClick={() => setShown((n) => n + (p.initial ?? 9))}>
{fill(t.more, { n: Math.min(rest, p.initial ?? 9) })}
</Button>
)}
{p.cta && (
<Button href={p.cta.href} icon="arrow-right">
{p.cta.label}
</Button>
)}
</div>
)}
</Section>
);
}
/* ------------------------------------------------------------------ */
/* columns-drift */
/* ------------------------------------------------------------------ */
const LAYOUTS = [
{ n: 1, className: 'sm:hidden' },
{ n: 2, className: 'max-sm:hidden lg:hidden' },
{ n: 3, className: 'max-lg:hidden' },
];
function ColumnsDrift(p: ViewProps) {
const { t } = p;
const [paused, setPaused] = useState(false);
const speeds = [46, 58, 40];
return (
<Section background="surface" className={p.className}>
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<Head {...p} />
<button
type="button"
onClick={() => setPaused(!paused)}
className="inline-flex min-h-11 shrink-0 cursor-pointer items-center gap-2 self-start rounded-lg border border-border bg-card px-4 text-[14px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-primary motion-reduce:hidden sm:self-auto"
>
<Icon name={paused ? 'play' : 'pause'} size={15} />
{paused ? t.play : t.pause}
</button>
</div>
<div className="group/wall relative mt-12 h-[44rem] overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)] motion-reduce:h-auto motion-reduce:[mask-image:none]">
{/* one, two or three columns by screen width, so every voice is on the wall at every size */}
{LAYOUTS.map(({ n, className }) => (
<div key={n} className={cn('grid h-full gap-4', n === 1 && 'grid-cols-[minmax(0,1fr)]', n === 2 && 'grid-cols-2', n === 3 && 'grid-cols-3', className)}>
{Array.from({ length: n }, (_, c) => p.items.filter((_, i) => i % n === c)).map((col, c) => (
<div key={c} className="relative min-w-0">
<div
className={cn('flex flex-col gap-4 motion-reduce:animate-none', 'animate-[spw-up_var(--d)_linear_infinite] group-hover/wall:[animation-play-state:paused] group-focus-within/wall:[animation-play-state:paused]', paused && '[animation-play-state:paused]')}
// the same pace per card in every layout: a longer column takes longer
style={{ '--d': `${round((speeds[c % 3] / 4) * Math.max(1, col.length), 1)}s`, animationDelay: `${-c * 7}s` } as React.CSSProperties}
>
{col.map((q, i) => (
<Card key={i} q={q} t={t} />
))}
{/* the copy that makes the loop seamless, hidden from screen readers */}
<div aria-hidden className="flex flex-col gap-4 motion-reduce:hidden">
{col.map((q, i) => (
<Card key={i} q={q} t={t} />
))}
</div>
</div>
</div>
))}
</div>
))}
</div>
<style>{'@keyframes spw-up{to{transform:translateY(calc(-50% - 0.5rem))}}'}</style>
{p.cta && (
<div className="mt-10 flex justify-center">
<Button href={p.cta.href} size="lg" icon="arrow-right" className="max-sm:w-full">
{p.cta.label}
</Button>
</div>
)}
</Section>
);
}
/* ------------------------------------------------------------------ */
/* rating-wall */
/* ------------------------------------------------------------------ */
function RatingWall(p: ViewProps) {
const { t } = p;
const locale = useLocale();
const [sort, setSort] = useState<'newest' | 'best'>('newest');
const sortId = useId();
const [ref, inView] = useInView<HTMLDivElement>();
const r = p.rating;
const loc = locale === 'de' ? 'de-DE' : 'en-US';
const list = [...p.items].sort((a, b) => (sort === 'best' ? (b.rating ?? 0) - (a.rating ?? 0) : (b.order ?? 0) - (a.order ?? 0)));
return (
<Section className={p.className}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-14">
<div className="lg:sticky lg:top-24 lg:self-start">
<Head {...p} />
{r && (
<div ref={ref} className="kit-rise mt-8 rounded-2xl border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)]" style={delay(120)}>
<div className="flex items-end gap-3">
<span className="text-[56px] font-semibold leading-none tabular-nums tracking-[-0.05em] text-foreground">{new Intl.NumberFormat(loc, { minimumFractionDigits: 1 }).format(r.value)}</span>
<span className="pb-1.5">
<Stars value={r.value} size={16} />
<span className="mt-1 block text-[13px] text-muted-foreground">{fill(t.reviews, { n: new Intl.NumberFormat(loc).format(r.count) })}</span>
</span>
</div>
<ul className="mt-6 grid gap-2">
{r.distribution.map((share, i) => (
<li key={i} className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.75rem] items-center gap-3 text-[12.5px] text-muted-foreground">
<span className="flex items-center gap-1 tabular-nums">
{5 - i}
<Icon name="star" size={11} className="fill-current" />
<span className="sr-only">{fill(t.stars, { n: 5 - i })}</span>
</span>
<span className="h-2 overflow-hidden rounded-full bg-track">
<span className="block h-full origin-left rounded-full bg-[color-mix(in_oklab,var(--warning)_85%,var(--foreground))] transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${inView ? round(share / 100, 3) : 0})`, transitionDelay: `${i * 80}ms` }} />
</span>
<span className="text-right tabular-nums">{new Intl.NumberFormat(loc).format(share)}{locale === 'de' ? ' %' : '%'}</span>
</li>
))}
</ul>
</div>
)}
{p.cta && (
<Button href={p.cta.href} variant="outline" icon="arrow-right" className="mt-6 max-sm:w-full">
{p.cta.label}
</Button>
)}
</div>
<div className="min-w-0">
<div className="flex items-center justify-end gap-2">
<span id={sortId} className="text-[13px] text-muted-foreground">
{t.sort}
</span>
<div role="radiogroup" aria-labelledby={sortId} className="inline-flex gap-1 rounded-lg bg-surface p-1">
{(['newest', 'best'] as const).map((k) => (
<button
key={k}
type="button"
role="radio"
aria-checked={sort === k}
tabIndex={sort === k ? 0 : -1}
onClick={() => setSort(k)}
onKeyDown={(e) => {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
e.preventDefault();
const next = sort === 'newest' ? 'best' : 'newest';
setSort(next);
e.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next === 'newest' ? 0 : 1]?.focus();
}}
className={cn('h-11 cursor-pointer rounded-md px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary sm:h-8', sort === k ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}
>
{t[k]}
</button>
))}
</div>
</div>
<ul className="mt-4 grid grid-cols-[minmax(0,1fr)] gap-3 md:grid-cols-2">
{list.map((q, i) => (
<li key={`${sort}-${q.name}`} className="kit-rise" style={delay(Math.min(i, 6) * 50)}>
<Card q={q} t={t} className="h-full" />
</li>
))}
</ul>
</div>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* highlight-scroll */
/* ------------------------------------------------------------------ */
function BigQuote({ q, index }: { q: ProofQuote; index: number }) {
const [ref, inView] = useInView<HTMLElement>('0px 0px -35% 0px');
const right = index % 2 === 1;
return (
<figure ref={ref} className={cn('max-w-[44rem]', right && 'sm:ml-auto sm:text-right')}>
<Icon name="quote" size={28} className={cn('text-[color-mix(in_oklab,var(--primary)_60%,var(--muted-foreground))]', right && 'sm:ml-auto')} />
<blockquote className="mt-4 text-balance text-[clamp(1.4rem,1.1rem+1.2vw,2rem)] font-medium leading-[1.3] tracking-[-0.015em] text-muted-foreground">
<Quote text={q.quote} lit={inView} className="[&_mark]:font-semibold" />
</blockquote>
<figcaption className={cn('mt-6 flex', right && 'sm:justify-end')}>
<Person q={q} size="lg" />
</figcaption>
</figure>
);
}
function HighlightScroll(p: ViewProps) {
const stats = p.stats ?? [];
return (
<Section className={p.className}>
<Head {...p} />
<div className="mt-16 grid gap-16 sm:gap-20">
{p.items.map((q, i) => (
<div key={i} className="grid gap-16 sm:gap-20">
<BigQuote q={q} index={i} />
{stats[i] && i < p.items.length - 1 && (
<div className="kit-rise flex items-baseline justify-center gap-4 border-y border-border py-8 text-center max-sm:flex-col max-sm:items-center max-sm:gap-1">
<span className="text-[clamp(2.5rem,2rem+2vw,3.75rem)] font-semibold leading-none tracking-[-0.04em] text-foreground">{stats[i].value}</span>
<span className="max-w-[20rem] text-pretty text-[15px] leading-snug text-muted-foreground sm:text-left sm:text-[16px]">{stats[i].label}</span>
</div>
)}
</div>
))}
</div>
{p.cta && (
<div className="mt-16 flex justify-center">
<Button href={p.cta.href} size="lg" icon="arrow-right" className="max-sm:w-full">
{p.cta.label}
</Button>
</div>
)}
</Section>
);
}
/* ------------------------------------------------------------------ */
const VIEWS: Record<SocialProofWallVariant, (p: ViewProps) => React.ReactNode> = {
'masonry-filter': MasonryFilter,
'columns-drift': ColumnsDrift,
'rating-wall': RatingWall,
'highlight-scroll': HighlightScroll,
};
export function SocialProofWall({ variant = 'masonry-filter', labels, ...rest }: SocialProofWallProps) {
const t = useTexts(TEXTS, labels);
const View = VIEWS[variant] ?? MasonryFilter;
return <View {...rest} variant={variant} t={t} />;
}Frequent questions
How do I install Social proof wall?
With aiw add @ai-website/social-proof-wall. The command puts components/ui/social-proof-wall.tsx into your project, the code is yours from then on. It brings along button, icon and section. It needs no further npm packages.
Which variants does Social proof wall have?
4 variants, Masonry with filters, Drifting columns, Rating with distribution and Highlights while scrolling. You pick one with the variant prop, for example variant="columns-drift".
Can I translate the texts of Social proof wall?
Yes. Social proof wall carries 13 texts of its own in English and German, such as “All”, “Filter by group” and “Pause the wall”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Social proof wall?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Social proof wall with aiw_components and install it with aiw_add @ai-website/social-proof-wall. Name the variant in your prompt, for example variant="masonry-filter", and the assistant writes your content straight into the props.