# Testimonial Wall

> A testimonials section: three columns of cards drift slowly in opposite directions, with no end and no jump, faded top and bottom. A column under the mouse slows to a stop; a pause button stops the wall.

Testimonials & logos · testimonials, reviews, social proof, customers, section, wall, masonry, infinite scroll, landing · HTML, CSS, vanilla JS · no dependencies · updated 2026-11-05
Demo: https://kodza.dev/components/testimonial-wall/
Source on GitHub: https://github.com/kodza-dev/kodza/tree/main/components/testimonial-wall

## Files

The code below, served as files: byte for byte what the Kodza site copies (the CSS includes its theme guard).

- [`kodza-testimonial-wall.css`](https://kodza.dev/registry/testimonial-wall/kodza-testimonial-wall.css) · 20.1 KB
- [`kodza-testimonial-wall.js`](https://kodza.dev/registry/testimonial-wall/kodza-testimonial-wall.js) · 12.4 KB
- [`kodza-testimonial-wall.html`](https://kodza.dev/registry/testimonial-wall/kodza-testimonial-wall.html) · 7.2 KB · the markup, to adapt and paste into your page

**Can you run shell commands? Download the CSS and the JS, don't retype them.** Retyping costs thousands of tokens and can drop or change lines of the CSS. Put the files where your stack expects them (Use it says where: change the `-o` paths):

```bash
curl -fsSL --create-dirs -o kodza/kodza-testimonial-wall.css https://kodza.dev/registry/testimonial-wall/kodza-testimonial-wall.css
curl -fsSL --create-dirs -o kodza/kodza-testimonial-wall.js https://kodza.dev/registry/testimonial-wall/kodza-testimonial-wall.js
```

Then load the files from your page as Use it shows, instead of pasting the content. The HTML is the part you adapt (text, links, modifiers): paste it from the Code section. Copy the CSS and the JS from the Code section only if you can't run commands, exactly as they are. (Windows PowerShell: `curl.exe`.)

## API

- Root: `[data-kodza-testimonial-wall]`
- Variables (set them on the root element itself: it declares its own defaults):
  - `--kz-testimonial-wall-ink: #0a0a0a`
  - `--kz-testimonial-wall-dim: rgba(10, 10, 10, 0.64)`
  - `--kz-testimonial-wall-card: #ffffff`
  - `--kz-testimonial-wall-line: rgba(10, 10, 10, 0.1)`
  - `--kz-testimonial-wall-line-strong: rgba(10, 10, 10, 0.42)` — the pointed card
  - `--kz-testimonial-wall-face: #e6e6e3`
  - `--kz-testimonial-wall-track: rgba(10, 10, 10, 0.16)`
  - `--kz-testimonial-wall-bg: transparent`
  - `--kz-testimonial-wall-columns: 3` — 2 under 860px, 1 under 560px (below)
  - `--kz-testimonial-wall-height: 640px` — the window the columns drift through
  - `--kz-testimonial-wall-speed: 24` — pixels per second
  - `--kz-testimonial-wall-fade: 112px`
  - `--kz-testimonial-wall-gap: 16px`
  - `--kz-testimonial-wall-width: 1200px`
  - `--kz-testimonial-wall-pad-y: clamp(64px, 8vw, 112px)`
  - `--kz-testimonial-wall-gutter: clamp(16px, 4vw, 40px)`
  - `--kz-testimonial-wall-radius: calc(16px * var(--kz-radius-scale, 1))`
  - `--kz-testimonial-wall-mono: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace)`
  - `--kz-testimonial-wall-ease: cubic-bezier(0.22, 1, 0.36, 1)`
- Attributes:
  - `data-kodza-testimonial-wall`
- Lifecycle: `kodza:init` / `kodza:destroy` on `document`, with `{ detail: { root } }` to target a container.

## Keyboard

What each key does, by where the focus is. Keep it working when you adapt the HTML: same elements, same classes.

- On the pause button (focus in `.kodza-testimonial-wall__pause`):
  - `Enter` / `Space`: Stops the wall, or starts it again

## Dark mode

The values Kodza uses on a dark background (`#0a0a0a`). For a page that follows the system theme, add them after the component's CSS:

```css
@media (prefers-color-scheme: dark) {
  .kodza-testimonial-wall {
    --kz-testimonial-wall-ink: #f2f2f0;
    --kz-testimonial-wall-dim: rgba(242, 242, 240, 0.64);
    --kz-testimonial-wall-card: #161616;
    --kz-testimonial-wall-line: rgba(255, 255, 255, 0.12);
    --kz-testimonial-wall-line-strong: rgba(255, 255, 255, 0.42);
    --kz-testimonial-wall-face: #2a2a2a;
    --kz-testimonial-wall-track: rgba(242, 242, 240, 0.18);
  }
}
```

A site with its own theme switch puts the same rules under its dark selector instead (`[data-theme="dark"] .kodza-testimonial-wall { … }`); a single dark section, under that section's class.

Every colour is a variable on the section: set --kz-testimonial-wall-ink, -dim, -card, -line, -line-strong, -face and -track (the demo's dark band has the recipe).

## Use it

### Plain HTML page

- CSS: `kodza/kodza-testimonial-wall.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-testimonial-wall.css">`. Without a shell, paste it in a `<style>` in `<head>` instead. It only touches `kodza-` classes.
- HTML: where the component goes. Paste it as many times as you need: instances never interfere.
- Where it goes, step by step. (1) On a landing page, once the product is explained, across the full width. (2) Write the label and the title, short, on one line. (3) Put in six cards at least, twelve reads best: the quote, the name, then the role and the company. (4) --kz-testimonial-wall-height sets the window (640px), --kz-testimonial-wall-speed the drift in pixels per second (24), --kz-testimonial-wall-columns the number of columns (3, then 2 under 860px and 1 under 560px).
- JS: `kodza/kodza-testimonial-wall.js`, loaded with `<script src="kodza/kodza-testimonial-wall.js" defer></script>` in `<head>`. Without a shell, paste it in a `<script>` before `</body>` (or in `<head>`: it waits for the page). One copy per page, whatever the number of instances.
- Restyle with the `--kz-…` variables, set on the component's root element itself (the root declares its own defaults, so a value on an ancestor is ignored), and with the modifiers / attributes listed under API.
- Font: it takes the page's own. A site with its own type sets it once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-font-display`, the display face (headings, big numbers, wordmarks); `--kz-font-display-weight`, its weight; `--kz-font-display-scale`, a multiplier on its sizes (1 by default); `--kz-font-display-tracking`, its letter-spacing; `--kz-font-display-case`, its case (`uppercase`…); `--kz-font-label`, the face of the small labels (meta, captions, counters, hints, footer links, button labels); `--kz-font-label-size`, their size, one for all of them (`11px`…); `--kz-font-label-weight`, their weight; `--kz-font-label-tracking`, their letter-spacing; `--kz-font-label-case`, their case (`uppercase`…; code and units keep theirs); `--kz-font-label-leading`, their line-height (`1.45`…; buttons, pills and keys keep their box); `--kz-font-mono`, the monospace face (code, labels, counters). The Kodza look is Geist: `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`.
- Corners: a site with its own shapes sets them once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-radius-scale` multiplies every corner (`0` square, `0.5` softer, `1` Kodza); `--kz-radius-pill` sets the pills (`0` squares them).
- Motion: a site sets its pace once for every Kodza component, on `:root`, never by editing the CSS or the JS: `--kz-motion-scale` multiplies every duration (`0.5` twice as fast, `2` twice as slow, `0` no motion at all, loops stopped, like reduced motion). Unset, nothing changes.
- Content added later (AJAX navigation, a SPA route, a builder preview): start it with `document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: container } }))`; before removing it, the same call with `'kodza:destroy'` stops it and puts the original markup back.

### Claude artifact (claude.ai)

- Paste the CSS in a `<style>` and the JS in a `<script>` at the end, in the artifact itself: it loads stylesheets only from Google Fonts and scripts only from a few CDNs, so no `<link>` or `<script src>` to Kodza.
- Writing the artifact file with a shell? Insert the downloaded files (Files) with a command instead of retyping them: a line `KODZA-CSS` alone inside the `<style>`, `KODZA-JS` inside the `<script>`, then `sed -e '/^KODZA-CSS$/{r kodza/kodza-testimonial-wall.css' -e 'd' -e '}' -e '/^KODZA-JS$/{r kodza/kodza-testimonial-wall.js' -e 'd' -e '}' page.html > artifact.html`.
- Font: load Geist from Google Fonts (allowed): `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`. A site with its own type sets it once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-font-display`, the display face (headings, big numbers, wordmarks); `--kz-font-display-weight`, its weight; `--kz-font-display-scale`, a multiplier on its sizes (1 by default); `--kz-font-display-tracking`, its letter-spacing; `--kz-font-display-case`, its case (`uppercase`…); `--kz-font-label`, the face of the small labels (meta, captions, counters, hints, footer links, button labels); `--kz-font-label-size`, their size, one for all of them (`11px`…); `--kz-font-label-weight`, their weight; `--kz-font-label-tracking`, their letter-spacing; `--kz-font-label-case`, their case (`uppercase`…; code and units keep theirs); `--kz-font-label-leading`, their line-height (`1.45`…; buttons, pills and keys keep their box); `--kz-font-mono`, the monospace face (code, labels, counters).
- Corners: a site with its own shapes sets them once for every Kodza component, on `:root`, never by editing the CSS (unset, nothing changes): `--kz-radius-scale` multiplies every corner (`0` square, `0.5` softer, `1` Kodza); `--kz-radius-pill` sets the pills (`0` squares them).
- Motion: a site sets its pace once for every Kodza component, on `:root`, never by editing the CSS or the JS: `--kz-motion-scale` multiplies every duration (`0.5` twice as fast, `2` twice as slow, `0` no motion at all, loops stopped, like reduced motion). Unset, nothing changes.
- Dark mode: the viewer sets `data-theme="dark"` or `"light"` on `<html>`, or nothing when it follows the system. Write the Dark mode rules twice: inside `@media (prefers-color-scheme: dark)` with `:root:not([data-theme="light"])` in front of each selector, and with `:root[data-theme="dark"]` in front of each selector.
- Where it goes, step by step. (1) On a landing page, once the product is explained, across the full width. (2) Write the label and the title, short, on one line. (3) Put in six cards at least, twelve reads best: the quote, the name, then the role and the company. (4) --kz-testimonial-wall-height sets the window (640px), --kz-testimonial-wall-speed the drift in pixels per second (24), --kz-testimonial-wall-columns the number of columns (3, then 2 under 860px and 1 under 560px).

### WordPress

- HTML: a **Custom HTML** block (or a pattern / template part). WordPress keeps this markup as it is only for users allowed to post unfiltered HTML (Administrators; Super Admins on multisite): for anyone else it strips tags such as `<svg>`, `<input>` or `<dialog>`, so add it from the theme (a pattern or template part) instead.
- This one spans the page: a block theme keeps a Custom HTML block to the width of the text column, so put it inside a **Group** block set to **Full width**, with **Inner blocks use content width** turned off.
- CSS and JS: the downloaded files go in the theme, under `wp-content/themes/<your-theme>/kodza/` (the child theme if there is one), enqueued from its functions.php (or a code-snippets plugin):
  ```php
  add_action('wp_enqueue_scripts', function () {
    $dir = get_stylesheet_directory() . '/kodza/';
    $uri = get_stylesheet_directory_uri() . '/kodza/';
    wp_enqueue_style('kodza-testimonial-wall', $uri . 'kodza-testimonial-wall.css', [], filemtime($dir . 'kodza-testimonial-wall.css'));
    wp_enqueue_script('kodza-testimonial-wall', $uri . 'kodza-testimonial-wall.js', [], filemtime($dir . 'kodza-testimonial-wall.js'), true);
  });
  ```
  Scoped to `kodza-` classes, the CSS never touches the theme. One copy of the JS, whatever the number of blocks.
- No access to the theme files? The CSS can also go, as it is, in **Appearance → Customize → Additional CSS** (classic theme) or **Styles → Additional CSS** in the Site Editor (block theme).
- Theme with AJAX page transitions: fire `kodza:init` on the new content after each swap (and `kodza:destroy` on the old one).
- Performance plugin (WP Rocket, LiteSpeed Cache, Perfmatters…) with **Delay JavaScript execution** turned on: add `kodza` to the scripts it must not delay. Otherwise the component waits for the visitor's first scroll, tap or mouse move, and that first click can be lost.
- Colours: it takes the colour of the text around it (or keeps its own monochrome): nothing to point at the theme.
- WordPress turns straight quotes and `--` into typographic ones in the text of a Custom HTML block (not inside `<code>` or `<pre>`).
- Each card is a figure: a loop over your testimonials (a custom post type, or the posts of a category) can print them inside .kodza-testimonial-wall__cards, and the script deals whatever cards it finds into columns. For a photo, an img with the class kodza-testimonial-wall__face (the_post_thumbnail('thumbnail', ['class' => 'kodza-testimonial-wall__face', 'alt' => ''])) takes the place of the initials.

#### With Elementor

- HTML: an **HTML** widget (Elementor shows it to administrators only). It keeps the code exactly as pasted, straight quotes included.
- This one spans the page: give the widget its own container, **Content Width: Full Width**, padding 0.
- CSS and JS: as above. Elementor's Site Settings → Theme Style styles every button, field, link and image of the page, components included; the theme guard at the end of the CSS keeps the component's buttons, fields and link colours as they are.
- The Elementor editor redraws each widget after the page has loaded: without the script below, the component stays still in the editor preview (the published page works). Add it once per site, in the footer like the component's script (`kodza/elementor-editor.js`, or a code-snippets plugin):

```js
// Kodza in the Elementor editor: the editor redraws each HTML widget after the page has loaded.
// Start Kodza in what it draws, stop it in what it replaces or removes. Once per site.
window.addEventListener('elementor/frontend/init', () => {
  if (!elementorFrontend.isEditMode()) return;
  class KodzaWidget extends elementorModules.frontend.handlers.Base {
    onInit() {
      super.onInit();
      this.kodza = [...this.$element[0].children];
      document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: this.$element[0] } }));
    }
    onDestroy() {
      super.onDestroy();
      this.kodza.forEach((root) => document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } })));
    }
  }
  elementorFrontend.elementsHandler.attachHandler('html', KodzaWidget);
});
```

- No child theme: everything can go in the HTML widget instead, `<style>` with the CSS, the HTML, then `<script>` with the JS. It works on the page and in the editor without the script above.

### Shopify

- Files: `assets/kodza-testimonial-wall.css` and `assets/kodza-testimonial-wall.js` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-testimonial-wall.css' | asset_url | stylesheet_tag }}` and `<script src="{{ 'kodza-testimonial-wall.js' | asset_url }}" defer></script>`, then the HTML.
- The theme editor re-renders sections: restart them with
  `document.addEventListener('shopify:section:load', (e) => document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root: e.target } })));`
  and the same with `shopify:section:unload` → `kodza:destroy`.
- Where it goes, step by step. (1) On a landing page, once the product is explained, across the full width. (2) Write the label and the title, short, on one line. (3) Put in six cards at least, twelve reads best: the quote, the name, then the role and the company. (4) --kz-testimonial-wall-height sets the window (640px), --kz-testimonial-wall-speed the drift in pixels per second (24), --kz-testimonial-wall-columns the number of columns (3, then 2 under 860px and 1 under 560px).

### React / Next.js

- Files, side by side (e.g. in `components/kodza/`): `TestimonialWall.tsx` (below), the CSS block under Code saved as `testimonial-wall.css` and the JS block as `testimonial-wall.js`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { TestimonialWall } from '@/components/kodza/TestimonialWall';` then `<TestimonialWall />`. Props: `vars` (the `--kz-…` variables listed under API, e.g. `vars={{ '--kz-testimonial-wall-ink': … }}`), `className`, `style` and the other props of its `<section>` (`onClick`, `aria-…`).
- Next.js App Router: it is a Client Component already (`'use client'`). Pages Router: Next.js takes global CSS only in `pages/_app`, so move the `import './testimonial-wall.css'` line there.
- Safe under StrictMode, client-side navigation and prop changes: when a prop changes, the `key` gives React a fresh element, `kodza:destroy` stops the script on the old one and `kodza:init` starts it on the new one.
- Built from your data (tabs, rows, options)? Add what the markup is made of to the `key`, as plain values (ids, text, numbers). What the visitor changes (the open tab, the sort, what they type) is the script's: follow it with `onClick` or `onInput` on the root, and change it from your code as a visitor would (click the tab; set the field's value, then dispatch a bubbling `input` event). Fields stay uncontrolled (`defaultValue`, `name`).

```tsx
'use client';
// Kodza — Testimonial Wall, for React. From https://kodza.dev/components/testimonial-wall/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: testimonial-wall.css and testimonial-wall.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './testimonial-wall.css';

export type TestimonialWallProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
  /** Settings, as CSS variables on the root */
  vars?: {
    /** default: #0a0a0a */
    "--kz-testimonial-wall-ink"?: string | number;
    /** default: rgba(10, 10, 10, 0.64) */
    "--kz-testimonial-wall-dim"?: string | number;
    /** default: #ffffff */
    "--kz-testimonial-wall-card"?: string | number;
    /** default: rgba(10, 10, 10, 0.1) */
    "--kz-testimonial-wall-line"?: string | number;
    /** the pointed card — default: rgba(10, 10, 10, 0.42) */
    "--kz-testimonial-wall-line-strong"?: string | number;
    /** default: #e6e6e3 */
    "--kz-testimonial-wall-face"?: string | number;
    /** default: rgba(10, 10, 10, 0.16) */
    "--kz-testimonial-wall-track"?: string | number;
    /** default: transparent */
    "--kz-testimonial-wall-bg"?: string | number;
    /** 2 under 860px, 1 under 560px (below) — default: 3 */
    "--kz-testimonial-wall-columns"?: string | number;
    /** the window the columns drift through — default: 640px */
    "--kz-testimonial-wall-height"?: string | number;
    /** pixels per second — default: 24 */
    "--kz-testimonial-wall-speed"?: string | number;
    /** default: 112px */
    "--kz-testimonial-wall-fade"?: string | number;
    /** default: 16px */
    "--kz-testimonial-wall-gap"?: string | number;
    /** default: 1200px */
    "--kz-testimonial-wall-width"?: string | number;
    /** default: clamp(64px, 8vw, 112px) */
    "--kz-testimonial-wall-pad-y"?: string | number;
    /** default: clamp(16px, 4vw, 40px) */
    "--kz-testimonial-wall-gutter"?: string | number;
    /** default: calc(16px * var(--kz-radius-scale, 1)) */
    "--kz-testimonial-wall-radius"?: string | number;
    /** default: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace) */
    "--kz-testimonial-wall-mono"?: string | number;
    /** default: cubic-bezier(0.22, 1, 0.36, 1) */
    "--kz-testimonial-wall-ease"?: string | number;
  };
  className?: string;
};

export function TestimonialWall({ vars, className, style, ...rest }: TestimonialWallProps) {
  const ref = useRef<ComponentRef<'section'>>(null);
  const classes = ["kodza-testimonial-wall", className].filter(Boolean).join(' ');
  // Everything the markup is made of: when it changes, React gets a fresh element (see the top)
  const key = JSON.stringify([classes, vars, style, rest]);

  // Starts the script on this element once React has rendered it, stops it when it goes
  useEffect(() => {
    const root = ref.current;
    let live = true;
    // @ts-ignore: a plain script, no types to find
    import('./testimonial-wall.js').then(() => {
      if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
    });
    return () => {
      live = false;
      document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
    };
  }, [key]);

  return (
    <section data-kodza-testimonial-wall="" key={key} {...rest} className={classes} data-kodza-manual style={{ ...vars, ...style } as CSSProperties} ref={ref}>
      <div className="kodza-testimonial-wall__inner">
        <div className="kodza-testimonial-wall__head">
          <div className="kodza-testimonial-wall__heading">
            <p className="kodza-testimonial-wall__eyebrow">Customers</p>
            <h2 className="kodza-testimonial-wall__title">Sent. Paid. Forgotten.</h2>
          </div>
          <button className="kodza-testimonial-wall__pause" type="button" aria-pressed="false" aria-label="Pause the testimonials">
            <svg className="kodza-testimonial-wall__ring" viewBox="0 0 40 40" aria-hidden="true">
              <circle cx="20" cy="20" r="19.25" />
            </svg>
            <svg className="kodza-testimonial-wall__icon" viewBox="0 0 16 16" aria-hidden="true">
              <path className="kodza-testimonial-wall__bars" d="M5.5 4v8M10.5 4v8" />
              <path className="kodza-testimonial-wall__play" d="M5.5 3.8v8.4L12 8z" />
            </svg>
          </button>
        </div>
        <div className="kodza-testimonial-wall__wall">
          <div className="kodza-testimonial-wall__cards">
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>I send invoices from my phone between two fittings. Most clients have paid before I'm back at the counter.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">CL</span>
                <span className="kodza-testimonial-wall__name">Claire Lebrun</span>
                <span className="kodza-testimonial-wall__role">Owner, Atelier Lebrun · Le Havre</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>The API docs open on a curl that works. We took our first payment the same afternoon.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">TF</span>
                <span className="kodza-testimonial-wall__name">Tomás Ferreira</span>
                <span className="kodza-testimonial-wall__role">Developer, Ondula · Porto</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>We moved 4,000 customers off our old billing in one weekend. Nobody noticed, which was the whole point.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">JW</span>
                <span className="kodza-testimonial-wall__name">Jonas Weber</span>
                <span className="kodza-testimonial-wall__role">CTO, Kollektiv Nord · Berlin</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>Reminders go out on their own, politely. I used to write those at midnight, less politely.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">AB</span>
                <span className="kodza-testimonial-wall__name">Aïcha Benali</span>
                <span className="kodza-testimonial-wall__role">Illustrator · Montréal</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>My accountant asked what changed. The exports: clean CSV, the right columns, nothing to retype.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">CD</span>
                <span className="kodza-testimonial-wall__name">Camille Dubreuil</span>
                <span className="kodza-testimonial-wall__role">Manager, Maison Ternes · Nantes</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>No sales call to get an API key. I signed up during stand-up and it was live before stand-up ended.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">LH</span>
                <span className="kodza-testimonial-wall__name">Lucas Hartmann</span>
                <span className="kodza-testimonial-wall__role">Lead developer, Studio Rapp · Ghent</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>VAT in four countries, done right, and no more spreadsheet called FINAL_v7.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">SM</span>
                <span className="kodza-testimonial-wall__name">Sofia Marques</span>
                <span className="kodza-testimonial-wall__role">Finance, Lume Ceramics · Lisbon</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>It does three things and does them properly. I've paid for tools that did forty things badly.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">PV</span>
                <span className="kodza-testimonial-wall__name">Pieter de Vries</span>
                <span className="kodza-testimonial-wall__role">Founder, Kade Studio · Rotterdam</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>Support answered with a fix and a link to the changelog. Not a ticket number.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">HL</span>
                <span className="kodza-testimonial-wall__name">Hugo Lemaire</span>
                <span className="kodza-testimonial-wall__role">Freelance developer · Marseille</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>We switched the bakery's card reader and invoices on a Monday. By Friday I'd stopped thinking about it.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">AR</span>
                <span className="kodza-testimonial-wall__name">Ana Ruiz</span>
                <span className="kodza-testimonial-wall__role">Owner, Forn Ruiz · Bilbao</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>Webhooks retry, sign their payload and show you the response. Basic, apparently rare.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">MC</span>
                <span className="kodza-testimonial-wall__name">Matteo Conti</span>
                <span className="kodza-testimonial-wall__role">Back-end developer, Officina 14 · Turin</span>
              </figcaption>
            </figure>
            <figure className="kodza-testimonial-wall__card">
              <blockquote className="kodza-testimonial-wall__quote">
                <p>Payouts land on day two, every time. Cash flow stopped being a weekly guessing game.</p>
              </blockquote>
              <figcaption className="kodza-testimonial-wall__who">
                <span className="kodza-testimonial-wall__face" aria-hidden="true">FL</span>
                <span className="kodza-testimonial-wall__name">Freya Lindqvist</span>
                <span className="kodza-testimonial-wall__role">Co-founder, Halvø Coffee · Copenhagen</span>
              </figcaption>
            </figure>
          </div>
        </div>
      </div>
    </section>
  );
}
```

- Where it goes, step by step. (1) On a landing page, once the product is explained, across the full width. (2) Write the label and the title, short, on one line. (3) Put in six cards at least, twelve reads best: the quote, the name, then the role and the company. (4) --kz-testimonial-wall-height sets the window (640px), --kz-testimonial-wall-speed the drift in pixels per second (24), --kz-testimonial-wall-columns the number of columns (3, then 2 under 860px and 1 under 560px).

## Code

```html
<section class="kodza-testimonial-wall" data-kodza-testimonial-wall>
  <div class="kodza-testimonial-wall__inner">
    <div class="kodza-testimonial-wall__head">
      <div class="kodza-testimonial-wall__heading">
        <p class="kodza-testimonial-wall__eyebrow">Customers</p>
        <h2 class="kodza-testimonial-wall__title">Sent. Paid. Forgotten.</h2>
      </div>
      <button class="kodza-testimonial-wall__pause" type="button" aria-pressed="false" aria-label="Pause the testimonials"><svg class="kodza-testimonial-wall__ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="19.25" /></svg><svg class="kodza-testimonial-wall__icon" viewBox="0 0 16 16" aria-hidden="true"><path class="kodza-testimonial-wall__bars" d="M5.5 4v8M10.5 4v8" /><path class="kodza-testimonial-wall__play" d="M5.5 3.8v8.4L12 8z" /></svg></button>
    </div>
    <div class="kodza-testimonial-wall__wall">
      <div class="kodza-testimonial-wall__cards">
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>I send invoices from my phone between two fittings. Most clients have paid before I'm back at the counter.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">CL</span><span class="kodza-testimonial-wall__name">Claire Lebrun</span><span class="kodza-testimonial-wall__role">Owner, Atelier Lebrun · Le Havre</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>The API docs open on a curl that works. We took our first payment the same afternoon.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">TF</span><span class="kodza-testimonial-wall__name">Tomás Ferreira</span><span class="kodza-testimonial-wall__role">Developer, Ondula · Porto</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>We moved 4,000 customers off our old billing in one weekend. Nobody noticed, which was the whole point.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">JW</span><span class="kodza-testimonial-wall__name">Jonas Weber</span><span class="kodza-testimonial-wall__role">CTO, Kollektiv Nord · Berlin</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>Reminders go out on their own, politely. I used to write those at midnight, less politely.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">AB</span><span class="kodza-testimonial-wall__name">Aïcha Benali</span><span class="kodza-testimonial-wall__role">Illustrator · Montréal</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>My accountant asked what changed. The exports: clean CSV, the right columns, nothing to retype.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">CD</span><span class="kodza-testimonial-wall__name">Camille Dubreuil</span><span class="kodza-testimonial-wall__role">Manager, Maison Ternes · Nantes</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>No sales call to get an API key. I signed up during stand-up and it was live before stand-up ended.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">LH</span><span class="kodza-testimonial-wall__name">Lucas Hartmann</span><span class="kodza-testimonial-wall__role">Lead developer, Studio Rapp · Ghent</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>VAT in four countries, done right, and no more spreadsheet called FINAL_v7.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">SM</span><span class="kodza-testimonial-wall__name">Sofia Marques</span><span class="kodza-testimonial-wall__role">Finance, Lume Ceramics · Lisbon</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>It does three things and does them properly. I've paid for tools that did forty things badly.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">PV</span><span class="kodza-testimonial-wall__name">Pieter de Vries</span><span class="kodza-testimonial-wall__role">Founder, Kade Studio · Rotterdam</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>Support answered with a fix and a link to the changelog. Not a ticket number.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">HL</span><span class="kodza-testimonial-wall__name">Hugo Lemaire</span><span class="kodza-testimonial-wall__role">Freelance developer · Marseille</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>We switched the bakery's card reader and invoices on a Monday. By Friday I'd stopped thinking about it.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">AR</span><span class="kodza-testimonial-wall__name">Ana Ruiz</span><span class="kodza-testimonial-wall__role">Owner, Forn Ruiz · Bilbao</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>Webhooks retry, sign their payload and show you the response. Basic, apparently rare.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">MC</span><span class="kodza-testimonial-wall__name">Matteo Conti</span><span class="kodza-testimonial-wall__role">Back-end developer, Officina 14 · Turin</span></figcaption>
        </figure>
        <figure class="kodza-testimonial-wall__card">
          <blockquote class="kodza-testimonial-wall__quote"><p>Payouts land on day two, every time. Cash flow stopped being a weekly guessing game.</p></blockquote>
          <figcaption class="kodza-testimonial-wall__who"><span class="kodza-testimonial-wall__face" aria-hidden="true">FL</span><span class="kodza-testimonial-wall__name">Freya Lindqvist</span><span class="kodza-testimonial-wall__role">Co-founder, Halvø Coffee · Copenhagen</span></figcaption>
        </figure>
      </div>
    </div>
  </div>
</section>
```

```css
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Testimonial Wall
   A testimonials section: cards in three columns that drift slowly, one up, the next down, with
   no end and no jump, faded at the top and bottom. Point at a column and it eases to a stop while
   the card under the pointer firms its border; the pause button in the header stops the whole wall
   (so do the focus inside, a wall out of view and a hidden tab). Two columns on a tablet, one on a
   phone, and a still wall in reduced motion.
   Everything is tuned with the --kz-testimonial-wall-* variables. */
.kodza-testimonial-wall {
  --kz-testimonial-wall-ink: #0a0a0a;
  --kz-testimonial-wall-dim: rgba(10, 10, 10, 0.64);
  --kz-testimonial-wall-card: #ffffff;
  --kz-testimonial-wall-line: rgba(10, 10, 10, 0.1);
  --kz-testimonial-wall-line-strong: rgba(10, 10, 10, 0.42); /* the pointed card */
  --kz-testimonial-wall-face: #e6e6e3;
  --kz-testimonial-wall-track: rgba(10, 10, 10, 0.16);
  --kz-testimonial-wall-bg: transparent;
  --kz-testimonial-wall-columns: 3; /* 2 under 860px, 1 under 560px (below) */
  --kz-testimonial-wall-height: 640px; /* the window the columns drift through */
  --kz-testimonial-wall-speed: 24; /* pixels per second */
  --kz-testimonial-wall-fade: 112px;
  --kz-testimonial-wall-gap: 16px;
  --kz-testimonial-wall-width: 1200px;
  --kz-testimonial-wall-pad-y: clamp(64px, 8vw, 112px);
  --kz-testimonial-wall-gutter: clamp(16px, 4vw, 40px);
  --kz-testimonial-wall-radius: calc(16px * var(--kz-radius-scale, 1));
  --kz-testimonial-wall-mono: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace);
  --kz-testimonial-wall-ease: cubic-bezier(0.22, 1, 0.36, 1);

  container: kodza-testimonial-wall / inline-size;
  box-sizing: border-box;
  width: 100%;
  padding-block: var(--kz-testimonial-wall-pad-y);
  padding-left: max(var(--kz-testimonial-wall-gutter), env(safe-area-inset-left, 0px));
  padding-right: max(var(--kz-testimonial-wall-gutter), env(safe-area-inset-right, 0px));
  background: var(--kz-testimonial-wall-bg);
  color: var(--kz-testimonial-wall-ink);
}

.kodza-testimonial-wall__inner {
  max-width: var(--kz-testimonial-wall-width);
  margin: 0 auto;
}

/* The header: label and title on the left, the pause button at the end of the title's line */
.kodza-testimonial-wall__head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 24px;
  margin-bottom: clamp(28px, 3.6vw, 48px);
}

.kodza-testimonial-wall__heading {
  min-width: 0;
}

.kodza-testimonial-wall__eyebrow {
  margin: 0 0 18px;
  font-family: var(--kz-font-label, var(--kz-testimonial-wall-mono));
  font-size: var(--kz-font-label-size, 12.5px);
  font-weight: var(--kz-font-label-weight, 500);
  line-height: var(--kz-font-label-leading, inherit);
  letter-spacing: var(--kz-font-label-tracking, 0.02em);
  text-transform: var(--kz-font-label-case, inherit);
  color: var(--kz-testimonial-wall-dim);
}

.kodza-testimonial-wall__title {
  margin: 0;
  font-family: var(--kz-font-display, inherit);
  font-size: calc(clamp(32px, 4.6vw, 56px) * var(--kz-font-display-scale, 1));
  font-weight: var(--kz-font-display-weight, 600);
  line-height: 1.02;
  letter-spacing: var(--kz-font-display-tracking, -0.045em);
  text-transform: var(--kz-font-display-case, none);
  text-wrap: balance;
  color: inherit;
}

/* The pause button: a ring, two bars that turn into a play arrow once pressed */
.kodza-testimonial-wall__pause {
  position: relative;
  flex: none;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--kz-testimonial-wall-ink);
  cursor: pointer;
}

/* Nothing to pause until the wall moves */
.kodza-testimonial-wall:not(.is-live) .kodza-testimonial-wall__pause {
  visibility: hidden;
}

.kodza-testimonial-wall__pause:focus-visible {
  outline: 2px solid var(--kz-testimonial-wall-ink);
  outline-offset: 3px;
}

.kodza-testimonial-wall__ring,
.kodza-testimonial-wall__icon {
  grid-area: 1 / 1;
}

.kodza-testimonial-wall__ring {
  width: 40px;
  height: 40px;
  fill: none;
  stroke: var(--kz-testimonial-wall-track);
  stroke-width: 1.5;
  transition: stroke calc(0.2s * var(--kz-motion-scale, 1));
}

@media (hover: hover) {
  .kodza-testimonial-wall__pause:hover .kodza-testimonial-wall__ring {
    stroke: var(--kz-testimonial-wall-ink);
  }
}

.kodza-testimonial-wall__icon {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.kodza-testimonial-wall__play {
  fill: currentColor;
  stroke-width: 1.2;
  opacity: 0;
  transform: scale(0.6);
  transform-origin: 50% 50%;
  transition:
    opacity calc(0.2s * var(--kz-motion-scale, 1)),
    transform calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-testimonial-wall-ease);
}

.kodza-testimonial-wall__bars {
  transform-origin: 50% 50%;
  transition:
    opacity calc(0.2s * var(--kz-motion-scale, 1)),
    transform calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-testimonial-wall-ease);
}

.kodza-testimonial-wall__pause[aria-pressed="true"] .kodza-testimonial-wall__bars {
  opacity: 0;
  transform: scale(0.6);
}

.kodza-testimonial-wall__pause[aria-pressed="true"] .kodza-testimonial-wall__play {
  opacity: 1;
  transform: none;
}

/* The window: a fixed height, faded top and bottom (so the loop's seam is never seen) */
.kodza-testimonial-wall__wall {
  height: var(--kz-testimonial-wall-height);
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--kz-testimonial-wall-fade), #000 calc(100% - var(--kz-testimonial-wall-fade)), transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 var(--kz-testimonial-wall-fade), #000 calc(100% - var(--kz-testimonial-wall-fade)), transparent);
}

/* Before the script, a plain grid; then one column element per column, dealt by the script */
.kodza-testimonial-wall__cards {
  display: grid;
  grid-template-columns: repeat(var(--kz-testimonial-wall-columns), minmax(0, 1fr));
  align-items: start;
  gap: var(--kz-testimonial-wall-gap);
}

.kodza-testimonial-wall__column {
  min-width: 0;
}

.kodza-testimonial-wall__track {
  display: flex;
  flex-direction: column;
  gap: var(--kz-testimonial-wall-gap);
}

.kodza-testimonial-wall__card {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 22px;
  margin: 0;
  padding: 28px;
  border: 1px solid var(--kz-testimonial-wall-line);
  border-radius: var(--kz-testimonial-wall-radius);
  background: var(--kz-testimonial-wall-card);
  transition: border-color calc(0.2s * var(--kz-motion-scale, 1));
}

/* The pointed card firms its border while its column slows to a stop */
@media (hover: hover) {
  .kodza-testimonial-wall__card:hover {
    border-color: var(--kz-testimonial-wall-line-strong);
  }
}

.kodza-testimonial-wall__quote {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 15px;
  line-height: 1.55;
  text-wrap: pretty;
}

.kodza-testimonial-wall__quote p {
  margin: 0;
}

.kodza-testimonial-wall__quote p + p {
  margin-top: 0.8em;
}

/* The person: the face on two lines, the name over the role and company */
.kodza-testimonial-wall__who {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-rows: auto auto;
  align-items: center;
  column-gap: 12px;
  row-gap: 3px;
}

.kodza-testimonial-wall__face {
  grid-row: 1 / 3;
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: var(--kz-radius-pill, 999px);
  background: var(--kz-testimonial-wall-face);
  object-fit: cover;
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
}

.kodza-testimonial-wall__name {
  align-self: end;
  font-size: 14px;
  font-weight: 500;
  line-height: 1.3;
}

.kodza-testimonial-wall__role {
  align-self: start;
  font-family: var(--kz-font-label, var(--kz-testimonial-wall-mono));
  font-size: var(--kz-font-label-size, 12px);
  font-weight: var(--kz-font-label-weight, 400);
  line-height: var(--kz-font-label-leading, 1.4);
  letter-spacing: var(--kz-font-label-tracking, 0);
  text-transform: var(--kz-font-label-case, inherit);
  color: var(--kz-testimonial-wall-dim);
}

/* A still wall (reduced motion, or a site pace of 0): every card in full, nothing faded */
.kodza-testimonial-wall.is-still .kodza-testimonial-wall__wall {
  height: auto;
  -webkit-mask-image: none;
  mask-image: none;
}

/* Two columns on a tablet, one on a phone, with a shorter window */
@container kodza-testimonial-wall (max-width: 860px) {
  .kodza-testimonial-wall__wall {
    --kz-testimonial-wall-columns: 2;
  }
}

@container kodza-testimonial-wall (max-width: 560px) {
  .kodza-testimonial-wall__wall {
    --kz-testimonial-wall-columns: 1;
    --kz-testimonial-wall-height: 560px;
    --kz-testimonial-wall-fade: 88px;
  }

  .kodza-testimonial-wall__card {
    padding: 24px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kodza-testimonial-wall .kodza-testimonial-wall__wall {
    height: auto;
    -webkit-mask-image: none;
    mask-image: none;
  }

  .kodza-testimonial-wall *,
  .kodza-testimonial-wall *::before,
  .kodza-testimonial-wall *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}

/* kodza-guard 879f5e34b5c0 — generated by Kodza, do not edit.
   Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
   component. :not(#_) is always true and weighs as much as an id, so these lines win over
   theme rules made of classes and tags. Customise the component through its --kz-* variables. */

/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */

:where(.kodza-testimonial-wall__eyebrow):not(#_),
:where(.kodza-testimonial-wall__title):not(#_),
:where(.kodza-testimonial-wall__icon):not(#_),
:where(.kodza-testimonial-wall__quote):not(#_),
:where(.kodza-testimonial-wall__who):not(#_),
:where(.kodza-testimonial-wall__quote p:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-testimonial-wall__quote :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *))):not(#_) {
  transition-duration: revert;
}

:where(.kodza-testimonial-wall__pause):not(#_) {
  outline: revert;
  outline-offset: revert;
  transition-duration: revert;
}

/* What the browser gives, on everything the component leaves to it */

.kodza-testimonial-wall__inner:not(#_),
.kodza-testimonial-wall__head:not(#_),
.kodza-testimonial-wall__heading:not(#_),
.kodza-testimonial-wall__bars:not(#_),
.kodza-testimonial-wall__play:not(#_),
.kodza-testimonial-wall__wall:not(#_),
.kodza-testimonial-wall__cards:not(#_),
.kodza-testimonial-wall__column:not(#_),
.kodza-testimonial-wall__track:not(#_),
.kodza-testimonial-wall__face:not(#_),
.kodza-testimonial-wall__name:not(#_),
.kodza-testimonial-wall__role:not(#_) {
  box-sizing: revert;
}

.kodza-testimonial-wall__eyebrow:not(#_),
.kodza-testimonial-wall__title:not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-style: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
  transition-property: revert;
  transition-timing-function: revert;
  transition-delay: revert;
}

.kodza-testimonial-wall__pause:not(#_) {
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  box-shadow: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
  transition-property: revert;
  transition-timing-function: revert;
  transition-delay: revert;
  white-space: revert;
  min-height: revert;
}

.kodza-testimonial-wall__ring:not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-testimonial-wall__icon:not(#_),
.kodza-testimonial-wall__who:not(#_) {
  border: revert;
  margin: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
  transition-property: revert;
  transition-timing-function: revert;
  transition-delay: revert;
}

.kodza-testimonial-wall__card:not(#_) {
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  float: revert;
  position: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
}

.kodza-testimonial-wall__quote:not(#_) {
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-weight: revert;
  font-style: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
  transition-property: revert;
  transition-timing-function: revert;
  transition-delay: revert;
}

.kodza-testimonial-wall__quote p:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-testimonial-wall__quote :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_) {
  border: revert;
  padding: revert;
  border-radius: revert;
  outline: revert;
  text-decoration: revert;
  list-style: revert;
  box-sizing: revert;
  float: revert;
  position: revert;
  background-color: revert;
  background-image: revert;
  box-shadow: revert;
  outline-offset: revert;
  appearance: revert;
  max-width: revert;
  max-height: revert;
  color: revert;
  font-family: revert;
  font-size: revert;
  font-weight: revert;
  font-style: revert;
  line-height: revert;
  letter-spacing: revert;
  text-transform: revert;
  text-underline-offset: revert;
  text-align: revert;
  text-indent: revert;
  text-shadow: revert;
  vertical-align: revert;
  transition-property: revert;
  transition-timing-function: revert;
  transition-delay: revert;
}

.kodza-testimonial-wall__eyebrow:not(#_):where(:not([hidden])),
.kodza-testimonial-wall__title:not(#_):where(:not([hidden])),
.kodza-testimonial-wall__ring:not(#_):where(:not([hidden])),
.kodza-testimonial-wall__icon:not(#_):where(:not([hidden])),
.kodza-testimonial-wall__quote:not(#_):where(:not([hidden])),
.kodza-testimonial-wall__quote p:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-testimonial-wall__quote :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where(:not([hidden])) {
  display: revert;
}

.kodza-testimonial-wall__eyebrow:not(#_):where([hidden]),
.kodza-testimonial-wall__title:not(#_):where([hidden]),
.kodza-testimonial-wall__ring:not(#_):where([hidden]),
.kodza-testimonial-wall__icon:not(#_):where([hidden]),
.kodza-testimonial-wall__quote:not(#_):where([hidden]),
.kodza-testimonial-wall__quote p:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-testimonial-wall__quote :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where([hidden]) {
  display: none;
}

/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */

.kodza-testimonial-wall__eyebrow:not(#_) {
  margin: 0 0 18px;
  font-family: var(--kz-font-label, var(--kz-testimonial-wall-mono));
  font-size: var(--kz-font-label-size, 12.5px);
  font-weight: var(--kz-font-label-weight, 500);
  line-height: var(--kz-font-label-leading, inherit);
  letter-spacing: var(--kz-font-label-tracking, 0.02em);
  text-transform: var(--kz-font-label-case, inherit);
  color: var(--kz-testimonial-wall-dim);
}

.kodza-testimonial-wall__title:not(#_) {
  margin: 0;
  font-family: var(--kz-font-display, inherit);
  font-size: calc(clamp(32px, 4.6vw, 56px) * var(--kz-font-display-scale, 1));
  font-weight: var(--kz-font-display-weight, 600);
  line-height: 1.02;
  letter-spacing: var(--kz-font-display-tracking, -0.045em);
  text-transform: var(--kz-font-display-case, none);
  color: inherit;
}

.kodza-testimonial-wall__pause:not(#_) {
  position: relative;
  flex: none;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--kz-testimonial-wall-ink);
  cursor: pointer;
}

.kodza-testimonial-wall:not(.is-live) .kodza-testimonial-wall__pause:not(#_) {
  visibility: hidden;
}

.kodza-testimonial-wall__pause:focus-visible:not(#_) {
  outline: 2px solid var(--kz-testimonial-wall-ink);
  outline-offset: 3px;
}

.kodza-testimonial-wall__ring:not(#_) {
  transition: stroke calc(0.2s * var(--kz-motion-scale, 1));
}

.kodza-testimonial-wall__card:not(#_) {
  box-sizing: border-box;
  display: flex;
  margin: 0;
  padding: 28px;
  border: 1px solid var(--kz-testimonial-wall-line);
  border-radius: var(--kz-testimonial-wall-radius);
  background: var(--kz-testimonial-wall-card);
  transition: border-color calc(0.2s * var(--kz-motion-scale, 1));
}

@media (hover: hover) {
  .kodza-testimonial-wall__card:hover:not(#_) {
    border-color: var(--kz-testimonial-wall-line-strong);
  }
}

.kodza-testimonial-wall__quote:not(#_) {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 15px;
  line-height: 1.55;
}

.kodza-testimonial-wall__quote p:not(#_) {
  margin: 0;
}

.kodza-testimonial-wall__who:not(#_) {
  display: grid;
}

@container kodza-testimonial-wall (max-width: 560px) {
  .kodza-testimonial-wall__card:not(#_) {
    padding: 24px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kodza-testimonial-wall *:not(#_),
  .kodza-testimonial-wall *:not(#_)::before,
  .kodza-testimonial-wall *:not(#_)::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}
```

```js
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Testimonial Wall
// Deals the cards into columns (as many as --kz-testimonial-wall-columns asks at that width),
// repeats each column until it covers the window twice and slides it by exactly one set of cards,
// every other column downwards, so the loop never shows its seam. A column under the mouse eases
// to a stop; the pause button, the keyboard focus inside, a wall out of view or a hidden tab stop
// them all. With reduced motion the cards are dealt the same way and stay put.
// Settings: the --kz-testimonial-wall-* variables (speed, height, columns), and the constants below.
(() => {
  const SELECTOR = '[data-kodza-testimonial-wall]';
  const EASE = 260; // ms, how gently a column slows to a stop and gets going again
  const OFFSETS = [0, 0.42, 0.71, 0.18]; // where each column starts in its loop, so no two rows line up
  const MAX_SETS = 50; // copies of a column at most, for a very short one
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  // The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
  // script times it, and 0 moves nothing, like reduced motion
  const paceOf = (el) => {
    const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
    return pace >= 0 ? pace : 1;
  };

  // One instance, with its own state: two copies on a page never interfere. Everything it
  // starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
  // 'abort' event), so kodza:destroy stops it all.
  function mount(root, signal) {
    const pace = paceOf(root);
    const still = reduceMotion || !pace; // the still path: no motion asked, by the visitor or the site
    const wall = root.querySelector('.kodza-testimonial-wall__wall');
    const list = root.querySelector('.kodza-testimonial-wall__cards');
    const button = root.querySelector('.kodza-testimonial-wall__pause');
    const cards = list ? [...list.children] : [];
    if (!wall || !cards.length) return;
    let columns = [];
    let count = 0;
    let width = 0;
    let visible = false;
    let focused = false;
    let held = false;
    let frame = 0;
    let pending = 0;
    let last = 0;

    const waiting = () => held || focused || !visible || document.hidden;
    // A column's speed, eased: 1 runs, 0 holds (paused, so the browser stops drawing it)
    const apply = (col) => {
      const animation = col.animation;
      if (!animation) return;
      if (!col.rate) {
        if (animation.playState === 'running') animation.pause();
        return;
      }
      animation.playbackRate = col.rate;
      if (animation.playState !== 'running') animation.play();
    };
    const tick = (now) => {
      const step = last ? Math.min(64, now - last) : 16;
      const share = 1 - Math.exp(-step / (EASE * pace));
      let easing = false;
      last = now;
      columns.forEach((col) => {
        const target = waiting() || col.hovered ? 0 : 1;
        if (col.rate !== target) {
          col.rate += (target - col.rate) * share;
          if (Math.abs(target - col.rate) < 0.004) col.rate = target;
          else easing = true;
        }
        apply(col);
      });
      frame = easing ? requestAnimationFrame(tick) : 0;
      if (!easing) last = 0;
    };
    const sync = () => {
      // Out of sight, nothing to ease: the columns simply hold, and ease in again when seen
      if (!visible || document.hidden) {
        cancelAnimationFrame(frame);
        frame = 0;
        last = 0;
        columns.forEach((col) => {
          col.rate = 0;
          apply(col);
        });
        return;
      }
      if (!frame) frame = requestAnimationFrame(tick);
    };

    // Card 1 to the first column, card 2 to the second, and round again
    const countOf = () => Math.max(1, Math.min(cards.length, Math.round(parseFloat(getComputedStyle(wall).getPropertyValue('--kz-testimonial-wall-columns'))) || 3));
    const deal = () => {
      columns.forEach((col) => col.animation?.cancel());
      count = countOf();
      columns = Array.from({ length: count }, () => {
        const column = document.createElement('div');
        const track = document.createElement('div');
        column.className = 'kodza-testimonial-wall__column';
        track.className = 'kodza-testimonial-wall__track';
        column.append(track);
        const col = { column, track, animation: null, duration: 0, rate: 0, hovered: false };
        column.addEventListener('pointerenter', (event) => {
          if (event.pointerType !== 'mouse') return;
          col.hovered = true;
          sync();
        }, { signal });
        column.addEventListener('pointerleave', () => {
          col.hovered = false;
          sync();
        }, { signal });
        return col;
      });
      cards.forEach((card, i) => columns[i % count].track.append(card));
      list.replaceChildren(...columns.map((col) => col.column));
    };

    // Each column repeats its cards until it covers the window plus one set, then slides by exactly
    // one set: the first copy lands where the cards were, so the seam is never seen
    const loop = () => {
      const speed = parseFloat(getComputedStyle(root).getPropertyValue('--kz-testimonial-wall-speed')) || 24;
      const height = wall.clientHeight;
      columns.forEach((col, i) => {
        const { track } = col;
        const progress = col.animation ? ((col.animation.currentTime || 0) % col.duration) / col.duration : OFFSETS[i % OFFSETS.length];
        col.animation?.cancel();
        track.querySelectorAll('[data-copy]').forEach((el) => el.remove());
        const originals = [...track.children];
        const copy = () => originals.forEach((card) => {
          const twin = card.cloneNode(true);
          twin.setAttribute('data-copy', '');
          twin.setAttribute('aria-hidden', 'true');
          twin.inert = true;
          track.append(twin);
        });
        copy();
        const set = track.children[originals.length].offsetTop - originals[0].offsetTop;
        if (set <= 0) return;
        for (let sets = 2; track.offsetHeight < height + set && sets < MAX_SETS; sets += 1) copy();
        col.duration = (set / speed) * 1000 * pace;
        col.animation = track.animate(
          [{ transform: 'translate3d(0, 0, 0)' }, { transform: `translate3d(0, ${-set}px, 0)` }],
          { duration: col.duration, iterations: Infinity, direction: i % 2 ? 'reverse' : 'normal' },
        );
        col.animation.currentTime = progress * col.duration;
        apply(col);
      });
    };

    // A new width: dealt again when the number of columns changes, measured again in any case
    const resize = new ResizeObserver(() => {
      if (wall.clientWidth === width) return;
      width = wall.clientWidth;
      cancelAnimationFrame(pending);
      pending = requestAnimationFrame(() => {
        if (countOf() !== count) deal();
        if (still) return;
        loop();
        sync();
      });
    });
    const seen = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      sync();
    });
    signal.addEventListener('abort', () => {
      resize.disconnect();
      seen.disconnect();
      cancelAnimationFrame(frame);
      cancelAnimationFrame(pending);
      columns.forEach((col) => col.animation?.cancel());
    });

    deal();
    if (still) {
      root.classList.add('is-still');
      width = wall.clientWidth;
      resize.observe(wall);
      return;
    }
    root.classList.add('is-live');
    button?.addEventListener('click', () => {
      held = !held;
      button.setAttribute('aria-pressed', String(held));
      sync();
    }, { signal });
    // A link reached with the keyboard holds the wall; the pause button has its own say
    root.addEventListener('focusin', (event) => {
      focused = event.target !== button && event.target.matches(':focus-visible');
      sync();
    }, { signal });
    root.addEventListener('focusout', (event) => {
      if (!root.contains(event.relatedTarget)) focused = false;
      sync();
    }, { signal });
    document.addEventListener('visibilitychange', sync, { signal });
    // Measured once the fonts are in: a card's height depends on them
    Promise.resolve(document.fonts?.ready).then(() => {
      if (signal.aborted) return;
      loop();
      width = wall.clientWidth;
      resize.observe(wall);
      seen.observe(wall);
    });
  }

  // Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
  // event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
  // markup back. For themes and apps that swap content without a page load. Each running root
  // keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
  // component inside another one is saved as written, not half-started, and stopped with it. Two
  // components on one root (a reveal on a stat card) both start, and stop together.
  const KODZA = Symbol.for('kodza');
  const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
  const instances = new WeakMap();
  const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
  const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
  const put = (el, saved) => {
    [...el.attributes].forEach((a) => el.removeAttribute(a.name));
    saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
    el.innerHTML = saved.html;
  };
  // The markup inside a root as written: Kodza components already running in it give back their own
  const written = (root) => {
    const all = [...root.querySelectorAll('*')];
    if (!all.some((el) => el[KODZA])) return root.innerHTML;
    const copy = root.cloneNode(true);
    const twins = copy.querySelectorAll('*');
    let last = null;
    all.forEach((el, i) => {
      if (!el[KODZA] || last?.contains(el)) return;
      last = el;
      put(twins[i], el[KODZA]);
    });
    return copy.innerHTML;
  };
  const init = (scope) => find(scope).forEach((root) => {
    // Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
    // running on it, a root is a copy of a started one (a cloned slide): it is left as it is
    if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
    // Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
    // it has hydrated it. Any earlier start would change markup React has not taken over yet
    const owner = root.closest('[data-kodza-manual]');
    if (owner && !(scope && owner.contains(scope))) return;
    const controller = new AbortController();
    // Another Kodza component already running on this root: its markup as written is kept, and the
    // two stop together
    const before = root[KODZA];
    const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
    if (before) before.above = saved;
    instances.set(root, { controller, saved, before });
    root[KODZA] = saved;
    root[RUNNING] = true;
    mount(root, controller.signal);
    root.classList.add('is-ready');
  });
  const destroy = (scope) => find(scope).forEach((root) => {
    const instance = instances.get(root);
    if (!instance) return;
    instances.delete(root);
    instance.saved.above?.stop(); // a Kodza component started after it on the same root stops first
    // The Kodza components running inside it stop first: once its markup is put back, their own
    // script could not find them any more
    root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
    instance.controller.abort();
    delete root[RUNNING];
    // Started on top of another one: that one stops next, and puts the markup back
    if (instance.before) {
      delete instance.before.above;
      root[KODZA] = instance.before;
      return instance.before.stop();
    }
    delete root[KODZA];
    put(root, instance.saved);
  });
  document.addEventListener('kodza:init', (event) => init(event.detail?.root));
  document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
  init();
  // Loaded in <head>, the script runs before the markup exists: once more when it is parsed
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
```

Free to use, from Kodza (https://kodza.dev).
