本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
セクション

口コミのセクションで、カードが縦 3 列に並び、交互の向きに流れ続ける

デモ

操作:カーソルを重ねる・クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

口コミのカードを縦 3 列に並べ、列ごとに上・下・上と交互の向きに、違う速さで流し続けます。どの列も、カードの組を複製して続けて並べ、1 組の高さだけ動かしたら頭へ巻き戻します。1 組ぶん動いた見た目は元の見た目とぴったり重なるので、継ぎ目は見えません。壁の上下の端は mask-image で地に溶かし、カードが端から湧き出て、端へ消えていくように見せています。

速さは 1 秒に 34〜46px ほどで、列ごとに変えています。同じ速さだと 3 列が 1 枚の板のようにそろって動き、向きを交互にした効果が薄れます。動かす量は yPercent で組の高さに対する割合にしているので、画面の幅が変わって組の高さが変わっても、巻き戻す位置はずれません。複製する数は、窓の高さを組の高さで割って 1 つ足した数です。

複製した組は aria-hidden で読み上げから外します。外さないと、同じ口コミが何度も読み上げられます。また、最後のカードの下の余白まで組の高さに入れておきます(リストを flow-root にする)。余白が組の外にはみ出すと、巻き戻す所で表示が 16px 跳ねます。

5 秒より長く動き続けるものには、止める手段が要ります。壁にポインタを重ねると、流れの timeScale を 0.4 秒で 0 へ落として止め、離すと動き出します。急に止めると、読もうとしたカードが跳ねて見えるため、ゆっくり止めています。キーボードや指で操作する人のために、一時停止のボタンも置いています。動きを減らす設定では流さず、全部のカードを段組みで並べます。

口コミは 1 つのリストに書いておき、スクリプトが画面の幅に合わせて列に配ります。広い画面は 3 列、狭い画面は 1 列です。つまみは、列ごとの速さ、壁の高さ、上下を溶かす幅です。1 件ずつ切り替えるスライダーと違い、たくさんの声を一度に見せられるので、レビューの多いサービスの紹介や導入事例、採用ページの社員の声などに向いています。

コード

HTML
<section class="voices" aria-labelledby="voices-title">
  <div class="voices__intro">
    <p class="voices__eyebrow">Testimonials</p>
    <h2 class="voices__title" id="voices-title">お客さまから<br>届いた声</h2>
    <p class="voices__lead">ここに説明文が入ります。</p>
    <p class="voices__score"><strong>4.9</strong>5段階・1,280件の評価</p>
    <!-- 5 秒より長く動き続けるので、止める手段を置く。流すときだけ JS が出す -->
    <button class="voices__toggle" type="button" aria-pressed="false" hidden>一時停止</button>
  </div>
  <!-- 口コミは 1 つのリストに書く。JS が画面の幅に合わせて列に配り、列ごとに流す -->
  <div class="voices__wall">
    <ul class="voices__list">
      <li class="voices__card">
        <blockquote>公開から3か月で、問い合わせが2倍になりました。</blockquote>
        <p class="voices__who"><img src="/img/voices/01.jpg" alt="" width="36" height="36"><b>佐藤 美香</b><span>カフェのオーナー</span></p>
      </li>
      <li class="voices__card">
        <blockquote>更新の手順がシンプルで、スタッフの誰でも記事を出せるようになりました。</blockquote>
        <p class="voices__who"><img src="/img/voices/02.jpg" alt="" width="36" height="36"><b>森 健太</b><span>学習塾の広報</span></p>
      </li>
      <li class="voices__card">
        <blockquote>ほかでは断られた要望にも、代わりの案を出してくれました。</blockquote>
        <p class="voices__who"><img src="/img/voices/03.jpg" alt="" width="36" height="36"><b>木村 亮</b><span>工務店の二代目</span></p>
      </li>
      <li class="voices__card">
        <blockquote>うまく説明できなかった要望も、きちんと形にしてもらえました。</blockquote>
        <p class="voices__who"><img src="/img/voices/04.jpg" alt="" width="36" height="36"><b>松本 玲</b><span>建築事務所の代表</span></p>
      </li>
      <li class="voices__card">
        <blockquote>細かいところまで丁寧で、お店の雰囲気が伝わるサイトになりました。</blockquote>
        <p class="voices__who"><img src="/img/voices/05.jpg" alt="" width="36" height="36"><b>石田 彩</b><span>アパレルのディレクター</span></p>
      </li>
      <li class="voices__card">
        <blockquote>写真の見せ方ひとつで、商品がこんなに違って見えるとは思いませんでした。</blockquote>
        <p class="voices__who"><img src="/img/voices/06.jpg" alt="" width="36" height="36"><b>岡田 恵</b><span>雑貨店のオーナー</span></p>
      </li>
      <li class="voices__card">
        <blockquote>スマホで見たときの速さに驚きました。離脱もはっきり減っています。</blockquote>
        <p class="voices__who"><img src="/img/voices/07.jpg" alt="" width="36" height="36"><b>小林 菜央</b><span>ECサイトの運営責任者</span></p>
      </li>
      <li class="voices__card">
        <blockquote>相談への返事がいつも早く、安心して任せられます。</blockquote>
        <p class="voices__who"><img src="/img/voices/08.jpg" alt="" width="36" height="36"><b>中村 結衣</b><span>クリニックの事務長</span></p>
      </li>
      <li class="voices__card">
        <blockquote>サイトからの予約が、目に見えて増えました。</blockquote>
        <p class="voices__who"><img src="/img/voices/09.jpg" alt="" width="36" height="36"><b>上田 蒼太</b><span>ヘアサロンの店長</span></p>
      </li>
      <li class="voices__card">
        <blockquote>採用ページを作り直してから、応募が増えました。</blockquote>
        <p class="voices__who"><img src="/img/voices/10.jpg" alt="" width="36" height="36"><b>原田 修</b><span>ITベンチャーの人事</span></p>
      </li>
      <li class="voices__card">
        <blockquote>公開したあとも、数字を見ながら毎月少しずつ良くしてくれるのが心強いです。</blockquote>
        <p class="voices__who"><img src="/img/voices/11.jpg" alt="" width="36" height="36"><b>鈴木 花</b><span>旅館の女将</span></p>
      </li>
      <li class="voices__card">
        <blockquote>専門用語を使わずに説明してくれるので、わかりやすかったです。</blockquote>
        <p class="voices__who"><img src="/img/voices/12.jpg" alt="" width="36" height="36"><b>林 香織</b><span>NPOの代表</span></p>
      </li>
    </ul>
  </div>
</section>

CSS
.voices {
  --bg: #111111;
  --surface: #262626;
  --ink: #eeece6;
  --mute: #a8a6a0;
  --wall-height: 640px; /* 流すときの壁の高さ */
  display: grid;
  grid-template-columns: minmax(0, 320px) minmax(0, 704px);
  justify-content: center;
  align-items: center;
  gap: 48px 64px;
  padding: 96px 24px;
  background: var(--bg);
  color: var(--ink);
}

.voices__eyebrow {
  margin: 0 0 16px;
  font-size: 13px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--mute);
}

.voices__title { margin: 0; font-size: clamp(32px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.voices__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }

.voices__score { display: flex; align-items: baseline; gap: 8px; margin: 32px 0 0; font-size: 13px; letter-spacing: 0.04em; color: var(--mute); }
.voices__score strong { font-size: 32px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; color: var(--ink); }

.voices__toggle {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  height: 40px;
  margin-top: 32px;
  padding: 0 20px;
  border: 0;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--ink);
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

/* 一時停止の 2 本線 */
.voices__toggle::before { content: ""; box-sizing: border-box; width: 10px; height: 12px; border: solid currentColor; border-width: 0 3px; }
.voices__toggle[hidden] { display: none; }
.voices__toggle[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
.voices :focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 流さないとき(JS が動かない・動きを減らす設定)は、全部を段組みで並べる */
.voices__list { margin: 0; padding: 0; list-style: none; columns: 3 200px; column-gap: 16px; }
.voices__card { break-inside: avoid; margin: 0 0 16px; padding: 20px; border-radius: 4px; background: var(--surface); }
.voices__card blockquote { margin: 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; word-break: auto-phrase; }

.voices__who {
  display: grid;
  grid-template-columns: 36px 1fr;
  column-gap: 12px;
  align-items: center;
  margin: 16px 0 0;
  font-size: 12px;
  line-height: 1.4;
  letter-spacing: 0.04em;
  color: var(--mute);
}

.voices__who img { grid-row: span 2; width: 36px; height: 36px; border-radius: 50%; object-fit: cover; background: #3a3a3a; }
.voices__who b { font-size: 14px; color: var(--ink); }

/* 流すとき: 列を並べた窓。上下の端は地に溶かす */
.voices__wall.is-running {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr); /* 列の高さを窓にそろえる(中身の高さで伸ばさない) */
  gap: 16px;
  height: var(--wall-height);
  overflow: hidden;
  -webkit-mask-image: linear-gradient(transparent, #000 15%, #000 85%, transparent);
  mask-image: linear-gradient(transparent, #000 15%, #000 85%, transparent);
}

/* 最後のカードの余白まで組の高さに入れる(入れないと、巻き戻す所で 16px 跳ねる) */
.voices__col .voices__list { display: flow-root; columns: auto; }

@media (max-width: 900px) {
  .voices { --wall-height: 560px; grid-template-columns: minmax(0, 704px); }
}

JavaScript
import { gsap } from "gsap";

const SPEEDS = [34, 46, 40]; // 列ごとの速さ(1 秒に流れる px)。違う速さにして、列の動きをずらす
const EASE_TIME = 0.4; // 止まるまで・動き出すまでの時間(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".voices").forEach((section) => {
  // 動きを減らす設定では流さず、全部のカードを段組みで並べたままにする
  if (reduceMotion) return;
  const wall = section.querySelector(".voices__wall");
  const cards = [...wall.querySelectorAll(".voices__card")];
  const toggle = section.querySelector(".voices__toggle");
  let loops = [];
  let hovering = false;
  let paused = false;
  const speed = () => (hovering || paused ? 0 : 1);

  // カードを count 列に配り直し、列ごとに縦に流す
  function build(count) {
    loops.forEach((loop) => loop.kill());
    const cols = Array.from({ length: count }, (_, i) => {
      const col = document.createElement("div");
      const list = document.createElement("ul");
      col.className = "voices__col";
      list.className = "voices__list";
      list.append(...cards.filter((_, k) => k % count === i));
      col.append(list);
      return col;
    });
    wall.replaceChildren(...cols);
    wall.classList.add("is-running");

    loops = cols.map((col, i) => {
      const list = col.firstElementChild;
      // 窓の高さを越えても途切れないよう、組を複製する。複製は読み上げから外す
      const copies = Math.ceil(col.offsetHeight / list.offsetHeight) + 1;
      for (let k = 0; k < copies; k++) {
        const clone = list.cloneNode(true);
        clone.setAttribute("aria-hidden", "true");
        col.append(clone);
      }
      // どの組も自分の高さ(yPercent の 100)だけ動かして巻き戻す。1 組ぶん動くと元の絵に重なるので継ぎ目が出ない
      const up = i % 2 === 0; // 上・下・上と、向きを交互にする
      return gsap
        .fromTo(
          col.querySelectorAll(".voices__list"),
          { yPercent: up ? 0 : -100 },
          { yPercent: up ? -100 : 0, duration: list.offsetHeight / SPEEDS[i % SPEEDS.length], ease: "none", repeat: -1 }
        )
        .timeScale(speed());
    });
  }

  // 広い画面は 3 列、狭い画面は 1 列。幅が変わって列の数が変わったら、配り直す
  const mm = gsap.matchMedia();
  mm.add("(min-width: 640px)", () => build(3));
  mm.add("(max-width: 639px)", () => build(1));

  // 重ねている間と、一時停止のボタンが押されている間は、ゆっくり止める
  const ease = () =>
    loops.forEach((loop) => gsap.to(loop, { timeScale: speed(), duration: EASE_TIME, ease: "power2.out", overwrite: true }));
  wall.addEventListener("pointerenter", () => {
    hovering = true;
    ease();
  });
  wall.addEventListener("pointerleave", () => {
    hovering = false;
    ease();
  });

  // キーボードや指でも止められるボタン。名前は変えず、押されているかを aria-pressed で伝える
  toggle.hidden = false;
  toggle.addEventListener("click", () => {
    paused = !paused;
    toggle.setAttribute("aria-pressed", String(paused));
    ease();
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。