Social Proof Wall
Viele kurze Stimmen auf einmal, damit die Menge selbst der Beleg ist: Mauerwerk mit Filtern, drei treibende Spalten, ein Durchschnitt mit Verteilung und große Zitate, deren Kernsatz aufleuchtet.
Viele Stimmen auf einmal
Eine Wand der Stimmen zeigt viele kurze Stimmen zugleich, die schiere Zahl wird zum Beleg. Die vier Fassungen hier halten diese Zahl lesbar, mit Filtern, ruhigem Treiben, einer ehrlichen Verteilung der Bewertungen und Kernsätzen, die herausstechen.
„Mauerwerk mit Filtern“ legt Karten unterschiedlicher Höhe in Spalten, in jeder der Kernsatz markiert, Chips mit Zahlen filtern nach Gruppe und ein Knopf zeigt mehr, ohne die Seite zu verlassen. „Treibende Spalten“ lässt drei Spalten in eigenem Tempo nach oben treiben und an den Rändern ausblenden, sie halten unter dem Zeiger, bei Fokus und per Knopf an und stehen bei reduzierter Bewegung still. „Bewertung mit Verteilung“ zeigt den Durchschnitt groß mit Sternen und den Anteil je Stern als Balken, daneben bewertete Zitate, sortierbar nach neu oder bester Bewertung. „Leuchten beim Scrollen“ lässt große Zitate links und rechts wechseln, der Kernsatz leuchtet auf, sobald eines die Bildschirmmitte erreicht.
Markiere je Zitat einen Kernsatz, eine Wand wird überflogen, nicht gelesen. Zeig die ganze Verteilung der Bewertungen, ein paar niedrigere machen den Durchschnitt glaubwürdig. Filtere nach den Gruppen, zu denen deine Käufer gehören, dann findet jeder Menschen wie sich.
Variants
Details
Was Wand der Stimmen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Testimonials
- Bereich
- Marketing
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/social-proof-wall.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 13 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SocialProofWallProps aus components/ui/social-proof-wall.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSocialProofWallVariantmasonry-filtercolumns-driftrating-wallhighlight-scroll
eyebrowoptionalReact.ReactNodetitlePflichtReact.ReactNodedescriptionoptionalReact.ReactNodeitemsPflichtProofQuote[]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 }
Eingebaute Texte
Diese Texte schreibt Wand der Stimmen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 13 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| all | Englisch All | Deutsch Alle |
| filter | Englisch Filter by group | Deutsch Nach Gruppe filtern |
| more | Englisch Show {n} more | Deutsch {n} weitere zeigen |
| pause | Englisch Pause the wall | Deutsch Wand anhalten |
| play | Englisch Play the wall | Deutsch Wand abspielen |
| outOf | Englisch out of 5 | Deutsch von 5 |
| reviews | Englisch {n} reviews | Deutsch {n} Bewertungen |
| stars | Englisch {n} stars | Deutsch {n} Sterne |
| sort | Englisch Sort | Deutsch Sortieren |
| newest | Englisch Newest | Deutsch Neueste |
| best | Englisch Highest rated | Deutsch Beste Bewertung |
| rated | Englisch Rated {n} out of 5 | Deutsch Mit {n} von 5 bewertet |
| shown | Englisch {n} of {t} voices shown | Deutsch {n} von {t} Stimmen sichtbar |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Wand der Stimmen reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-label, aria-labelledby und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, label, radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 7 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 469 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Wand der Stimmen?
Mit aiw add @ai-website/social-proof-wall. Der Befehl legt die Datei components/ui/social-proof-wall.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Wand der Stimmen?
4 Varianten, Mauerwerk mit Filtern, Treibende Spalten, Bewertung mit Verteilung und Leuchten beim Scrollen. Du wählst sie mit der Prop variant, zum Beispiel variant="columns-drift".
Kann ich die Texte von Wand der Stimmen übersetzen?
Ja. Wand der Stimmen bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Alle“, „Nach Gruppe filtern“ und „Wand anhalten“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Wand der Stimmen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Wand der Stimmen mit aiw_components und installieren sie mit aiw_add @ai-website/social-proof-wall. Nenn im Prompt die Variante, etwa variant="masonry-filter", dann schreibt der Assistent deine Inhalte direkt in die Props.