Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 2 Components in Testimonials

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

Mauerwerk mit FilternNeu in 1.2

Karten unterschiedlicher Höhe in Spalten, in jeder der Kernsatz markiert. Chips mit Zahlen filtern nach Gruppe, ein Knopf zeigt mehr, ohne die Seite zu verlassen.

Treibende SpaltenNeu in 1.2

Drei Spalten treiben in eigenem Tempo nach oben und blenden an den Rändern aus. Sie halten unter dem Zeiger, bei Fokus und per Knopf an und stehen bei reduzierter Bewegung still.

Bewertung mit VerteilungNeu in 1.2

Der Durchschnitt groß mit Sternen und den Anteilen je Stern als Balken, die hineinwachsen, daneben bewertete Zitate, sortierbar nach neu oder bester Bewertung.

Leuchten beim ScrollenNeu in 1.2

Große Zitate wechseln links und rechts, der Kernsatz leuchtet auf, sobald eines die Bildschirmmitte erreicht. Dazwischen stehen Zahlen wie Belege.

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
Bringt mit
utilskitbuttoniconsection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 13 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle SocialProofWallProps aus components/ui/social-proof-wall.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    SocialProofWallVariant

    masonry-filtercolumns-driftrating-wallhighlight-scroll

  • eyebrowoptional
    React.ReactNode
  • titlePflicht
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • itemsPflicht
    ProofQuote[]
  • initialoptional
    number

    masonry-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
allEnglisch AllDeutsch Alle
filterEnglisch Filter by groupDeutsch Nach Gruppe filtern
moreEnglisch Show {n} moreDeutsch {n} weitere zeigen
pauseEnglisch Pause the wallDeutsch Wand anhalten
playEnglisch Play the wallDeutsch Wand abspielen
outOfEnglisch out of 5Deutsch von 5
reviewsEnglisch {n} reviewsDeutsch {n} Bewertungen
starsEnglisch {n} starsDeutsch {n} Sterne
sortEnglisch SortDeutsch Sortieren
newestEnglisch NewestDeutsch Neueste
bestEnglisch Highest ratedDeutsch Beste Bewertung
ratedEnglisch Rated {n} out of 5Deutsch Mit {n} von 5 bewertet
shownEnglisch {n} of {t} voices shownDeutsch {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
components/ui/social-proof-wall.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert