JavaScript

アンカーリンクでアコーディオンを初期表示で開く方法|JavaScript実装ガイド

みなと
UI実装担当
UI実装担当

FAQページを作ったけど、リンクから開いたときに、特定の質問を最初から開いた状態にしたい…

FAQページなどでは、特定の質問をピンポイントで案内したい場面がよくあります。その際、URLを開いた時点で該当のアコーディオンが開いていれば、ユーザーは探すことなく、すぐに目的の情報を確認できます。

前回の記事では、アンカーリンクをクリックしたときにアコーディオンを開く方法を紹介しました。

あわせて読みたい
アンカーリンクでアコーディオンを自動で開く!JavaScriptで実装するスムーススクロール
アンカーリンクでアコーディオンを自動で開く!JavaScriptで実装するスムーススクロール

今回はその応用として、ページにアクセスしたタイミングで、指定したアコーディオンを開いた状態にする方法を解説します。

今回の実装方針
  • スムーススクロールなどの演出は行わない(応用例として後半で紹介します)
  • ブラウザ標準のアンカー挙動をそのまま利用する
  • JavaScriptは、既存の開閉処理に加えて、初期表示時の状態制御を行う

初期表示でアコーディオンを開くための処理を、順を追って見ていきましょう!

なお、アコーディオンの基本的な実装については、以下の記事で解説しているので、あわせて参考にしてみてください。

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

完成デモの紹介

まずは、完成デモを確認してみましょう。以下のリンクから、それぞれ異なるFAQ項目を指定してページを開くことができます。

商品の発送に関するQ&Aを開いた状態で、デモページを表示
支払い方法の変更に関するQ&Aを開いた状態で、デモページを表示
アカウント統合に関するQ&Aを開いた状態で、デモページを表示

いずれのリンクでも、URLのアンカー(#)に対応した項目が、ページ表示時に自動で開いた状態になります。スクロールは制御しておらず、アンカー(#)によるブラウザのデフォルト挙動を利用しています。

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

まずは、今回のデモで使用しているコード全体を確認します。各コードにはポイントとなる部分にコメントを入れているので、処理の流れを意識しながら見てみてください。

アコーディオンの基本的な開閉処理については前回の記事とほぼ同じ構成です。基本部分の実装を詳しく知りたい場合は、あわせて前回の記事も参考にしてみてください。

HTMLコード

HTMLでは、各アコーディオン項目にidを付け、アンカー(#)で直接指定できるようにしています。初期表示の制御に関係するのは、このidの指定部分です。

<!-- FAQ本体エリア -->
<section class="faq-section">
  <h2 class="faq-heading">よくある質問</h2>
  <!-- 質問と回答のリスト -->
  <ul class="faq-accordion">
    <!--
      各<li>が1つの質問・回答ブロック
      アンカー(#)で直接指定できるように、各アコーディオン項目に id を付与している
      ※ 初期表示の制御に関係するポイント
    -->
    <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>

    <!-- 初期表示制御用の id -->
    <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>

    <!-- 初期表示制御用の id -->
    <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では、アコーディオンの開閉に関わるスタイルと、開いた状態を示すためのクラス指定を行っています。is-openクラスが付与されると、アイコンが「+」から「−」に切り替わるようになっています。

/* ================================
  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では、クリックによるアコーディオンの開閉処理に加えて、ページアクセス時にアンカー(#)を判定し、該当項目を開いた状態にする処理を実装しています。スクロールの制御は行わず、アンカーによる移動はブラウザの標準挙動に任せています。

/* =======================================
  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); // 開いている → 閉じる
        }
      }
    });
  });
});

/* =======================================
  初期表示時の処理(アンカー指定で開いた状態にする)
  ======================================= */
document.addEventListener("DOMContentLoaded", () => {
  // URLの「#」以降(アンカー名)を取得
  const hash = location.hash.slice(1);

  // アンカー指定がない場合は何もしない
  if (!hash) return;

  // アンカー名と同じ id を持つFAQ項目(li)を取得
  const targetLi = document.getElementById(hash);

  // 対応する要素が存在しない場合は処理を中断
  if (!targetLi) return;

  // 対象のFAQ項目の+アイコンを−表示に切り替える
  targetLi.classList.add('is-open');

  // 回答部分を即時表示させる
  const answer = targetLi.querySelector('.faq-accordion-a');
  answer.style.display = 'block';
});

コードのポイント解説

ここでは、今回追加した「初期表示時の処理」を中心に、実装のポイントを解説します。

ポイント:初期表示時に指定のアコーディオンを開く仕組み

初期表示時の処理は、アンカー(#)をもとに対象のアコーディオンを特定し、開いた状態にするだけのシンプルな仕組みです。

1)location.hashから#を取り除いてidと一致させる

const hash = location.hash.slice(1);
if (!hash) return;

location.hash#shippingのように#を含んだ文字列で取得されます。一方、getElementById()に渡すのはshippingのように#を除いたid名なので、slice(1)で先頭1文字を削っています。

また、アンカー指定がない場合は何もする必要がないので、早めにreturnして処理を終了します。

2)idが一致するliを取得する

const targetLi = document.getElementById(hash);
if (!targetLi) return;

HTML側では各FAQ項目にidを付けています。

<li id="shipping">...</li>
<li id="payment">...</li>
<li id="account">...</li>

そのため、URLが.../#paymentならpaymentliが取得できます。もし存在しないアンカー(例:#foo)でアクセスされた場合に備えて、見つからなければ処理を中断しています。

3)is-openを付けて「開いている状態」にする(+→−)

targetLi.classList.add('is-open');

この処理では、対象のFAQ項目にis-openクラスを付与しています。CSS側では、このクラスが付くことで、アイコンが「+」から「−」に切り替わります。

.faq-accordion > li.is-open .faq-accordion-q-icon::after {
  transform: rotate(0deg);
}

また、クリック時の開閉判定でもis-openを使っているため、初期表示でもis-openを付けておくことで状態がズレないようにしています。

4)回答部分をdisplay: block;にして中身を表示する

const answer = targetLi.querySelector('.faq-accordion-a');
answer.style.display = 'block';

CSSでは、回答部分が初期状態で非表示になっています。

.faq-accordion-a {
  display: none;
}

初期表示時は、開閉アニメーションは行わず、display: block;を指定して、回答をそのまま表示しています。

なお、スクロール位置の制御は行っていません。アンカー(#)を指定したときに目的の位置まで移動するのは、ブラウザの標準挙動です。

応用例:CSSだけでスムーススクロールしたい場合

アンカー移動をスムーススクロールにしたいだけであれば、JavaScriptを使わずに、CSSだけで対応することもできます。

html {
  scroll-behavior: smooth;
}

このように指定しておくと、アンカー(#)を指定したときの移動が、自動的にスムーススクロールになります。今回のように、スクロール処理をブラウザの標準挙動に任せている構成であれば、最小限の指定で簡単に導入できる方法です。

ただし、この方法にはいくつか制限があります。

  • スクロールのイージング(動きの加速・減速)を細かく調整できない
  • 追従ヘッダーなどがある場合に、スクロール位置を補正できない

より細かな制御が必要な場合は、JavaScriptでスクロール処理を実装する方法もありますが、このあたりは別の記事で紹介予定です。

まとめ

本記事では、ページにアクセスした時点で、指定したアコーディオンを開いた状態にする方法を紹介しました。

スクロールはアンカー(#)によるブラウザの標準挙動に任せ、JavaScriptでは初期表示の状態制御だけを行っています。

まずは最小構成で実装し、必要に応じて制御を追加していく考え方の参考になれば幸いです。

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

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