本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ナビゲーション

ナビゲーションのメニューを移ると、ドロップダウンの背景が大きさを変えながら滑って移る

デモ

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

仕様

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

しくみと調整

項目ごとに中身の大きさが違うドロップダウンを、1 枚の白い板で見せます。隣の項目へ移るとき、板は閉じずに、次の中身の幅・高さ・位置へ補間しながら滑り、上の三角も項目の真下へ動きます。中身は移る向きに少しずれながら入れ替わり、右の項目へ移ると、前の中身は左へ抜け、次の中身は右から入ります。

板の補間は 0.45 秒の power3.out です。前の中身は 0.12 秒で消し、消えきってから次の中身を 40px 横から入れます。2 つの中身を重ねたままフェードすると、途中で文が混ざって読めない瞬間が出ます。板は中身より先に動き出すので、大きな中身も、広がっていく板の中から現れて見えます。

中身はすべて板の中に重ねて置き、板の overflow: hidden ではみ出しを切ります。中身の大きさは隠したままでも測れるよう、display ではなく visibility で隠します。項目から板へ下りるあいだに閉じないよう、ナビと板のすきまは透明な余白でつなぎ、ナビから出ても 0.12 秒待ってから閉じます。

中身は DOM ではメニューの後ろにあるので、キーボードでの移動を補います。開いた項目で Tab を押すと中身の最初のリンクへ入り、最後のリンクから Tab で次の項目へ戻ります。Esc で閉じて項目に焦点を戻し、タッチでは押して開け閉めします。項目のボタンには aria-expanded を付けて、開いているかを伝えます。

板の ease を expo.out にすると、吸い付くように止まります。中身のずれを小さくすると落ち着いた印象に、大きくすると移る向きがはっきりします。動きを減らす設定では、補間せずにその場で切り替えます。製品・導入事例・開発者向け・会社案内のように、中身の量が違うメニューを並べるヘッダーに向いています。

コード

HTML
<!-- 板(plate)は 1 枚だけ置き、項目ごとの中身(pane)をその中に重ねる。中身は aria-controls で項目のボタンと結ぶ。
     隠れている中身は visibility: hidden なので、タブ移動と読み上げから外れる -->
<nav class="morph-nav" aria-label="メイン">
  <ul class="morph-nav__menu">
    <li><button class="morph-nav__trigger" type="button" aria-expanded="false" aria-controls="menu-products">製品</button></li>
    <li><button class="morph-nav__trigger" type="button" aria-expanded="false" aria-controls="menu-solutions">ソリューション</button></li>
    <li><button class="morph-nav__trigger" type="button" aria-expanded="false" aria-controls="menu-developers">開発者向け</button></li>
    <li><button class="morph-nav__trigger" type="button" aria-expanded="false" aria-controls="menu-company">会社情報</button></li>
  </ul>

  <div class="morph-nav__dropdown">
    <span class="morph-nav__caret" aria-hidden="true"></span>
    <div class="morph-nav__plate">
      <div class="morph-nav__pane morph-nav__pane--grid" id="menu-products">
        <a href="/payments/"><strong>オンライン決済</strong><span>ここに説明文が入ります。</span></a>
        <a href="/billing/"><strong>定期課金</strong><span>ここに説明文が入ります。</span></a>
        <a href="/connect/"><strong>プラットフォーム決済</strong><span>ここに説明文が入ります。</span></a>
        <a href="/terminal/"><strong>店頭決済</strong><span>ここに説明文が入ります。</span></a>
      </div>
      <div class="morph-nav__pane" id="menu-solutions">
        <p class="morph-nav__label">事業の規模から探す</p>
        <a href="/startups/"><strong>スタートアップ</strong><span>ここに説明文が入ります。</span></a>
        <a href="/enterprise/"><strong>大企業</strong><span>ここに説明文が入ります。</span></a>
        <p class="morph-nav__label">業種から探す</p>
        <a href="/ecommerce/"><strong>ネットショップ</strong></a>
        <a href="/saas/"><strong>SaaS</strong></a>
        <a href="/marketplaces/"><strong>マーケットプレイス</strong></a>
      </div>
      <div class="morph-nav__pane" id="menu-developers">
        <a href="/docs/"><strong>ドキュメント</strong><span>ここに説明文が入ります。</span></a>
        <a href="/api/"><strong>APIリファレンス</strong><span>ここに説明文が入ります。</span></a>
        <a href="/changelog/"><strong>更新履歴</strong><span>ここに説明文が入ります。</span></a>
      </div>
      <div class="morph-nav__pane" id="menu-company">
        <a href="/company/"><strong>会社概要</strong></a>
        <a href="/customers/"><strong>導入事例</strong></a>
        <a href="/careers/"><strong>採用情報</strong></a>
        <a href="/blog/"><strong>ブログ</strong></a>
      </div>
    </div>
  </div>
</nav>

CSS
.morph-nav {
  --surface: #ffffff;               /* 板 */
  --ink: #262626;                   /* 文字 */
  --mute: #6b6b6b;                  /* 説明・ラベル */
  --line: rgba(38, 38, 38, 0.12);   /* 板の縁 */
  --gap: 12px;                      /* ナビと板のあいだ */
  position: relative; /* 項目の offsetLeft をナビからの距離にする */
  height: 48px;
  color: var(--ink);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
}

.morph-nav__menu { display: flex; height: 100%; margin: 0; padding: 0; list-style: none; }

.morph-nav__trigger {
  display: flex;
  align-items: center;
  gap: 6px;
  height: 100%; /* 当たり判定をナビの高さいっぱいにする */
  padding: 0 14px;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  white-space: nowrap;
  cursor: pointer;
}

/* 下向きの矢印 */
.morph-nav__trigger::after {
  content: "";
  width: 5px;
  height: 5px;
  border: solid currentColor;
  border-width: 0 1.5px 1.5px 0;
  transform: translateY(-2px) rotate(45deg);
}

.morph-nav__trigger:focus-visible,
.morph-nav__pane a:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: -2px;
  border-radius: 4px;
}

/* 板と三角の入れ物。上の余白は、ナビから板へ下りるあいだにポインタが外へ出ないための橋 */
.morph-nav__dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 10;
  padding-top: var(--gap);
  visibility: hidden;
  opacity: 0;
}

/* 1 枚の板。幅・高さはスクリプトが中身に合わせて補間し、はみ出した中身は切る */
.morph-nav__plate {
  position: relative;
  overflow: hidden;
  border-radius: 4px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
}

/* 上の 2 辺に縁を引いた正方形を 45° 回し、下半分で板の縁の線を隠す */
.morph-nav__caret {
  position: absolute;
  top: var(--gap);
  left: -7px;
  z-index: 1;
  width: 14px;
  height: 14px;
  margin-top: -7px;
  border: solid var(--line);
  border-width: 1px 0 0 1px;
  border-top-left-radius: 2px;
  background: var(--surface);
  transform: rotate(45deg);
}

.morph-nav__pane { position: absolute; top: 0; left: 0; width: max-content; max-width: calc(100vw - 32px); padding: 20px 24px; box-sizing: border-box; visibility: hidden; }
.morph-nav__pane--grid { display: grid; grid-template-columns: repeat(2, auto); column-gap: 32px; }
.morph-nav__pane a { display: block; padding: 8px 0; border-radius: 4px; color: inherit; text-decoration: none; }
.morph-nav__pane a:hover strong { text-decoration: underline; text-underline-offset: 3px; }
.morph-nav__pane strong { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.morph-nav__pane span { display: block; margin-top: 2px; color: var(--mute); font-size: 13px; font-weight: 400; word-break: auto-phrase; }
.morph-nav__label { margin: 8px 0 4px; color: var(--mute); font-size: 12px; font-weight: 700; letter-spacing: 0.06em; }

@media (max-width: 600px) {
  .morph-nav__trigger { gap: 4px; padding: 0 6px; font-size: 13px; letter-spacing: 0.02em; }
  .morph-nav__pane--grid { grid-template-columns: 1fr; }
}

JavaScript
import { gsap } from "gsap";

// 動きを減らす設定では、時間を 0 にしてその場で切り替える
const D = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 1;
const SHIFT = 40; // 中身が入れ替わるときの横のずれ(px)
const EASE = "power3.out";

document.querySelectorAll(".morph-nav").forEach((nav) => {
  const triggers = [...nav.querySelectorAll(".morph-nav__trigger")];
  const dropdown = nav.querySelector(".morph-nav__dropdown");
  const plate = nav.querySelector(".morph-nav__plate");
  const caret = nav.querySelector(".morph-nav__caret");
  const panes = triggers.map((trigger) => document.getElementById(trigger.getAttribute("aria-controls")));
  let current = -1; // 開いている項目
  let hovered = false; // ポインタを乗せて開いたか(乗せて開いた直後の click では閉じない)
  let closing = null;
  // 項目 i の真下に置く板の位置と大きさ(ナビの幅からはみ出さない)。三角の位置は板の左端から
  const place = (i) => {
    const w = panes[i].offsetWidth;
    const center = triggers[i].offsetLeft + triggers[i].offsetWidth / 2;
    const x = Math.max(0, Math.min(center - w / 2, nav.clientWidth - w));
    return { x, w, h: panes[i].offsetHeight, caret: center - x };
  };

  const show = (i) => {
    closing?.kill();
    if (i === current) return;
    const to = place(i);
    const from = current;
    // 待っている古い動き(遅れて始まる入れ替えなど)を止めてから、新しい動きを積む
    gsap.killTweensOf([dropdown, plate, caret, ...panes]);
    if (from < 0) {
      // 閉じていたら、板はその場の大きさで開く
      gsap.set(panes, { autoAlpha: 0, x: 0 });
      gsap.set(dropdown, { x: to.x });
      gsap.set(plate, { width: to.w, height: to.h });
      gsap.set(caret, { x: to.caret });
      gsap.to(panes[i], { autoAlpha: 1, duration: 0.2 * D });
      gsap.fromTo(dropdown, { y: -10 }, { autoAlpha: 1, y: 0, duration: 0.3 * D, ease: EASE });
    } else {
      // 板は 1 枚のまま、次の位置と大きさへ。中身は移る向きにずれながら、前のものが消えてから入れ替わる
      const dir = Math.sign(i - from);
      gsap.to(dropdown, { autoAlpha: 1, x: to.x, y: 0, duration: 0.45 * D, ease: EASE });
      gsap.to(plate, { width: to.w, height: to.h, duration: 0.45 * D, ease: EASE });
      gsap.to(caret, { x: to.caret, duration: 0.45 * D, ease: EASE });
      gsap.to(panes[from], { autoAlpha: 0, duration: 0.12 * D, ease: "power1.out" });
      gsap.to(panes[from], { x: -dir * SHIFT, duration: 0.3 * D, ease: "power2.in" });
      gsap.fromTo(panes[i], { autoAlpha: 0, x: dir * SHIFT }, { autoAlpha: 1, x: 0, duration: 0.45 * D, delay: 0.12 * D, ease: EASE });
    }
    triggers.forEach((trigger, k) => trigger.setAttribute("aria-expanded", String(k === i)));
    current = i;
  };
  const hide = () => {
    if (current < 0) return;
    triggers[current].setAttribute("aria-expanded", "false");
    current = -1;
    gsap.to(dropdown, { autoAlpha: 0, y: -6, duration: 0.2 * D, ease: "power2.in" });
  };
  triggers.forEach((trigger, i) => {
    // ポインタは乗せただけで開く。タッチとキーボードは押して開け閉めする
    trigger.addEventListener("pointerenter", (event) => {
      hovered = event.pointerType === "mouse";
      if (hovered) show(i);
    });
    trigger.addEventListener("click", () => {
      current === i && !hovered ? hide() : show(i);
      hovered = false;
    });
    // 開いた項目から Tab を押すと、その中身へ入る(中身は DOM ではメニューの後ろにある)
    trigger.addEventListener("keydown", (event) => {
      if (event.key !== "Tab" || event.shiftKey || current !== i) return;
      event.preventDefault();
      panes[i].querySelector("a")?.focus();
    });
    // 中身の端から Tab で出るときは、項目の並びへ戻す
    panes[i].addEventListener("keydown", (event) => {
      const links = panes[i].querySelectorAll("a");
      const edge = event.shiftKey ? links[0] : links[links.length - 1];
      const next = event.shiftKey ? trigger : triggers[i + 1];
      if (event.key !== "Tab" || document.activeElement !== edge || !next) return;
      event.preventDefault();
      if (!event.shiftKey) hide();
      next.focus();
    });
  });
  // 出てすぐには閉じない(斜めに板へ移るあいだに閉じないよう、0.12 秒待つ)
  nav.addEventListener("pointerleave", (event) => {
    hovered = false;
    if (event.pointerType === "mouse") closing = gsap.delayedCall(0.12, hide);
  });
  nav.addEventListener("pointerenter", () => closing?.kill());
  nav.addEventListener("keydown", (event) => {
    if (event.key !== "Escape" || current < 0) return;
    triggers[current].focus(); // 焦点を項目に戻してから閉じる
    hide();
  });
  nav.addEventListener("focusout", (event) => !nav.contains(event.relatedTarget) && hide());
  document.addEventListener("pointerdown", (event) => !nav.contains(event.target) && hide());
});

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

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