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

機能紹介のセクションでタブが自動で進み、進捗の線が満ちると次の図に切り替わる

デモ

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

仕様

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

しくみと調整

左に機能のタブを縦に並べ、選んでいるタブの説明を開いて、上の線を左から満たしていきます。線が満ちきると次のタブへ進み、右の図が切り替わります。次の図は前の図の上に重ね、下の縁から上へ開いて覆います(clip-path の inset)。タブを押すと、そのタブへ切り替えて、線を 0 から満たし直します。

1 つのタブは 4 秒見せ、線はその間、一定の速さで満ちるので、次に替わるまでの残り時間が読めます。図は 0.7 秒の power3.inOut で開き、中身を 24px 下から同時に上げて、画面が差し込まれるように見せます。前のタブの線は満ちたまま 0.3 秒で薄れ、説明は 0.5 秒で閉じます。線を満たすトゥイーンそのものを時計にし、終わったら次へ進むので、止めるのも pause と resume を呼ぶだけです。

自動で進むものは、読んでいる途中に中身が替わると困ります。ポインタを重ねている間と、キーボードの焦点がタブにある間は止め、画面の外にある間も進めません。焦点は :focus-visible のときだけ数えます。マウスで押しても焦点は入るので、数えてしまうと、押したあとにポインタを離しても止まったままになります。

タブの列は tablist で、縦に並ぶので ↑ ↓ キーで隣へ移ります。タブの名前は機能名だけにし、開く説明は aria-describedby で添えます。ボタンの中の文がすべて名前になって、長く読み上げられるのを避けるためです。説明の開閉は CSS で行の高さを 0fr から 1fr へ動かすので、高さを測らずに済みます。動きを減らす設定では自動で進めず、線も出さずに、押したときだけ一瞬で切り替えます。

つまみは、見せる時間と、図が開く時間、中身が上がる量です。見せる時間は、説明と図を読み取れる 4〜6 秒が目安で、短くすると読み終える前に替わります。押して切り替えるだけのタブと違い、放っておいても全部の機能が順に見えるので、SaaS やアプリの機能紹介、サービスの手順、導入の流れを見せる節に向いています。

コード

HTML
<section class="features" aria-labelledby="features-title">
  <p class="features__eyebrow">Features</p>
  <h2 class="features__title" id="features-title">チームの仕事に、<wbr>ちょうどいい。</h2>

  <div class="features__body">
    <!-- 縦に並ぶタブ。Tab キーで入るのは選んでいるタブだけで、ほかへは ↑ ↓ キーで移る。
         名前は機能名だけにし、開く説明は aria-describedby で添える -->
    <div class="features__tabs" role="tablist" aria-label="機能" aria-orientation="vertical">
      <button class="features__tab" type="button" role="tab" id="feature-tab-1" aria-controls="feature-1"
              aria-selected="true" aria-labelledby="feature-name-1" aria-describedby="feature-text-1">
        <span class="features__fill" aria-hidden="true"></span>
        <span class="features__name" id="feature-name-1">みんなで計画する</span>
        <span class="features__more"><span class="features__text" id="feature-text-1">ここに説明文が入ります。</span></span>
      </button>
      <button class="features__tab" type="button" role="tab" id="feature-tab-2" aria-controls="feature-2"
              aria-selected="false" aria-labelledby="feature-name-2" aria-describedby="feature-text-2" tabindex="-1">
        <span class="features__fill" aria-hidden="true"></span>
        <span class="features__name" id="feature-name-2">進み具合を確認する</span>
        <span class="features__more"><span class="features__text" id="feature-text-2">ここに説明文が入ります。</span></span>
      </button>
      <button class="features__tab" type="button" role="tab" id="feature-tab-3" aria-controls="feature-3"
              aria-selected="false" aria-labelledby="feature-name-3" aria-describedby="feature-text-3" tabindex="-1">
        <span class="features__fill" aria-hidden="true"></span>
        <span class="features__name" id="feature-name-3">手作業を自動化する</span>
        <span class="features__more"><span class="features__text" id="feature-text-3">ここに説明文が入ります。</span></span>
      </button>
      <button class="features__tab" type="button" role="tab" id="feature-tab-4" aria-controls="feature-4"
              aria-selected="false" aria-labelledby="feature-name-4" aria-describedby="feature-text-4" tabindex="-1">
        <span class="features__fill" aria-hidden="true"></span>
        <span class="features__name" id="feature-name-4">成果を共有する</span>
        <span class="features__more"><span class="features__text" id="feature-text-4">ここに説明文が入ります。</span></span>
      </button>
    </div>

    <!-- 図は同じ升に重ねる。切り替えるときは、次の図を上に重ねて下から開く -->
    <div class="features__panels">
      <div class="features__panel" id="feature-1" role="tabpanel" aria-labelledby="feature-tab-1">
        <img src="/img/feature-plan.png" alt="タスクをボードに並べた画面" width="1216" height="848">
      </div>
      <div class="features__panel" id="feature-2" role="tabpanel" aria-labelledby="feature-tab-2">
        <img src="/img/feature-track.png" alt="今月の進み具合を折れ線で見せる画面" width="1216" height="848">
      </div>
      <div class="features__panel" id="feature-3" role="tabpanel" aria-labelledby="feature-tab-3">
        <img src="/img/feature-automate.png" alt="自動で進める手順を並べた画面" width="1216" height="848">
      </div>
      <div class="features__panel" id="feature-4" role="tabpanel" aria-labelledby="feature-tab-4">
        <img src="/img/feature-share.png" alt="成果をまとめた報告の画面" width="1216" height="848">
      </div>
    </div>
  </div>
</section>

CSS
.features {
  --bg: #f2f1ed;
  --ink: #262626;
  --mute: #898989;
  --mute-small: #6b6b6b;
  --line: rgb(38 38 38 / 0.12);
  --ease: cubic-bezier(0.65, 0, 0.35, 1);
  padding: 96px 24px;
  background: var(--bg);
  color: var(--ink);
}

.features__eyebrow,
.features__title,
.features__body {
  max-width: 1088px;
  margin-inline: auto;
}

.features__eyebrow {
  margin-block: 0 16px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--mute-small);
}

.features__title {
  margin-block: 0 48px;
  font-size: clamp(28px, 4vw, 44px);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -0.02em;
  word-break: keep-all; /* 折り返すのは HTML の <wbr> の所だけ */
}

.features__body {
  display: grid;
  grid-template-columns: minmax(0, 400px) minmax(0, 1fr);
  gap: 80px;
  align-items: start;
}

.features__tabs { display: flex; flex-direction: column; }

.features__tab {
  position: relative;
  display: block;
  width: 100%;
  padding: 20px 0;
  border: 0;
  border-top: 1px solid var(--line);
  background: none;
  color: var(--ink);
  font: inherit;
  text-align: left;
  cursor: pointer;
}

/* 進み具合の線。上の罫線に重ね、JS が scaleX を 0 から 1 へ動かす */
.features__fill {
  position: absolute;
  left: 0;
  top: -1px;
  width: 100%;
  height: 2px;
  background: var(--ink);
  transform: scaleX(0);
  transform-origin: left;
}

.features__name { display: block; font-size: 22px; font-weight: 700; line-height: 1.3; color: var(--mute); transition: color 0.3s; }
.features__tab[aria-selected="true"] .features__name { color: var(--ink); }
.features__tab:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 説明は行の高さを 0fr から 1fr へ動かして開く(高さを測らずに済む) */
.features__more { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.5s var(--ease); }
.features__tab[aria-selected="true"] .features__more { grid-template-rows: 1fr; }

.features__text { min-height: 0; overflow: hidden; word-break: auto-phrase; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; opacity: 0; transition: opacity 0.3s; }
.features__text::before { content: ""; display: block; height: 8px; }
.features__tab[aria-selected="true"] .features__text { opacity: 1; transition-delay: 0.2s; }

/* 図は同じ升に重ねる。JS が動く前は、最初の図だけを見せる */
.features__panels { display: grid; }
.features__panel { grid-area: 1 / 1; aspect-ratio: 608 / 424; overflow: hidden; border-radius: 4px; background: #dedbe6; }
.features__panel:not(:first-child) { visibility: hidden; }
.features__panel img { display: block; width: 100%; height: 100%; object-fit: cover; }

@media (max-width: 800px) { .features__body { grid-template-columns: 1fr; gap: 40px; } }

/* 動きを減らす設定では、自動で進めないので線を出さず、開閉も一瞬で行う */
@media (prefers-reduced-motion: reduce) {
  .features__fill { display: none; }
  .features__name,
  .features__more,
  .features__text { transition: none; }
}

JavaScript
import { gsap } from "gsap";

const HOLD = 4; // 1 つの機能を見せる時間(秒)。線はこの時間で満ちる
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".features").forEach((section) => {
  const body = section.querySelector(".features__body");
  const tabs = [...section.querySelectorAll('[role="tab"]')];
  const panels = tabs.map((tab) => document.getElementById(tab.getAttribute("aria-controls")));
  const fills = tabs.map((tab) => tab.querySelector(".features__fill"));
  let current = Math.max(0, tabs.findIndex((tab) => tab.getAttribute("aria-selected") === "true"));
  let top = 1; // いちばん上に重ねた図の z-index
  let clock = null; // 線を満たすトゥイーン。満ちたら次のタブへ進む
  // 止める理由。重ねている・キーボードの焦点がある・画面の外にある、のどれか 1 つでもあれば止める
  const hold = { hover: false, focus: false, hidden: true };
  const update = () => clock && (hold.hover || hold.focus || hold.hidden ? clock.pause() : clock.resume());

  function select(next) {
    const prev = current;
    current = next;
    tabs.forEach((tab, i) => {
      tab.setAttribute("aria-selected", String(i === next));
      tab.tabIndex = i === next ? 0 : -1;
    });

    if (prev !== next) {
      // 次の図をいちばん上に重ね、下から開いて前の図を覆う。覆い終わったら、前の図を読み上げとタブ移動から外す
      const panel = panels[next];
      gsap.set(panel, { zIndex: ++top, visibility: "visible" });
      gsap.fromTo(panel, { clipPath: "inset(100% 0% 0% 0%)" }, {
        clipPath: "inset(0% 0% 0% 0%)",
        duration: reduceMotion ? 0 : 0.7,
        ease: "power3.inOut",
        overwrite: "auto",
        // 途中で次のタブが押されたら、そちらに任せる
        onComplete: () => panel === panels[current] && panels.forEach((other) => other !== panel && gsap.set(other, { visibility: "hidden" })),
      });
      if (!reduceMotion) gsap.fromTo(panel.firstElementChild, { y: 24 }, { y: 0, duration: 0.7, ease: "power3.inOut" });
    }

    // 前の線は満ちたまま薄れて消え、いまの線は 0 から満ちる。動きを減らす設定では自動で進めない
    clock?.kill();
    fills.forEach((fill, i) => {
      if (i === prev && prev !== next) {
        gsap.to(fill, { opacity: 0, duration: 0.3, overwrite: true, onComplete: () => gsap.set(fill, { scaleX: 0, opacity: 1 }) });
      } else if (i !== next) gsap.set(fill, { scaleX: 0, opacity: 1, overwrite: true });
    });
    if (reduceMotion) return;
    clock = gsap.fromTo(fills[next], { scaleX: 0, opacity: 1 }, {
      scaleX: 1,
      duration: HOLD,
      ease: "none",
      overwrite: true,
      onComplete: () => select((current + 1) % tabs.length),
    });
    update();
  }

  // 押したタブへ切り替え、そこから進める
  tabs.forEach((tab, i) => tab.addEventListener("click", () => i !== current && select(i)));

  // ↑ ↓ で隣のタブへ(端では反対の端へ回る)。Home と End で先頭と末尾へ
  section.querySelector('[role="tablist"]').addEventListener("keydown", (event) => {
    const step = { ArrowDown: current + 1, ArrowUp: current - 1, Home: 0, End: tabs.length - 1 }[event.key];
    if (step === undefined) return;
    event.preventDefault();
    select((step + tabs.length) % tabs.length);
    tabs[current].focus();
  });

  // 重ねている間は止める。焦点はキーボードのとき(:focus-visible)だけ止める。マウスで押しても焦点は入るため
  body.addEventListener("pointerenter", () => ((hold.hover = true), update()));
  body.addEventListener("pointerleave", () => ((hold.hover = false), update()));
  section.addEventListener("focusin", (event) => ((hold.focus = event.target.matches(":focus-visible")), update()));
  section.addEventListener("focusout", (event) => !section.contains(event.relatedTarget) && ((hold.focus = false), update()));

  // 画面に入るまでは進めない(見ていない間に先へ進まないように)
  new IntersectionObserver(([entry]) => ((hold.hidden = !entry.isIntersecting), update())).observe(section);

  select(current);
});

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

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