デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- 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 件ずつ切り替えるスライダーと違い、たくさんの声を一度に見せられるので、レビューの多いサービスの紹介や導入事例、採用ページの社員の声などに向いています。
コード
<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>
.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); }
}
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();
});
});