JavaScript

アコーディオンをJavaScriptで自作する|基本形から応用まで実装例付きで解説

みなと

アコーディオンUIは、Web制作においてよく使われるパターンの一つです。FAQ、メニュー、スマホ対応の情報ブロックなど、活用シーンは多岐にわたります。

見た目には単純に見えるかもしれませんが、実際に現場で使える形で仕上げようとすると、思った以上に細かな配慮が必要になる場面が少なくありません。たとえば…

  • アニメーションを滑らかにしたい
  • 開閉の挙動を制御したい(複数開く、一つだけ開く、初期状態で開いておく 等)
  • スクロール位置を整えたい
  • レスポンシブで違和感のない見た目にしたい
  • ページ内の他要素と連動させたい
みなと
みなと

表面的にはすぐ動く。でも、デザインの意図やユーザー体験まで考えると、実装は意外と大変だったりします。

本記事では、「現場でそのまま使えるレベル」を意識して、以下の4つのバリエーションをデモとして紹介します。

  1. 基本形
  2. 一つ開けると他は閉じる
  3. 一番目は開けておく
  4. 閉じるボタン付きにする

いずれもライブラリに頼らず、HTML/CSS/JavaScriptのみで構成しています。

スワン
スワン

実装としてはシンプルな部類ですが、動作の仕組みを理解しておくことで、プロジェクトに合わせた調整や応用ができるようになります!

初心者の方には、動作のしくみを少しずつ理解していく手がかりとして。中級者の方には、細かなUIの設計を見直すヒントのひとつとして。このページでは、実装と挙動をひとつひとつ丁寧に確認していければと思います。

デモ1. 基本形

まずは一番シンプルなアコーディオンから始めましょう。クリックで開閉する、いわゆる「よくあるFAQ型」です。

  • A.

    このエリアには、回答文のサンプルが入ります。実際の運用では、ここに詳細な説明文や注意事項、補足の説明などを記述することが想定されます。文章量がある場合でも、レイアウトが崩れたり読みにくくなったりしないように設計されています。

    例えば、1つの回答内に2〜3段落を設けたい場合もあるでしょう。そういったケースに備えて、段落間のマージンや行間も調整されており、視認性を損なわないよう工夫しています。

    また、リンクやリストなどを含む場合にも備えて、適切な余白とタイポグラフィが設定されています。デザインのテストという観点からも、この部分は自由度が高い構成となっています。

  • A.

    回答部分には、プレースホルダーの文章を入れています。ユーザーに伝えたい情報やガイドライン、Q&A形式で提供するコンテンツなどを想定した内容になります。

    たとえば、「○○の操作方法についてご説明します」といった実務的なテキストや、「以下の点にご注意ください」といった注意書きなどが含まれるケースもあるでしょう。そのため、文章が複数行にわたっても自然に読めるよう、レイアウトや行間が調整されています。

    さらに、レスポンシブ環境でもレイアウトが破綻しないよう配慮されており、スマホ・タブレット・PCいずれの環境でも快適に閲覧可能です。アニメーションの挙動も長文に耐えられるよう設計されています。

  • A.

    サンプルの回答文です。開閉時のアニメーションやスムーススクロールの挙動を確認するためのテキストを含んでいます。特に、この項目ではスクロール位置による動作の変化もチェックできます。

    内容としては、「ここには注意点やよくある質問の補足情報が記述される想定です」といった、現実に即した文章構成をイメージしています。視覚的に見やすく、アニメーション終了後の高さや位置も自然に調整されるようにしてあります。

    また、開閉のトグル操作と連動した要素がうまく表示されるかどうかを確認するために、文章量は意識的に長めにしています。開いたままの状態でスクロールしてみたり、閉じるボタンの動作を試したりすることで、UIの一連の流れをテストできます。

HTML

アコーディオン全体は <ul> リストで構成されています。各項目 <li> が「Q&Aの1セット」になっていて、button をクリックすると回答部分が表示されます。

<ul class="accordion">
  <li>
    <div class="accordion-q">
      <button type="button">
        <span class="accordion-q-prefix">Q.</span>
        <span class="accordion-q-content">サンプルとしての質問文が入ります。</span>
        <span class="accordion-q-icon"></span>
      </button>
    </div>
    <div class="accordion-a">
      <div class="accordion-a-inner">
        <div class="accordion-a-prefix">A.</div>
        <div class="accordion-a-content">
          <p>このエリアには、回答文のサンプルが入ります。実際の運用では、ここに詳細な説明文や注意事項、補足の説明などを記述することが想定されます。文章量がある場合でも、レイアウトが崩れたり読みにくくなったりしないように設計されています。</p>
          <p>例えば、1つの回答内に2〜3段落を設けたい場合もあるでしょう。そういったケースに備えて、段落間のマージンや行間も調整されており、視認性を損なわないよう工夫しています。</p>
          <p>また、リンクやリストなどを含む場合にも備えて、適切な余白とタイポグラフィが設定されています。デザインのテストという観点からも、この部分は自由度が高い構成となっています。</p>
        </div>
      </div>
    </div>
  </li>
  <li>
    <div class="accordion-q">
      <button type="button">
        <span class="accordion-q-prefix">Q.</span>
        <span class="accordion-q-content">ここにはダミーの質問文が入ります。</span>
        <span class="accordion-q-icon"></span>
      </button>
    </div>
    <div class="accordion-a">
      <div class="accordion-a-inner">
        <div class="accordion-a-prefix">A.</div>
        <div class="accordion-a-content">
          <p>回答部分には、プレースホルダーの文章を入れています。ユーザーに伝えたい情報やガイドライン、Q&A形式で提供するコンテンツなどを想定した内容になります。</p>
          <p>たとえば、「○○の操作方法についてご説明します」といった実務的なテキストや、「以下の点にご注意ください」といった注意書きなどが含まれるケースもあるでしょう。そのため、文章が複数行にわたっても自然に読めるよう、レイアウトや行間が調整されています。</p>
          <p>さらに、レスポンシブ環境でもレイアウトが破綻しないよう配慮されており、スマホ・タブレット・PCいずれの環境でも快適に閲覧可能です。アニメーションの挙動も長文に耐えられるよう設計されています。</p>
        </div>
      </div>
    </div>
  </li>
  <li>
    <div class="accordion-q">
      <button type="button">
        <span class="accordion-q-prefix">Q.</span>
        <span class="accordion-q-content">表示例としての質問文がここに表示されます。</span>
        <span class="accordion-q-icon"></span>
      </button>
    </div>
    <div class="accordion-a">
      <div class="accordion-a-inner">
        <div class="accordion-a-prefix">A.</div>
        <div class="accordion-a-content">
          <p>サンプルの回答文です。開閉時のアニメーションやスムーススクロールの挙動を確認するためのテキストを含んでいます。特に、この項目ではスクロール位置による動作の変化もチェックできます。</p>
          <p>内容としては、「ここには注意点やよくある質問の補足情報が記述される想定です」といった、現実に即した文章構成をイメージしています。視覚的に見やすく、アニメーション終了後の高さや位置も自然に調整されるようにしてあります。</p>
          <p>また、開閉のトグル操作と連動した要素がうまく表示されるかどうかを確認するために、文章量は意識的に長めにしています。開いたままの状態でスクロールしてみたり、閉じるボタンの動作を試したりすることで、UIの一連の流れをテストできます。</p>
        </div>
      </div>
    </div>
  </li>
</ul>

CSS

装飾はレスポンシブ対応済みで、PCとスマホで適切なサイズが切り替わるようにしています。.accordion-q-icon::afterrotate で回転させて、「+」を「−」に変化させる仕組みもポイントです。

.accordion {
  list-style: none;
  border-top: 1px solid #ccc;
}

.accordion-q-prefix {
  display: block;
  position: absolute;
  font-family: "Roboto", sans-serif;
  font-weight: 500;
  font-variation-settings: "wdth" 75;
  line-height: 1;
}

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

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

.accordion-q-content {
  display: block;
  font-weight: 400;
  line-height: 1.6;
}

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

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

.accordion-q-icon {
  display: block;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}

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

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

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

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

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

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

.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 print, (min-width: 768px) {
  .accordion-q button {
    padding: 25px 60px 25px 57px;
  }
}

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

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

.accordion-a {
  display: none;
  overflow: hidden;
}

.accordion-a-inner {
  position: relative;
}

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

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

.accordion-a-prefix {
  position: absolute;
  font-family: "Roboto", sans-serif;
  font-weight: 500;
  font-variation-settings: "wdth" 75;
  line-height: 1;
}

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

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

@media print, (min-width: 768px) {
  .accordion-a-content p {
    font-size: 16px;
    line-height: 1.8;
  }
}

@media screen and (max-width: 767px) {
  .accordion-a-content p {
    font-size: 15px;
    line-height: 1.7;
  }
}

@media print, (min-width: 768px) {
  .accordion-a-content p + p {
    margin-top: 25px;
  }
}

@media screen and (max-width: 767px) {
  .accordion-a-content p + p {
    margin-top: 20px;
  }
}

.accordion > li {
  border-bottom: 1px solid #ccc;
}

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

JavaScript

アコーディオンの動きを制御しているのは、以下のJavaScriptです。

document.querySelectorAll('.accordion').forEach((accordion) => {
  const props = {
    isAnimating: false,
    slideDuration: 400,
    slideEasing: 'cubic-bezier(0.215, 0.61, 0.355, 1)',
  };

  function answerShow(li) {
    props.isAnimating = true;
    li.classList.add('is-open');

    const answer = li.querySelector('.accordion-a');
    answer.style.display = 'block';
    const startHeight = 0;
    const endHeight = answer.scrollHeight;
    
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.height = '';
      props.isAnimating = false;
    };
  }

  function answerHide(li) {
    props.isAnimating = true;
    li.classList.remove('is-open');

    const answer = li.querySelector('.accordion-a');
    
    const startHeight = answer.scrollHeight;
    const endHeight = 0;
    
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.display = '';
      answer.style.height = '';
      props.isAnimating = false;
    };
  }

  accordion.querySelectorAll('li').forEach((li) => {
    li.querySelector('.accordion-q button').addEventListener('click', () => {
      if (!props.isAnimating) {
        if (!li.classList.contains('is-open')) {
          answerShow(li);
        } else {
          answerHide(li);
        }
      }
      return false;
    });
  });
});

要点をまとめると、次のような工夫が見られます。

複数アコーディオンへの対応

document.querySelectorAll('.accordion').forEach((accordion) => { ... });

この1行で、ページ内に複数配置されたアコーディオンにも対応できます。クラス名 .accordion を持つ要素すべてに対して、同じ制御を適用します。

開閉の条件分岐

各リストアイテム(<li>) 内のボタンに click イベントを設定し、以下のように現在の状態に応じて answerShow() または answerHide() を呼び分けます。

if (!li.classList.contains('is-open')) {
  answerShow(li);
} else {
  answerHide(li);
}

このように条件を分けることで、「開いていたら閉じる」「閉じていたら開く」という基本動作を簡潔に実装できます。

アニメーションの基本構造

アニメーションの本体には Web Animations API を使用しています。answerShow() の中では、

  1. 一度 display: block にして実際の高さ(scrollHeight)を取得
  2. 一旦高さ0pxから、最終的な高さまでアニメーション
  3. アニメーション終了後に style.height = '' でリセット

という流れを取っています。この一連の処理により、「自然な開閉」と「クリーンなDOM状態」を両立させています。

answer.animate([
  { height: `${startHeight}px` },
  { height: `${endHeight}px` }
], {
  duration: props.slideDuration,
  easing: props.slideEasing
}).onfinish = () => {
  answer.style.height = '';
  props.isAnimating = false;
};

逆に answerHide() では、高さの終点を0に設定して閉じるアニメーションを実現しています。

デモ2. 一つ開けると他は閉じる

「Q.〜」をクリックすると開きますが、すでに開いている項目がある場合は自動で閉じます。複数同時には開けない、「一つだけ開ける」タイプのアコーディオンです。

  • A.

    このエリアには、回答文のサンプルが入ります。実際の運用では、ここに詳細な説明文や注意事項、補足の説明などを記述することが想定されます。文章量がある場合でも、レイアウトが崩れたり読みにくくなったりしないように設計されています。

    例えば、1つの回答内に2〜3段落を設けたい場合もあるでしょう。そういったケースに備えて、段落間のマージンや行間も調整されており、視認性を損なわないよう工夫しています。

    また、リンクやリストなどを含む場合にも備えて、適切な余白とタイポグラフィが設定されています。デザインのテストという観点からも、この部分は自由度が高い構成となっています。

  • A.

    回答部分には、プレースホルダーの文章を入れています。ユーザーに伝えたい情報やガイドライン、Q&A形式で提供するコンテンツなどを想定した内容になります。

    たとえば、「○○の操作方法についてご説明します」といった実務的なテキストや、「以下の点にご注意ください」といった注意書きなどが含まれるケースもあるでしょう。そのため、文章が複数行にわたっても自然に読めるよう、レイアウトや行間が調整されています。

    さらに、レスポンシブ環境でもレイアウトが破綻しないよう配慮されており、スマホ・タブレット・PCいずれの環境でも快適に閲覧可能です。アニメーションの挙動も長文に耐えられるよう設計されています。

  • A.

    サンプルの回答文です。開閉時のアニメーションやスムーススクロールの挙動を確認するためのテキストを含んでいます。特に、この項目ではスクロール位置による動作の変化もチェックできます。

    内容としては、「ここには注意点やよくある質問の補足情報が記述される想定です」といった、現実に即した文章構成をイメージしています。視覚的に見やすく、アニメーション終了後の高さや位置も自然に調整されるようにしてあります。

    また、開閉のトグル操作と連動した要素がうまく表示されるかどうかを確認するために、文章量は意識的に長めにしています。開いたままの状態でスクロールしてみたり、閉じるボタンの動作を試したりすることで、UIの一連の流れをテストできます。

HTML

HTML構造はデモ1とまったく同じです。コードの詳細はデモ1のHTMLをご覧ください。

CSS

こちらもデモ1とまったく同じスタイルを適用しています。表示アニメーションなどの制御はJavaScript側で行っているため、CSSの主な役割は装飾です。

JavaScript

開いている項目があればそれを閉じてから、新たにクリックされた項目を開くように制御しています。アニメーションの挙動自体はデモ1と同様です。

document.querySelectorAll('.accordion').forEach((accordion) => {
  const props = {
    isAnimating: false,
    slideDuration: 400,
    slideEasing: 'cubic-bezier(0.215, 0.61, 0.355, 1)',
    lis: accordion.querySelectorAll('li'),
  };

  function answerShow(li) {
    props.isAnimating = true;

    // 他のliを閉じる
    props.lis.forEach((otherLi) => {
      if (otherLi !== li && otherLi.classList.contains('is-open')) {
        answerHide(otherLi);
      }
    });

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

    const answer = li.querySelector('.accordion-a');
    answer.style.display = 'block';
    const startHeight = 0;
    const endHeight = answer.scrollHeight;
    
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.height = '';
      props.isAnimating = false;
    };
  }

  function answerHide(li) {
    props.isAnimating = true;
    li.classList.remove('is-open');

    const answer = li.querySelector('.accordion-a');
    
    const startHeight = answer.scrollHeight;
    const endHeight = 0;
    
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.display = '';
      answer.style.height = '';
      props.isAnimating = false;
    };
  }

  props.lis.forEach((li) => {
    li.querySelector('.accordion-q button').addEventListener('click', () => {
      if (!props.isAnimating) {
        if (!li.classList.contains('is-open')) {
          answerShow(li);
        } else {
          answerHide(li);
        }
      }
      return false;
    });
  });
});

このデモでは、基本形(デモ1)をもとに、次の点を変更しました。

他の項目を閉じる処理を追加

アコーディオン全体から <li> を取得しておき、answerShow() の中で開いている項目をすべて閉じています。

props.lis.forEach((otherLi) => {
  if (otherLi !== li && otherLi.classList.contains('is-open')) {
    answerHide(otherLi);
  }
});

この処理により、クリックされた項目以外がすべて閉じられ、1つだけ開いた状態が保たれます。

デモ3. 一番目は開けておく

ページを開いたとき、最初の項目だけはあらかじめ開いた状態にしておきたい──そんな要望はよくあります。このデモでは、初期状態で一番目のQ&Aが展開された状態で表示されます。

  • A.

    このエリアには、回答文のサンプルが入ります。実際の運用では、ここに詳細な説明文や注意事項、補足の説明などを記述することが想定されます。文章量がある場合でも、レイアウトが崩れたり読みにくくなったりしないように設計されています。

    例えば、1つの回答内に2〜3段落を設けたい場合もあるでしょう。そういったケースに備えて、段落間のマージンや行間も調整されており、視認性を損なわないよう工夫しています。

    また、リンクやリストなどを含む場合にも備えて、適切な余白とタイポグラフィが設定されています。デザインのテストという観点からも、この部分は自由度が高い構成となっています。

  • A.

    回答部分には、プレースホルダーの文章を入れています。ユーザーに伝えたい情報やガイドライン、Q&A形式で提供するコンテンツなどを想定した内容になります。

    たとえば、「○○の操作方法についてご説明します」といった実務的なテキストや、「以下の点にご注意ください」といった注意書きなどが含まれるケースもあるでしょう。そのため、文章が複数行にわたっても自然に読めるよう、レイアウトや行間が調整されています。

    さらに、レスポンシブ環境でもレイアウトが破綻しないよう配慮されており、スマホ・タブレット・PCいずれの環境でも快適に閲覧可能です。アニメーションの挙動も長文に耐えられるよう設計されています。

  • A.

    サンプルの回答文です。開閉時のアニメーションやスムーススクロールの挙動を確認するためのテキストを含んでいます。特に、この項目ではスクロール位置による動作の変化もチェックできます。

    内容としては、「ここには注意点やよくある質問の補足情報が記述される想定です」といった、現実に即した文章構成をイメージしています。視覚的に見やすく、アニメーション終了後の高さや位置も自然に調整されるようにしてあります。

    また、開閉のトグル操作と連動した要素がうまく表示されるかどうかを確認するために、文章量は意識的に長めにしています。開いたままの状態でスクロールしてみたり、閉じるボタンの動作を試したりすることで、UIの一連の流れをテストできます。

HTML

HTML構造はデモ1とまったく同じです。

CSS

CSSもデモ1と共通です。

JavaScript

初期状態で一番目の項目を開いた状態にするだけで、基本的なコード構成はデモ1と同じです。

document.querySelectorAll('.accordion').forEach((accordion) => {
  const props = {
    isAnimating: false,
    slideDuration: 400,
    slideEasing: 'cubic-bezier(0.215, 0.61, 0.355, 1)',
  };

  function answerShow(li) {
    props.isAnimating = true;
    li.classList.add('is-open');

    const answer = li.querySelector('.accordion-a');
    answer.style.display = 'block';
    const startHeight = 0;
    const endHeight = answer.scrollHeight;
    
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.height = '';
      props.isAnimating = false;
    };
  }

  function answerHide(li) {
    props.isAnimating = true;
    li.classList.remove('is-open');

    const answer = li.querySelector('.accordion-a');
    
    const startHeight = answer.scrollHeight;
    const endHeight = 0;
    
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.display = '';
      answer.style.height = '';
      props.isAnimating = false;
    };
  }

  const firstLi = accordion.querySelectorAll('li')[0];
  firstLi.classList.add('is-open');

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

  accordion.querySelectorAll('li').forEach((li) => {
    li.querySelector('.accordion-q button').addEventListener('click', () => {
      if (!props.isAnimating) {
        if (!li.classList.contains('is-open')) {
          answerShow(li);
        } else {
          answerHide(li);
        }
      }
      return false;
    });
  });
});

このデモでは、基本形(デモ1)をもとに、次の点を変更しました。

一番目の項目を開く処理を追加

アコーディオンの初期化時に、最初の .accordion > liis-open クラスを追加し、回答部分を表示しています。

const firstLi = accordion.querySelectorAll('li')[0];
firstLi.classList.add('is-open');

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

この処理により、ページ読み込み時に自動で一つ目の質問が開いた状態になります。

みなと
みなと

answerShow() を使う手もあるんですが、読み込み直後にアニメーションしてしまうと不自然な印象になるため、アニメーションせずに表示しています。

デモ4. 閉じるボタン付きにする

各回答の末尾に、ユーザーがクリックで閉じられる「閉じるボタン」を用意しました。特に内容が長めの場合、読み終えた場所からそのまま閉じられるため、スクロールの手間を省くことができます。Qを再クリックする以外の選択肢を設けることで、操作性を少し向上させたパターンです。

  • A.

    このエリアには、回答文のサンプルが入ります。実際の運用では、ここに詳細な説明文や注意事項、補足の説明などを記述することが想定されます。文章量がある場合でも、レイアウトが崩れたり読みにくくなったりしないように設計されています。

    例えば、1つの回答内に2〜3段落を設けたい場合もあるでしょう。そういったケースに備えて、段落間のマージンや行間も調整されており、視認性を損なわないよう工夫しています。

    また、リンクやリストなどを含む場合にも備えて、適切な余白とタイポグラフィが設定されています。デザインのテストという観点からも、この部分は自由度が高い構成となっています。

  • A.

    回答部分には、プレースホルダーの文章を入れています。ユーザーに伝えたい情報やガイドライン、Q&A形式で提供するコンテンツなどを想定した内容になります。

    たとえば、「○○の操作方法についてご説明します」といった実務的なテキストや、「以下の点にご注意ください」といった注意書きなどが含まれるケースもあるでしょう。そのため、文章が複数行にわたっても自然に読めるよう、レイアウトや行間が調整されています。

    さらに、レスポンシブ環境でもレイアウトが破綻しないよう配慮されており、スマホ・タブレット・PCいずれの環境でも快適に閲覧可能です。アニメーションの挙動も長文に耐えられるよう設計されています。

  • A.

    サンプルの回答文です。開閉時のアニメーションやスムーススクロールの挙動を確認するためのテキストを含んでいます。特に、この項目ではスクロール位置による動作の変化もチェックできます。

    内容としては、「ここには注意点やよくある質問の補足情報が記述される想定です」といった、現実に即した文章構成をイメージしています。視覚的に見やすく、アニメーション終了後の高さや位置も自然に調整されるようにしてあります。

    また、開閉のトグル操作と連動した要素がうまく表示されるかどうかを確認するために、文章量は意識的に長めにしています。開いたままの状態でスクロールしてみたり、閉じるボタンの動作を試したりすることで、UIの一連の流れをテストできます。

HTML

Q&A 部分の末尾に <button> を使った閉じる操作を追加しています。構造としては以下のようなイメージです。

<ul class="accordion">
  <li>
    <div class="accordion-q">
      <button type="button">
        <span class="accordion-q-prefix">Q.</span>
        <span class="accordion-q-content">サンプルとしての質問文が入ります。</span>
        <span class="accordion-q-icon"></span>
      </button>
    </div>
    <div class="accordion-a">
      <div class="accordion-a-inner">
        <div class="accordion-a-body">
          <div class="accordion-a-prefix">A.</div>
          <div class="accordion-a-content">
            <p>このエリアには、回答文のサンプルが入ります。実際の運用では、ここに詳細な説明文や注意事項、補足の説明などを記述することが想定されます。文章量がある場合でも、レイアウトが崩れたり読みにくくなったりしないように設計されています。</p>
            <p>例えば、1つの回答内に2〜3段落を設けたい場合もあるでしょう。そういったケースに備えて、段落間のマージンや行間も調整されており、視認性を損なわないよう工夫しています。</p>
            <p>また、リンクやリストなどを含む場合にも備えて、適切な余白とタイポグラフィが設定されています。デザインのテストという観点からも、この部分は自由度が高い構成となっています。</p>
          </div>
        </div>
        <div class="accordion-a-close"><button type="button"><span><span></span>閉じる</button></span></div>
      </div>
    </div>
  </li>
  <li>
    <div class="accordion-q">
      <button type="button">
        <span class="accordion-q-prefix">Q.</span>
        <span class="accordion-q-content">ここにはダミーの質問文が入ります。</span>
        <span class="accordion-q-icon"></span>
      </button>
    </div>
    <div class="accordion-a">
      <div class="accordion-a-inner">
        <div class="accordion-a-body">
          <div class="accordion-a-prefix">A.</div>
          <div class="accordion-a-content">
            <p>回答部分には、プレースホルダーの文章を入れています。ユーザーに伝えたい情報やガイドライン、Q&A形式で提供するコンテンツなどを想定した内容になります。</p>
            <p>たとえば、「○○の操作方法についてご説明します」といった実務的なテキストや、「以下の点にご注意ください」といった注意書きなどが含まれるケースもあるでしょう。そのため、文章が複数行にわたっても自然に読めるよう、レイアウトや行間が調整されています。</p>
            <p>さらに、レスポンシブ環境でもレイアウトが破綻しないよう配慮されており、スマホ・タブレット・PCいずれの環境でも快適に閲覧可能です。アニメーションの挙動も長文に耐えられるよう設計されています。</p>
          </div>
        </div>
        <div class="accordion-a-close"><button type="button"><span><span></span>閉じる</button></span></div>
      </div>
    </div>
  </li>
  <li>
    <div class="accordion-q">
      <button type="button">
        <span class="accordion-q-prefix">Q.</span>
        <span class="accordion-q-content">表示例としての質問文がここに表示されます。</span>
        <span class="accordion-q-icon"></span>
      </button>
    </div>
    <div class="accordion-a">
      <div class="accordion-a-inner">
        <div class="accordion-a-body">
          <div class="accordion-a-prefix">A.</div>
          <div class="accordion-a-content">
            <p>サンプルの回答文です。開閉時のアニメーションやスムーススクロールの挙動を確認するためのテキストを含んでいます。特に、この項目ではスクロール位置による動作の変化もチェックできます。</p>
            <p>内容としては、「ここには注意点やよくある質問の補足情報が記述される想定です」といった、現実に即した文章構成をイメージしています。視覚的に見やすく、アニメーション終了後の高さや位置も自然に調整されるようにしてあります。</p>
            <p>また、開閉のトグル操作と連動した要素がうまく表示されるかどうかを確認するために、文章量は意識的に長めにしています。開いたままの状態でスクロールしてみたり、閉じるボタンの動作を試したりすることで、UIの一連の流れをテストできます。</p>
          </div>
        </div>
        <div class="accordion-a-close"><button type="button"><span><span></span>閉じる</button></span></div>
      </div>
    </div>
  </li>
</ul>

CSS

基本的なスタイルはデモ1と共通です。追加したのは主に.accordion-a-close に関するスタイル一式で、閉じるボタンのレイアウトやサイズ、装飾などを定義しています。

.accordion {
  list-style: none;
  border-top: 1px solid #ccc;
}

.accordion-q-prefix {
  display: block;
  position: absolute;
  font-family: "Roboto", sans-serif;
  font-weight: 500;
  font-variation-settings: "wdth" 75;
  line-height: 1;
}

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

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

.accordion-q-content {
  display: block;
  font-weight: 400;
  line-height: 1.6;
}

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

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

.accordion-q-icon {
  display: block;
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}

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

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

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

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

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

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

.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 print, (min-width: 768px) {
  .accordion-q button {
    padding: 25px 60px 25px 57px;
  }
}

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

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

.accordion-a {
  display: none;
  overflow: hidden;
}

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

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

.accordion-a-body {
  position: relative;
}

@media print, (min-width: 768px) {
  .accordion-a-body {
    padding-left: 57px;
  }
}

@media screen and (max-width: 767px) {
  .accordion-a-body {
    padding-left: 41px;
  }
}

.accordion-a-prefix {
  position: absolute;
  font-family: "Roboto", sans-serif;
  font-weight: 500;
  font-variation-settings: "wdth" 75;
  line-height: 1;
}

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

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

@media print, (min-width: 768px) {
  .accordion-a-content p {
    font-size: 16px;
    line-height: 1.8;
  }
}

@media screen and (max-width: 767px) {
  .accordion-a-content p {
    font-size: 15px;
    line-height: 1.7;
  }
}

@media print, (min-width: 768px) {
  .accordion-a-content p + p {
    margin-top: 25px;
  }
}

@media screen and (max-width: 767px) {
  .accordion-a-content p + p {
    margin-top: 20px;
  }
}

.accordion-a-close {
  line-height: 1.5;
}

@media print, (min-width: 768px) {
  .accordion-a-close {
    width: 160px;
    margin: 25px auto 0;
    font-size: 16px;
  }
}

@media screen and (max-width: 767px) {
  .accordion-a-close {
    width: 140px;
    margin: 20px auto 0;
    font-size: 15px;
  }
}

.accordion-a-close button {
  display: block;
  position: relative;
  width: 100%;
  border: none;
  background: #000;
  color: #fff;
  font: inherit;
  appearance: none;
  cursor: pointer;
  transition: opacity 400ms;
}

@media print, (min-width: 768px) {
  .accordion-a-close button {
    height: 45px;
  }
}

@media screen and (max-width: 767px) {
  .accordion-a-close button {
    height: 40px;
  }
}

.accordion-a-close button > span {
  display: inline-block;
  position: relative;
  padding-left: 15px;
  vertical-align: top;
}

.accordion-a-close button > span > span {
  display: block;
  position: absolute;
  left: -5px;
  top: 50%;
  width: 13px;
  height: 13px;
  margin-top: -6.5px;
  transform: rotate(45deg);
}

.accordion-a-close button > span > span::before,
.accordion-a-close button > span > span::after {
  content: "";
  display: block;
  position: absolute;
  left: 0;
  top: 6px;
  width: 100%;
  height: 1px;
  background: #fff;
}

.accordion-a-close button > span > span::after {
  transform: rotate(90deg);
}

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

.accordion > li {
  border-bottom: 1px solid #ccc;
}

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

JavaScript

基本形(デモ1)のコードをもとに、閉じるボタンで回答を手動で閉じられるようにする処理と、質問文の位置まで自動でスクロールを戻す処理を追加しています。

document.querySelectorAll('.accordion').forEach((accordion) => {
  const props = {
    isAnimating: false,
    slideDuration: 400,
    slideEasing: 'cubic-bezier(0.215, 0.61, 0.355, 1)',
  };

  function easeOutCubic(t) {
    return 1 - Math.pow(1 - t, 3);
  }

  function smoothScrollToY(targetY, duration, easing) {
    const startY = window.scrollY;
    const diff = targetY - startY;
    const startTime = performance.now();

    function animate(now) {
      const elapsed = now - startTime;
      const t = Math.min(elapsed / duration, 1);
      const easedT = easing(t);
      window.scrollTo(0, startY + diff * easedT);
      if (t < 1) {
        requestAnimationFrame(animate);
      }
    }

    requestAnimationFrame(animate);
  }

  function answerShow(li) {
    props.isAnimating = true;
    li.classList.add('is-open');

    const answer = li.querySelector('.accordion-a');
    answer.style.display = 'block';
    const startHeight = 0;
    const endHeight = answer.scrollHeight;
    
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.height = '';
      props.isAnimating = false;
    };
  }

  function answerHide(li) {
    props.isAnimating = true;
    li.classList.remove('is-open');

    const rect = li.getBoundingClientRect();
    if (rect.top < 0) {
      const top = rect.top + window.scrollY;
      smoothScrollToY(top, props.slideDuration, easeOutCubic);
    }

    const answer = li.querySelector('.accordion-a');
    
    const startHeight = answer.scrollHeight;
    const endHeight = 0;
    
    answer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.slideDuration,
      easing: props.slideEasing
    }).onfinish = () => {
      answer.style.display = '';
      answer.style.height = '';
      props.isAnimating = false;
    };
  }

  accordion.querySelectorAll('li').forEach((li) => {
    li.querySelector('.accordion-q button').addEventListener('click', () => {
      if (!props.isAnimating) {
        if (!li.classList.contains('is-open')) {
          answerShow(li);
        } else {
          answerHide(li);
        }
      }
      return false;
    });

    li.querySelector('.accordion-a-close button').addEventListener('click', () => {
      if (!props.isAnimating) {
        answerHide(li);
      }
      return false;
    });
  });
});

このデモでは、基本形(デモ1)をもとに、次の点を変更・追加しました。

「閉じる」ボタンのイベント処理を追加

アコーディオンの回答エリア内に配置された「閉じる」ボタンにイベントリスナーを追加し、クリックするとその項目が閉じられるようにしています。

accordion.querySelectorAll('li').forEach((li) => {
  li.querySelector('.accordion-a-close button').addEventListener('click', () => {
    if (!props.isAnimating) {
      answerHide(li);
    }
    return false;
  });
});

スクロール位置の調整を追加

回答を閉じる際、対象の質問文が画面の上部よりも上にある場合は、アニメーションと同時にスムーススクロールでその位置まで移動します。これにより、ユーザーが質問文を見失わずに済み、どの項目を操作したかが直感的にわかります。

const rect = li.getBoundingClientRect();
if (rect.top < 0) {
  const top = rect.top + window.scrollY;
  smoothScrollToY(top, props.slideDuration, easeOutCubic);
}
スワン
スワン

スクロールは「常に」ではなく、「Qの頭が見えていないときだけ」に限定しています。

補足:スクロールアニメーションの仕組み

スムーススクロールの処理には、次の2つの関数を使っています。

  • easeOutCubic(t):時間 t の進行に応じて変化する、滑らかなイージング関数です。jQueryの easeOutCubic とほぼ同等の動きになります。
  • smoothScrollToY(targetY, duration, easing)requestAnimationFrame を使い、指定位置 targetY に向かって滑らかにスクロールさせる関数です。引数に時間とイージング関数を取るので、柔軟にカスタマイズ可能です。

まとめ

JavaScriptでアコーディオンを実装する方法を、4つの実用的なデモを通して解説しました。

どのデモも一見シンプルに見えますが、ライブラリに頼らずスクラッチで実装する場合には、細かな挙動や表示の整合性を保つために、意外と多くの工夫が求められます。

今回は、まずUIの基本的な構造と動作の理解を深めることを目的に、ARIA属性やキーボード操作などのアクセシビリティ対応には触れていません。こうした対応は、土台となる仕組みを把握したうえで、必要に応じて取り入れていくのが良いと思います。

自分なりの形に応用するヒントとして、少しでも役立ててもらえたら嬉しいです。

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

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