本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
カード

口コミのカードが自動で順に切り替わり、ドットが残り時間を示して進む

デモ

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

仕様

用途
カード
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

口コミを同じ枠に重ね、切り替えでは、前の引用文と名前の段を左へ動かしながら消し、消えきってから次の文を右から 1 行ずつ入れます。顔写真は次の写真を上に重ね、右から左へ開いて前の写真を覆います(clip-path の inset)。下のドットは表示中のものが横長に伸び、塗りの幅で次に替わるまでの残り時間を示します。

前の文は 0.3 秒で抜け、次の文は 1 行 0.6 秒の power3.out で、0.06 秒ずつ遅らせて入ります。写真は 0.7 秒で開き、中身を写真の幅の 18% だけ右から寄せて、差し込まれるように見せます。重ねてフェードすると途中で 2 つの文が重なって読めないので、前の文が消えきってから次の文を出します。推薦の言葉は文節の切れ目で行を分けて書き、行ごとに入れます。

見え方は、口コミごとの「切り替えが始まってからの秒」だけで決まります。この秒を gsap.ticker で進めて毎回描き直すので、矢印やドットで飛ぶときは、いまの口コミの秒を抜け始める時刻に進め、選んだ口コミの秒を 0 に戻すだけで済みます。前の口コミが抜けきる前に次を選んでも、それぞれの動きは途中から続きます。

5 秒より長く自動で動き続けるものには、止める手段が要ります(WCAG 2.2.2)。ドットの横に止めるボタンを置き、ポインタを重ねている間と画面の外にある間も進めません。キーボードで中へ入ったら止め、止めるボタンで再開するまで止めたままにします。自動で進む間は aria-live を off にして読み上げの知らせを止め、止めたら polite にします。動きを減らす設定では自動で進めず、押したときだけその場で入れ替えます。

このデモは 1 件を 2.5 秒で送っていますが、コードでは読み終えられるよう 5 秒にしています。見せる時間は文の長さに合わせて 5〜7 秒が目安で、横に動く量を大きくすると勢いが出ます。デモでは矢印とドットを押すと切り替わり、カードに重ねている間は止まります。お客様の声・導入事例・レビューの紹介に向いています。

コード

HTML
<section class="stories" aria-labelledby="stories-title" aria-roledescription="カルーセル">
  <div class="stories__head">
    <div>
      <p class="stories__eyebrow">Customer stories</p>
      <h2 class="stories__title" id="stories-title">お客さまの声</h2>
    </div>
    <div class="stories__arrows">
      <button class="stories__arrow" type="button" data-step="-1" aria-label="前の口コミ">
        <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6"/></svg>
      </button>
      <button class="stories__arrow" type="button" data-step="1" aria-label="次の口コミ">
        <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
      </button>
    </div>
  </div>

  <!-- 口コミは同じ升に重ねる。自動で進む間は読み上げの知らせを止め(off)、止めたら polite にする -->
  <div class="stories__track" aria-live="off">
    <figure class="story" role="group" aria-roledescription="スライド" aria-label="1 / 4">
      <div class="story__photo"><img src="/img/yamamoto.jpg" alt="" width="336" height="336"></div>
      <blockquote class="story__quote">
        <p><span class="story__line">「3週間で、新しいサイトを公開できました。</span><span class="story__line">更新も社内で手軽にできて助かっています」</span></p>
      </blockquote>
      <figcaption class="story__line story__author">
        <span class="story__name">山本 真由</span>
        <span class="story__role">みなと不動産 広報部</span>
        <span class="story__stars" role="img" aria-label="5点満点中5点">★★★★★</span>
      </figcaption>
    </figure>
    <figure class="story" role="group" aria-roledescription="スライド" aria-label="2 / 4">
      <div class="story__photo"><img src="/img/nakamura.jpg" alt="" width="336" height="336"></div>
      <blockquote class="story__quote">
        <p><span class="story__line">「質問すると、いつもすぐに返事が来ます。</span><span class="story__line">困ったときも、安心して相談できます」</span></p>
      </blockquote>
      <figcaption class="story__line story__author">
        <span class="story__name">中村 大輔</span>
        <span class="story__role">ひかり物流株式会社 情報システム部</span>
        <span class="story__stars" role="img" aria-label="5点満点中5点">★★★★★</span>
      </figcaption>
    </figure>
    <figure class="story" role="group" aria-roledescription="スライド" aria-label="3 / 4">
      <div class="story__photo"><img src="/img/sato.jpg" alt="" width="336" height="336"></div>
      <blockquote class="story__quote">
        <p><span class="story__line">「リニューアル後、会員登録が2倍に。</span><span class="story__line">ページの表示も、ぐっと速くなりました」</span></p>
      </blockquote>
      <figcaption class="story__line story__author">
        <span class="story__name">佐藤 美咲</span>
        <span class="story__role">医療法人さくら会 広報・Web担当</span>
        <span class="story__stars" role="img" aria-label="5点満点中5点">★★★★★</span>
      </figcaption>
    </figure>
    <figure class="story" role="group" aria-roledescription="スライド" aria-label="4 / 4">
      <div class="story__photo"><img src="/img/kobayashi.jpg" alt="" width="336" height="336"></div>
      <blockquote class="story__quote">
        <p><span class="story__line">「画面がすっきりして、迷わず使えます。</span><span class="story__line">毎日の更新が、ずいぶん楽になりました」</span></p>
      </blockquote>
      <figcaption class="story__line story__author">
        <span class="story__name">小林 健太</span>
        <span class="story__role">小林設計事務所 代表</span>
        <span class="story__stars" role="img" aria-label="5点満点中5点">★★★★★</span>
      </figcaption>
    </figure>
  </div>

  <div class="stories__foot">
    <!-- 5 秒より長く動き続けるので、止めるボタンを置く。名前は変えず、状態は aria-pressed で伝える -->
    <button class="stories__pause" type="button" aria-pressed="false" aria-label="自動再生を一時停止">
      <svg class="stories__icon-pause" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M9 6v12M15 6v12"/></svg>
      <svg class="stories__icon-play" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M8 6v12l10-6z"/></svg>
    </button>
    <!-- 表示中のドットは横長に伸び、塗りで次に替わるまでの残り時間を示す -->
    <div class="stories__dots">
      <button class="stories__dot" type="button" aria-label="1件目の口コミ"></button>
      <button class="stories__dot" type="button" aria-label="2件目の口コミ"></button>
      <button class="stories__dot" type="button" aria-label="3件目の口コミ"></button>
      <button class="stories__dot" type="button" aria-label="4件目の口コミ"></button>
    </div>
    <p class="stories__rating">★4.9(レビュー1,200件以上)</p>
  </div>
</section>

CSS
.stories {
  --bg: #f2f1ed;
  --surface: #ffffff;
  --ink: #262626;
  --mute-small: #6b6b6b;
  --line: rgb(38 38 38 / 0.12);
  --shift: 45px;    /* 文が横へ動く量 */
  --parallax: 18%;  /* 開くときに写真が右から寄る量 */
  --dot: 8px;       /* ドットの大きさ */
  --pill: 48px;     /* 表示中に伸びたドットの幅 */
  padding: 48px 24px 64px;
  background: var(--bg);
  color: var(--ink);
}

.stories__head,
.stories__track,
.stories__foot { max-width: 1088px; margin-inline: auto; }

.stories__head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-end; gap: 24px 32px; }
.stories__eyebrow { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute-small); }
.stories__title { margin: 16px 0 0; font-size: clamp(28px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.stories__arrows { flex: none; display: flex; gap: 8px; }

/* アイコンだけのボタンは線の丸 */
.stories__arrow,
.stories__pause { display: grid; place-items: center; width: 48px; height: 48px; padding: 0; border: 0; border-radius: 50%; background: none; box-shadow: inset 0 0 0 1px var(--ink); color: var(--ink); cursor: pointer; }
.stories__pause { flex: none; width: 32px; height: 32px; }
.stories svg path { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.stories__pause path { stroke-width: 3; }
.stories__pause[aria-pressed="true"] .stories__icon-pause,
.stories__pause[aria-pressed="false"] .stories__icon-play { display: none; }
.stories button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

/* 白いカードは升の側に塗る。口コミを重ねても、抜けていく文が上の口コミに隠れない */
.stories__track { display: grid; margin-top: 48px; border-radius: 4px; background: var(--surface); }
.story { grid-area: 1 / 1; display: grid; grid-template-columns: minmax(0, 336px) minmax(0, 1fr); grid-template-rows: 1fr auto auto 1fr; column-gap: 48px; margin: 0; padding: 32px 48px 32px 32px; }
.story:not(:first-child) { visibility: hidden; } /* JS の前は、最初の 1 件だけを見せる */

/* JS が書く --open(写真の開き 0〜1)・--x(行のずれ)・--alpha(行の濃さ)で描く */
.story__photo { grid-row: 1 / -1; aspect-ratio: 1; overflow: hidden; background: var(--bg); clip-path: inset(0 0 0 calc((1 - var(--open, 1)) * 100%)); }
.story__photo img { display: block; width: 100%; height: 100%; object-fit: cover; transform: translateX(calc((1 - var(--open, 1)) * var(--parallax))); }
.story__line { display: block; opacity: var(--alpha, 1); transform: translateX(calc(var(--x, 0) * var(--shift))); }

.story__quote { grid-column: 2; grid-row: 2; margin: 0; font-size: clamp(20px, 2.4vw, 28px); line-height: 1.6; letter-spacing: 0.04em; word-break: auto-phrase; }
.story__quote p { margin: 0; }
.story__author { grid-column: 2; grid-row: 3; display: grid; grid-template-columns: 1fr auto; align-items: end; column-gap: 16px; margin-top: 32px; }
.story__name { font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.story__role { grid-row: 2; margin-top: 4px; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--mute-small); word-break: auto-phrase; }
.story__stars { grid-column: 2; grid-row: 1 / span 2; font-size: 20px; line-height: 1.3; letter-spacing: 0.12em; }

.stories__foot { display: flex; align-items: center; gap: 16px; margin-top: 32px; }
.stories__dots { display: flex; gap: 16px; }
/* 押せる高さは 24px。見た目の丸帯(::before)と塗り(::after)は、その中央に置く */
.stories__dot { position: relative; width: calc(var(--dot) + (var(--pill) - var(--dot)) * var(--grow, 0)); height: 24px; padding: 0; border: 0; background: none; cursor: pointer; }
.stories__dot:first-child { --grow: 1; }
.stories__dot::before,
.stories__dot::after { content: ""; position: absolute; left: 0; top: calc(50% - var(--dot) / 2); height: var(--dot); border-radius: 99px; }
.stories__dot::before { right: 0; background: var(--line); }
.stories__dot::after { width: calc(var(--fill, 0) * 100%); background: var(--ink); opacity: var(--fill-alpha, 0); }
.stories__rating { margin: 0 0 0 auto; font-size: 14px; line-height: 1.4; letter-spacing: 0.04em; color: var(--mute-small); }

@media (max-width: 800px) {
  .story { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; padding: 24px; }
  .story__photo { grid-row: auto; width: 128px; }
  .story__quote,
  .story__author { grid-column: 1; grid-row: auto; }
  .story__quote { margin-top: 24px; }
  .stories__foot { flex-wrap: wrap; }
  .stories__rating { flex-basis: 100%; margin-left: 0; }
}

/* 動きを減らす設定では自動で進めないので、止めるボタンと残り時間の塗りを出さない */
@media (prefers-reduced-motion: reduce) {
  .stories__pause,
  .stories__dot::after { display: none; }
}

JavaScript
import { gsap } from "gsap";

const HOLD = 5; // 1 件を見せる時間(秒)。ドットの塗りはこの間に満ちる
const OUT = 0.3; // 前の文が抜ける時間(秒)
const IN = 0.6; // 次の文が 1 行ぶん入る時間
const STAGGER = 0.06; // 行ごとのずれ
const WIPE = 0.7; // 写真が開く時間
const MORPH = 0.4; // ドットが伸び縮みする時間
const SETTLE = OUT + IN + STAGGER * 2; // 切り替えが始まってから、次の文が出そろうまで
const GONE = HOLD + 1; // ここまで進んだ口コミは抜けきっている
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const clamp = gsap.utils.clamp(0, 1);
const [enterEase, leaveEase, wipeEase, morphEase] = ["power3.out", "power2.in", "power3.inOut", "power2.inOut"].map((name) => gsap.parseEase(name));

document.querySelectorAll(".stories").forEach((section) => {
  const track = section.querySelector(".stories__track");
  const slides = [...track.querySelectorAll(".story")];
  const lines = slides.map((slide) => [...slide.querySelectorAll(".story__line")]);
  const dots = [...section.querySelectorAll(".stories__dot")];
  const pause = section.querySelector(".stories__pause");
  // 口コミごとの「切り替えが始まってからの秒」。見え方はこの値だけで決まり、HOLD を越えると抜けていく
  const age = slides.map((_, i) => (i === 0 ? SETTLE : GONE));
  let current = 0;
  // 止める理由。止めるボタン(キーボードの焦点が入ったときも押した扱い)・重ねている・画面の外、のどれか 1 つでもあれば止める
  const hold = { user: reduceMotion, hover: false, hidden: true };
  const stopped = () => hold.user || hold.hover || hold.hidden;

  const draw = () => {
    slides.forEach((slide, i) => {
      const t = age[i];
      // 写真は上に重なって右から開く。前の写真は、次の写真が開ききるまで下に残す
      slide.style.zIndex = t < HOLD ? "2" : "1";
      slide.style.visibility = t < HOLD + WIPE ? "visible" : "hidden";
      slide.style.setProperty("--open", wipeEase(clamp(t / WIPE)));
      // 文は前の文が抜けきってから、右から行ごとに入る(重ねてフェードすると、2 つの文が重なって読めない)
      lines[i].forEach((line, j) => {
        const enter = enterEase(clamp((t - OUT - j * STAGGER) / IN));
        const leave = leaveEase(clamp((t - HOLD - j * STAGGER * 0.5) / (OUT - STAGGER)));
        line.style.setProperty("--alpha", Math.min(enter, 1 - leave));
        line.style.setProperty("--x", 1 - enter - leave);
      });
      const shrink = morphEase(clamp((t - HOLD) / MORPH));
      dots[i].style.setProperty("--grow", morphEase(clamp(t / MORPH)) * (1 - shrink));
      dots[i].style.setProperty("--fill", clamp((t - OUT) / (HOLD - OUT)));
      dots[i].style.setProperty("--fill-alpha", t > OUT ? 1 - shrink : 0);
    });
  };

  // いまの口コミは抜けさせ、選んだ口コミを入れる。動きを減らす設定では、その場で入れ替える
  const go = (next, carry = 0) => {
    if (next === current) return;
    age[current] = reduceMotion ? GONE : Math.max(age[current], HOLD);
    age[next] = reduceMotion ? SETTLE : carry;
    current = next;
    slides.forEach((slide, i) => slide.setAttribute("aria-hidden", String(i !== next)));
    dots.forEach((dot, i) => dot.setAttribute("aria-current", String(i === next)));
    draw();
  };

  gsap.ticker.add((time, deltaTime) => {
    let moved = false;
    age.forEach((t, i) => {
      // 止めている間も、切り替えの途中の動きは最後まで流す(止めるのは、いまの口コミの残り時間だけ)
      if (t >= GONE || (i === current && stopped() && t >= SETTLE)) return;
      age[i] = t + deltaTime / 1000;
      moved = true;
    });
    if (age[current] >= HOLD) go((current + 1) % slides.length, age[current] - HOLD);
    if (moved) draw();
  });

  const update = () => {
    pause.setAttribute("aria-pressed", String(hold.user));
    track.setAttribute("aria-live", stopped() ? "polite" : "off");
  };
  section.querySelectorAll(".stories__arrow").forEach((arrow) => {
    arrow.addEventListener("click", () => go((current + Number(arrow.dataset.step) + slides.length) % slides.length));
  });
  dots.forEach((dot, i) => dot.addEventListener("click", () => go(i)));
  pause.addEventListener("click", () => ((hold.user = !hold.user), update()));
  // キーボードで中へ入ったら止め、止めるボタンで再開するまで止めたままにする。マウスで押しても焦点は入るので、:focus-visible で見分ける
  section.addEventListener("focusin", (event) => event.target.matches(":focus-visible") && event.target !== pause && ((hold.user = true), update()));
  track.addEventListener("pointerenter", () => ((hold.hover = true), update()));
  track.addEventListener("pointerleave", () => ((hold.hover = false), update()));
  // 画面の外にある間は進めない(見ていない間に先へ進まないように)
  new IntersectionObserver(([entry]) => ((hold.hidden = !entry.isIntersecting), update())).observe(section);

  slides.forEach((slide, i) => slide.setAttribute("aria-hidden", String(i !== 0)));
  dots.forEach((dot, i) => dot.setAttribute("aria-current", String(i === 0)));
  draw();
});

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

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