JavaScript

アンカーリンクでアコーディオンを自動で開く!JavaScriptで実装するスムーススクロール

みなと

以前の記事で、アコーディオンUIの基本的な実装方法を解説しました

あわせて読みたい
アコーディオンをJavaScriptで自作する|基本形から応用まで実装例付きで解説
アコーディオンをJavaScriptで自作する|基本形から応用まで実装例付きで解説

今回はその応用編として、アンカーリンクをクリックするとページがスムーズにスクロールし、対応するアコーディオン項目が自動で開く実装を紹介します。

単純にアンカーリンクで該当箇所へスクロールするだけでは、アコーディオンが閉じたままで内容が見えない状態になってしまいます。その場合、ユーザーはリンクをクリックしたあとに、さらに自分でアコーディオンを開く手間がかかってしまいます。

本記事では、こうした手間を解消するために、アンカーリンクとアコーディオンを連動させたスムーズスクロールの実装方法を紹介します。ライブラリには頼らず、純粋なJavaScriptだけで軽量に実装しているので、さまざまな場面で応用できます。

みなと
みなと

実務でも「アンカーでスクロールしたときに、アコーディオンを開いた状態にしてほしい」と要望をもらうことが何度かありました。それ以来、自分から「そうしておこうか?」と提案するようにしています。

スワン
スワン

こういう細かいところを丁寧に作り込んでおくと、ユーザーにとって親切なサイトになりますし、UXの面でも差が出るポイントです!

完成デモの紹介

では、実際の動きを見てみましょう。アンカーリンクをクリックすると、対応する項目までスムーズにスクロールし、アコーディオンが自動で開きます。

アンカーリンクをクリックすると該当の質問までスクロールし、アコーディオンが自動で開くデモ画面

リンクをクリックするだけで、ページが目的の質問まで移動し、そのまま回答が表示されます。1クリックでスクロールと展開が同時に行われるため、ユーザーの二度手間を防げます。

コード全体(HTML / CSS / JavaScript)

今回のデモは、HTML・CSS・JavaScriptで構成されています。それぞれの役割を簡単に見ながら、全体のコードを確認していきましょう。

HTMLコード

FAQページを想定し、ナビゲーション部分とアコーディオン部分をHTMLで構成しています。マークアップはシンプルですが、クラス名を整理しておくことで、CSSやJavaScriptから扱いやすくしています。

<!-- FAQナビゲーション:ページ内の各質問セクションへのアンカーリンク -->
<ul class="faq-nav">
  <li>
    <a href="#shipping">
      <span class="faq-nav-label">商品の発送について<span class="faq-nav-icon"></span></span>
    </a>
  </li>
  <li>
    <a href="#payment">
      <span class="faq-nav-label">支払い方法の変更について<span class="faq-nav-icon"></span></span>
    </a>
  </li>
  <li>
    <a href="#account">
      <span class="faq-nav-label">アカウント統合について<span class="faq-nav-icon"></span></span>
    </a>
  </li>
</ul>

<!-- FAQ本体エリア -->
<section class="faq-section">
  <h2 class="faq-heading">よくある質問</h2>
  <!-- 質問と回答のリスト -->
  <ul class="faq-accordion">
    <!-- 各<li>が1つの質問・回答ブロック -->
    <li id="shipping">
      <!-- 質問部分(クリックで開閉) -->
      <div class="faq-accordion-q">
        <button type="button">
          <span class="faq-accordion-q-prefix">Q.</span>
          <span class="faq-accordion-q-content">ダミー:商品の発送は注文から何日くらいで行われますか?</span>
          <span class="faq-accordion-q-icon"></span>
        </button>
      </div>

      <!-- 回答部分(開閉対象) -->
      <div class="faq-accordion-a">
        <div class="faq-accordion-a-inner">
          <div class="faq-accordion-a-prefix">A.</div>
          <div class="faq-accordion-a-content"><!-- 回答内容(省略) --></div>
        </div>
      </div>
    </li>

    <li id="payment">
      <div class="faq-accordion-q">
        <button type="button">
          <span class="faq-accordion-q-prefix">Q.</span>
          <span class="faq-accordion-q-content">ダミー:支払い方法を途中で変更したい場合、どの画面から手続きをすればよいですか?</span>
          <span class="faq-accordion-q-icon"></span>
        </button>
      </div>
      <div class="faq-accordion-a">
        <div class="faq-accordion-a-inner">
          <div class="faq-accordion-a-prefix">A.</div>
          <div class="faq-accordion-a-content"><!-- 回答内容(省略) --></div>
        </div>
      </div>
    </li>

    <li id="account">
      <div class="faq-accordion-q">
        <button type="button">
          <span class="faq-accordion-q-prefix">Q.</span>
          <span class="faq-accordion-q-content">ダミー:複数のアカウントを1つに統合することは可能ですか?</span>
          <span class="faq-accordion-q-icon"></span>
        </button>
      </div>
      <div class="faq-accordion-a">
        <div class="faq-accordion-a-inner">
          <div class="faq-accordion-a-prefix">A.</div>
          <div class="faq-accordion-a-content"><!-- 回答内容(省略) --></div>
        </div>
      </div>
    </li>
  </ul>
</section>

CSSコード

デザイン面の調整を行っている部分です。レイアウトの整え方や開閉アイコンの動き、余白などをCSSで制御しています。PCとスマホの両方で見やすくなるよう、レスポンシブにも対応しています。

/* ================================
  FAQナビゲーション(アンカーリンク)
  ================================ */

.faq-nav {
  border-top: 1px solid rgba(25, 39, 83, 0.2);
  border-bottom: 1px solid rgba(25, 39, 83, 0.2);
}

@media (min-width: 768px) {
  .faq-nav {
    display: flex;
    justify-content: center;
    gap: 50px;
    margin-bottom: 80px;
  }
}

@media (max-width: 767px) {
  .faq-nav {
    margin-bottom: 50px;
    padding: 15px 0;
  }
}

/* 各ナビ項目のリンク基本スタイル */
.faq-nav > li > a {
  display: flex;
  align-items: center;
  color: inherit;
  font-size: 16px;
  line-height: 1.5;
  text-decoration: none;
}

@media (min-width: 768px) {
  .faq-nav > li > a {
    height: 70px;
    transition: opacity 400ms;
  }
}

@media (max-width: 767px) {
  .faq-nav > li > a {
    padding: 5px 10px;
  }
}

/* ラベル+アイコン全体のラッパー */
.faq-nav-label {
  display: block;
  position: relative;
  padding-right: 25px;
}

/* 矢印アイコン(円背景+矢印) */
.faq-nav-icon {
  display: block;
  position: absolute;
  right: 0;
  top: 50%;
  width: 18px;
  height: 18px;
  margin-top: -8.5px;
  border-radius: 50%;
  background: #192753;
}

.faq-nav-icon::before {
  content: "";
  display: block;
  position: absolute;
  left: 50%;
  top: 50%;
  width: 7px;
  height: 7px;
  margin: -5px 0 0 -3.5px;
  box-sizing: border-box;
  border-top: 2px solid #fff;
  border-right: 2px solid #fff;
  transform: rotate(135deg);
}

@media (min-width: 768px) {
  .faq-nav > li > a:hover,
  .faq-nav > li > a:active {
    opacity: 0.5;
  }
}

/* ================================
  FAQセクション見出し
  ================================ */

.faq-heading {
  color: #192753;
  font-weight: 700;
  line-height: 1.5;
  text-align: center;
}

@media (min-width: 768px) {
  .faq-heading {
    margin-bottom: 30px;
    font-size: 30px;
  }
}

@media (max-width: 767px) {
  .faq-heading {
    margin-bottom: 25px;
    font-size: 24px;
  }
}

/* ================================
  FAQアコーディオン(Q&A部分)
  ================================ */

/* 質問リスト全体 */
.faq-accordion {
  list-style: none;
  border-top: 1px solid rgba(25, 39, 83, 0.2);
}

/* Q. の表示(左側固定配置) */
.faq-accordion-q-prefix {
  display: block;
  position: absolute;
  font-family: "Roboto", sans-serif;
  font-weight: 500;
  font-variation-settings: "wdth" 75;
  line-height: 1;
}

@media (min-width: 768px) {
  .faq-accordion-q-prefix {
    left: 20px;
    top: 24px;
    font-size: 30px;
  }
}

@media (max-width: 767px) {
  .faq-accordion-q-prefix {
    left: 10px;
    top: 20px;
    font-size: 24px;
  }
}

/* 質問テキスト */
.faq-accordion-q-content {
  display: block;
  font-weight: 400;
  line-height: 1.6;
}

@media (min-width: 768px) {
  .faq-accordion-q-content {
    font-size: 20px;
  }
}

@media (max-width: 767px) {
  .faq-accordion-q-content {
    font-size: 16px;
  }
}

/* 開閉アイコン(+/−) */
.faq-accordion-q-icon {
  display: block;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}

@media (min-width: 768px) {
  .faq-accordion-q-icon {
    right: 20px;
    width: 20px;
    height: 20px;
  }
}

@media (max-width: 767px) {
  .faq-accordion-q-icon {
    right: 10px;
    width: 16px;
    height: 16px;
  }
}

.faq-accordion-q-icon::before,
.faq-accordion-q-icon::after {
  content: "";
  display: block;
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  background: #333;
}

@media (min-width: 768px) {
  .faq-accordion-q-icon::before,
  .faq-accordion-q-icon::after {
    top: 9px;
  }
}

@media (max-width: 767px) {
  .faq-accordion-q-icon::before,
  .faq-accordion-q-icon::after {
    top: 7px;
  }
}

.faq-accordion-q-icon::after {
  transform: rotate(90deg);
  transition: transform 400ms;
}

/* 質問ボタン本体 */
.faq-accordion-q button {
  display: block;
  position: relative;
  width: 100%;
  box-sizing: border-box;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  appearance: none;
  cursor: pointer;
  transition: opacity 400ms;
}

@media (min-width: 768px) {
  .faq-accordion-q button {
    padding: 25px 60px 25px 57px;
  }
}

@media (max-width: 767px) {
  .faq-accordion-q button {
    padding: 20px 36px 20px 41px;
  }
}

@media (min-width: 768px) {
  .faq-accordion-q button:hover,
  .faq-accordion-q button:active {
    opacity: 0.5;
  }
}

/* 回答部分:非表示が初期状態 */
.faq-accordion-a {
  display: none;
  overflow: hidden; /* 開閉アニメ用 */
}

/* 回答内の余白調整(Q.との位置関係を保つ) */
.faq-accordion-a-inner {
  position: relative;
}

@media (min-width: 768px) {
  .faq-accordion-a-inner {
    padding: 5px 0 30px 57px;
  }
}

@media (max-width: 767px) {
  .faq-accordion-a-inner {
    padding: 5px 0 25px 41px;
  }
}

/* A. の表示(左側固定配置) */
.faq-accordion-a-prefix {
  position: absolute;
  font-family: "Roboto", sans-serif;
  font-weight: 500;
  font-variation-settings: "wdth" 75;
  line-height: 1;
}

@media (min-width: 768px) {
  .faq-accordion-a-prefix {
    left: 20px;
    top: 1px;
    font-size: 30px;
  }
}

@media (max-width: 767px) {
  .faq-accordion-a-prefix {
    left: 10px;
    top: 5px;
    font-size: 24px;
  }
}

/* 各項目の区切り線 */
.faq-accordion > li {
  border-bottom: 1px solid rgba(25, 39, 83, 0.2);
}

/* 開いた状態でアイコンを−に変化 */
.faq-accordion > li.is-open .faq-accordion-q-icon::after {
  transform: rotate(0deg);
}

/* ==============================
  その他の装飾は本題外のため省略
============================== */

JavaScriptコード

アンカーリンクによるスクロールと、アコーディオンの開閉をまとめて制御しています。ライブラリには頼らず、すべて純粋なJavaScriptで実装。クリックひとつでスムーズに移動し、内容が自動で開く仕組みになっています。

/* =======================================
  スムーススクロール(カスタムイージング対応)
  ======================================= */

/**
 * 指定座標まで、イージング付きでスムーススクロールする関数
 * @param {number} targetY - スクロール先のY座標(絶対位置)
 * @param {number} duration - アニメーション時間(ミリ秒)
 * @param {function} easingFunc - 0〜1の進捗を受け取り0〜1を返すイージング関数
 */
function scrollToWithEasing(targetY, duration = 600, easingFunc = t => t) {
  const startY = window.scrollY; // 現在位置
  const diff = targetY - startY; // 移動距離
  let startTime = null;

  // 毎フレーム実行されるループ関数
  function loop(currentTime) {
    if (!startTime) startTime = currentTime;
    const elapsed = currentTime - startTime; // 経過時間
    const progress = Math.min(elapsed / duration, 1); // 0〜1に正規化

    const easedProgress = easingFunc(progress); // イージング適用
    window.scrollTo(0, startY + diff * easedProgress); // 実際のスクロール

    if (progress < 1) {
      requestAnimationFrame(loop); // 終了まで繰り返す
    }
  }

  requestAnimationFrame(loop);
}

/**
 * easeOutCubic(ゆっくり止まる)イージング関数
 * t: 0〜1の進捗 → 戻り値: 0〜1の補正値
 */
function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3);
}

/* =======================================
  FAQナビクリック → 対応する項目へスムーススクロール
  ======================================= */

document.querySelectorAll('.faq-nav a').forEach((link) => {
  link.addEventListener('click', (e) => {
    e.preventDefault();

    // href属性から対象IDを取得(例:#shipping → shipping)
    const targetId = link.hash.slice(1);
    const targetLi = document.getElementById(targetId);
    if (!targetLi) return;

    // スクロール先の項目を瞬時に開く(開閉アニメは行わない)
    targetLi.classList.add('is-open');
    const answer = targetLi.querySelector('.faq-accordion-a');
    answer.style.display = 'block';

    // ページ全体のスクロール位置を計算
    const targetPosition = targetLi.getBoundingClientRect().top + window.scrollY;

    // イージング付きでスクロール
    scrollToWithEasing(targetPosition, 700, easeOutCubic);
  });
});

/* =======================================
  FAQアコーディオン(Qクリックで開閉)
  ======================================= */

document.querySelectorAll('.faq-accordion').forEach((accordion) => {
  // アニメーション関連の設定値
  const props = {
    isAnimating: false, // 開閉アニメーション中の多重操作(連打・別項目クリックなど)を防ぐフラグ
    slideDuration: 400,
    slideEasing: 'cubic-bezier(0.215, 0.61, 0.355, 1)', // CSS的なイージング
  };

  /**
   * 回答を開くアニメーション
   */
  function answerShow(li) {
    props.isAnimating = true;
    li.classList.add('is-open');

    const answer = li.querySelector('.faq-accordion-a');
    answer.style.display = 'block'; // 高さ計測のため一時的に表示
    const startHeight = 0;
    const endHeight = answer.scrollHeight; // コンテンツ本来の高さ
    
    // 高さを0→コンテンツ高さまでアニメーション
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.height = ''; // 高さをautoに戻す
      props.isAnimating = false;
    };
  }

  /**
   * 回答を閉じるアニメーション
   */
  function answerHide(li) {
    props.isAnimating = true;
    li.classList.remove('is-open');

    const answer = li.querySelector('.faq-accordion-a');
    
    const startHeight = answer.scrollHeight;
    const endHeight = 0;
    
    // 高さをコンテンツ高さ→0にアニメーション
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.display = ''; // display:none に戻る
      answer.style.height = '';
      props.isAnimating = false;
    };
  }

  // 各質問ボタンに開閉イベントを設定
  accordion.querySelectorAll('li').forEach((li) => {
    li.querySelector('.faq-accordion-q button').addEventListener('click', () => {
      if (!props.isAnimating) { // アニメ中は操作無効
        if (!li.classList.contains('is-open')) {
          answerShow(li); // 閉じている → 開く
        } else {
          answerHide(li); // 開いている → 閉じる
        }
      }
    });
  });
});

コードのポイント解説

全体の流れはシンプルですが、実際に連動させるためにはいくつか工夫が必要です。ここでは、動きのポイントとなる部分を中心に、JavaScriptの実装を解説していきます。

ポイント1:スムーズスクロールを独自関数で制御

今回のスムーススクロールは、ブラウザ標準の scrollTo({ behavior: 'smooth' }) を使わずに、自作のアニメーション関数で制御しています。

標準のスムーススクロールは手軽ですが、動きの速さや減速の仕方(イージング)を細かく調整できないという制約があります。そこで、requestAnimationFrame を使ってフレームごとに位置を計算し、easeOutCubic というイージング関数を使って自然な減速を表現しています。

function scrollToWithEasing(targetY, duration = 600, easingFunc = t => t) {
  const startY = window.scrollY; // 現在位置
  const diff = targetY - startY; // 移動距離
  let startTime = null;

  // 毎フレーム実行されるループ関数
  function loop(currentTime) {
    if (!startTime) startTime = currentTime;
    const elapsed = currentTime - startTime; // 経過時間
    const progress = Math.min(elapsed / duration, 1); // 0〜1に正規化

    const easedProgress = easingFunc(progress); // イージング適用
    window.scrollTo(0, startY + diff * easedProgress); // 実際のスクロール

    if (progress < 1) {
      requestAnimationFrame(loop); // 終了まで繰り返す
    }
  }

  requestAnimationFrame(loop);
}

このように関数としてまとめておくと、スクロールのスピード感や止まり方を自由に変えられるのがメリットです。たとえば、easeOutCubic の代わりに easeInOutCubic を使うと、「ゆっくり始まって、ゆっくり止まる」ような自然な動きにもできます。

イージングの種類や動きの違いは、下記のサイトで視覚的に確認できます
Easings.net(日本語版)

スワン
スワン

ブラウザ標準のスムーススクロールより少し手間はかかりますが、一度作ってしまえば、いろんなプロジェクトでそのまま使い回せます!

細かい動きの調整までは不要という場合は、scrollTo({ behavior: 'smooth' }) や CSS の scroll-behavior: smooth; を使うのもOKです。実装がシンプルになるので、軽いページではこちらのほうが手軽です。

ポイント2:アンカーリンクとアコーディオンを連動させる

今回の実装のメインとなる部分です。ナビゲーションのアンカーリンクをクリックすると、ページがスムーズにスクロールし、対応するアコーディオン項目が自動で開くようにしています。

リンクの href 属性からターゲット要素の ID を取得したあと、JavaScriptで次の2つの処理を行っています。

  • 対応する <li>.is-open クラスを付与して、アイコンを「+」から「−」に切り替える
  • 回答部分(.faq-accordion-a)に display: block; を適用して、内容を表示する
document.querySelectorAll('.faq-nav a').forEach((link) => {
  link.addEventListener('click', (e) => {
    e.preventDefault();

    // href属性から対象IDを取得(例:#shipping → shipping)
    const targetId = link.hash.slice(1);
    const targetLi = document.getElementById(targetId);
    if (!targetLi) return;

    // スクロール先の項目を瞬時に開く(開閉アニメは行わない)
    targetLi.classList.add('is-open');
    const answer = targetLi.querySelector('.faq-accordion-a');
    answer.style.display = 'block';

    // ページ全体のスクロール位置を計算
    const targetPosition = targetLi.getBoundingClientRect().top + window.scrollY;

    // イージング付きでスクロール
    scrollToWithEasing(targetPosition, 700, easeOutCubic);
  });
});

ちなみに、各質問ボタンのクリック時には、answerShow() という関数が回答を開くアニメーションを担っています。ナビゲーションのアンカーリンクをクリックしたときにも、この関数を使う方法はありますが、スムーススクロール中に同時に実行しても見た目上ほとんど気づかれず、アニメーションが重なることで動きがぎこちなく感じられることがあります。

そのため今回は、アコーディオン部分は answerShow() を使わず、瞬時に開く仕様にしています。

ポイント3:開閉アニメーションは Web Animations API で実装

アコーディオンの開閉アニメーションには、Web Animations API を使っています。これは CSS の transitionanimation と違い、JavaScriptから直接アニメーションを制御できる比較的新しい仕組みです。

今回のコードでは、アコーディオンを開く関数 answerShow() と閉じる関数 answerHide() の中で、このAPIを使って高さをアニメーションしています。

answer.animate([
  { height: `${startHeight}px` },
  { height: `${endHeight}px` }
], {
  duration: props.slideDuration,
  easing: props.slideEasing
}).onfinish = () => {
  answer.style.height = ''; // 高さをautoに戻す
  props.isAnimating = false;
};

流れとしては以下のようになります

  1. 一度 display: block にして、要素の実際の高さ(scrollHeight)を取得
  2. 高さを 0 → scrollHeight に変化させるアニメーションを実行
  3. アニメーション終了後に高さ指定をリセットして、自然な状態に戻す

この仕組みにより、CSSだけでは難しい「高さが可変の要素」にもアニメーションを適用できます。

スワン
スワン

CSSのtransitionだと、height: auto;みたいに値が決まっていない要素にはアニメーションをかけられません。Web Animations APIなら、実際の高さを取得して動かせるからスムーズに開閉できます!

また、props.slideEasing では cubic-bezier(0.215, 0.61, 0.355, 1) を指定しています。これは easeOutCubic に近い“終わりで減速する”動き になっており、開閉アニメーションが自然に見えるよう調整しています。

ちなみに、Web Animations API の easing は CSSアニメーションと同じ指定方法 です。さっき紹介した easeOutCubic() のような JavaScript 関数を使うのではなく、easeease-inease-out などのキーワード指定のほか、cubic-bezier() で細かく調整することもできます。

まとめ

アンカーリンクとアコーディオンを組み合わせることで、ユーザーはリンクをクリックするだけで目的の項目をすぐ確認できるようになります。FAQページのように情報量が多いページでは、こうした小さな工夫が使いやすさにつながります。

今回紹介したコードはライブラリに依存していないので、シンプルに流用でき、プロジェクトごとのカスタマイズもしやすいのがメリットです。

細かい部分まで気を配ることで、「なんか使いやすい」と感じてもらえるUIを目指していきましょう!

押していただけると励みになります!

ABOUT ME
みなと
みなと
フロントエンドエンジニア
東京のWeb制作会社で15年以上働いている現役フロントエンドエンジニアです。これまで、いろんなプロジェクトに関わりながら、フロントエンド開発やWebデザインに取り組んできました。このブログでは、今までの経験を活かして、Web制作に役立つ情報やノウハウをシェアしていきたいと思います。初心者の方から、現場で働く方まで、誰でも参考にできる内容をお届けしますので、ぜひ覗いてみてください。
記事URLをコピーしました