details と summary 要素 + JavaScript で作るアコーディオン

details 要素(詳細折りたたみ要素)と JavaScript を使ってアコーディオンを実装する方法の覚書です。summary 要素の矢印アイコンのカスタマイズ方法などについても。

作成日:2023年4月13日

details 要素と JavaScript(Web Animation API)を使って以下のようなアコーディオンウィジェットを比較的簡単に実装することができます。

details 要素

<details>: 詳細折りたたみ要素

ウィジェットが「開いた」状態になった時に情報を表示する折りたたみウィジェットを作成する HTML 要素です(MDN より)。

summary 要素

<summary>: 概要明示要素

<details> 要素の折り畳みボックスのキャプション、要約、説明文などを表示するための HTML 要素です。 <summary> 要素をクリックすると、親の <details> 要素の開閉状態を切り替えることができます(MDN より)。

マークアップ
<details>
  <summary>Title</summary>
  Content
</details>
<div class="details-wrapper">
<details class="js-animation">
  <summary>details 要素</summary>
  <div class="details-content-wrapper">
    <div class="details-content">
      <p>details: 詳細折りたたみ要素</p>
      <p>ウィジェットが「開いた」状態になった時に情報を表示する...</p>
    </div>
  </div>
</details>
<details class="js-animation">
  <summary>summary 要素</summary>
  <div class="details-content-wrapper">
    <div class="details-content">
      <p>summary: 概要明示要素</p>
      <p>details 要素の折り畳みボックスのキャプション、要約、説明文などを...</p>
    </div>
  </div>
</details>
<details class="js-animation">
  <summary>マークアップ</summary>
  <div class="details-content-wrapper">
    <div class="details-content">
      <pre>...</pre>
    </div>
  </div>
</details>
</div>
.details-wrapper details {
  border-bottom: none;
}
.details-wrapper details:last-child {
  border-bottom: 1px solid #aaa;
}
details {
  border: 1px solid #aaa;
  max-width: 520px;
}
details .details-content-wrapper {
  padding: 1rem 1rem 1rem 2rem;
}
details .details-content {
  overflow: hidden;
}
details summary {
  display: block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem 0.5rem 0.5rem 36px;
  transition: background-color .3s;
}
details summary:hover {
  background-color: #f1fff8;
}
details summary::-webkit-details-marker {
  display: none;
}
details summary::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 10px;
  margin: auto 0;
  width: 10px;
  height: 10px;
  border-top: 3px solid #097b27;
  border-right: 3px solid #097b27;
  transform: rotate(45deg);
}
details[open] summary {
  border-bottom: 1px solid #aaa;
}
document.addEventListener('DOMContentLoaded', () => {
  setupToggleDetailsAnimation();
});
function setupToggleDetailsAnimation() {
  const details = document.querySelectorAll('details.js-animation');
  details.forEach(elem => {
    const summary = elem.querySelector('summary');
    const content = elem.querySelector('.details-content');
    let isAnimating = false;
    summary.addEventListener('click', (e) => {
      e.preventDefault();
      if (isAnimating === true) {
        return;
      }
      if (elem.open) {
        isAnimating = true;
        const closeDetails = content.animate(
          {
            opacity: [1, 0],
            height: [content.offsetHeight + 'px', 0],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotateIcon = summary.animate(
          { rotate: ["90deg", "0deg"] },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        closeDetails.onfinish = () => {
          elem.removeAttribute('open');
          isAnimating = false;
        }
      } else {
        elem.setAttribute('open', 'true');
        isAnimating = true;
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotateIcon = summary.animate(
          { rotate: ["0deg", "90deg"] },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        openDetails.onfinish = () => {
          isAnimating = false;
        }
      }
    });
  });
};

details 要素と summary 要素

details 要素summary 要素は HTML5.1 で導入され、現在は HTML Living Standard で定義されている比較的新しい HTML の要素です。details 要素は「詳細折りたたみ要素」などと呼ばれます。

details 要素と summary 要素を使うと、簡単にアコーディオンや折りたたみメニューなどの開閉式のウィジェットを作ることができ、また、アクセシビリティやユーザビリティの面でも優れています。

details 要素と summary 要素は主要なモダンブラウザで利用することができます(can i use details)。

以下は details 要素と summary 要素を使ったシンプルな開閉式のウィジェットの例です。

details 要素の中に1つの summary 要素を配置し、続けてコンテンツを記述します。

HTML
<details>
  <summary>詳細を見る</summary>
  <p>詳細の内容</p>
</details>

上記を記述すると以下のように表示されます(スタイルは何も指定していない状態です)。

summary 要素の「詳細を見る」をクリックすると折りたたまれていた部分が開いて表示されます。その際、summary 要素の左側に表示される三角形アイコンの向きが回転します。

詳細を見る

詳細の内容

details 要素

details 要素には1つの summary 要素と、それに続く複数の任意の要素(コンテンツ)を配置することができます。

summary 要素

summary 要素は details 要素の最初の子要素として使用します。

summary 要素には、テキストや h2 要素などの見出しコンテンツ、段落内で使用できる HTML(strong 要素など)を入れることができます。

summary 要素をクリックすると、親の details 要素の開閉状態を切り替えることができます。

open 属性

details 要素には details 要素の内容が現在表示されているかどうかを示す open 属性をがあります。

open 属性が details 要素に指定されると、details 要素のコンテンツが開いた状態で表示され、open 属性が削除されると details 要素のコンテンツは閉じられて非表示になります。

summary 要素をクリックすると、親の details 要素の内容の表示・非表示を切り替えることができ、その際に open 属性が追加・削除されるようになっています。

また、HTML 上で details 要素に open 属性を指定することで、予め details 要素のコンテンツが開いた状態で表示することができます。

<details open><!-- details 要素に open 属性を指定 -->
  <summary>詳細を見る</summary>
  <p>詳細の内容(開いた状態で表示される)</p>
</details>

上記を記述すると、以下のように details 要素の内容が開いた状態で表示されます(summary 要素の部分をクリックすると閉じます)。

詳細を見る

詳細の内容(開いた状態で表示される)

スタイル

CSS を使って details 要素の内容が表示されている状態の details 要素と summary 要素のスタイルを指定したり、summary 要素の左側に表示される三角形のアイコンを変更したりすることができます。

details[open] セレクタ

details 要素の内容が表示されている(開いている)状態のスタイルは、属性セレクタ[]open 属性を指定して details[open] というセレクタを使って指定することができます。

details[open] {
  /* details 要素の内容が表示されている時の details 要素のスタイル */
}

details[open] summary {
  /* details 要素の内容が表示されている時の summary 要素のスタイル */
}

以下は details[open] で details 要素の内容が表示されている(開いている)時には padding-bottom を設定し、details[open] summary で details 要素の内容が表示されている時には summary 要素に border-bottom を設定しています。

details {
  border: 1px solid #aaa;
  padding: 0.5rem 0.5rem 0;
  max-width: 300px;
}

summary {
  font-weight: bold;
  margin: -0.5rem -0.5rem 0; /* details に指定した padding のスペースを相殺 */
  padding: 0.5rem;
}

details[open] {
  /* 開いている時の details 要素のスタイル */
  padding-bottom: 0.5rem;
}

details[open] summary {
  /* 開いている時の summary 要素のスタイル */
  border-bottom: 1px solid #aaa;
}
<details>
  <summary>詳細を見る</summary>
  <p>詳細の内容</p>
</details>
詳細を見る

詳細の内容

上記の例は以下のように details 要素のコンテンツをラップしてスタイルを指定する方が簡単です。

<details>
  <summary>詳細を見る</summary>
  <div class="details-content">
    <p>詳細の内容</p>
  </div>
</details>

コンテンツをラップしている details-content クラスの要素にパディングを設定すれば、前述の例のようにネガティブマージンでパディング分を相殺する必要がなくなります。

details {
  border: 1px solid #aaa;
  max-width: 300px;
}

/* details 要素のコンテンツをラップした要素にスタイルを指定 */
details .details-content {
  padding: 0.5rem;
}

summary {
  font-weight: bold;
  padding: 0.5rem;
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

summary 要素の矢印アイコン

summary 要素の display プロパティの値は Chrome や Firefox では list-item になっています。

display プロパティの値が list-item の要素(summary 要素や li 要素)では ::marker 疑似要素により list-style-type で指定されたアイコンが挿入されます。Chrome や Firefox ではこのアイコンが summary 要素の矢印アイコンになります。

以下を記述すると、Chrome や Firefox では summary 要素の矢印アイコンの色が赤になり大きさも少し大きくなります。

summary::marker {
  color: red;
  font-size: 18px;
}

但し、Safari では ::-webkit-details-marker という疑似要素により summary 要素のアイコンが表示されているため、Safari でアイコンの色と大きさを変更するには、以下のように記述します。

summary::-webkit-details-marker {
  color: red;
  font-size: 18px;
}

現時点で Safari の Web インスペクタで確認したところ、Safari の場合、summary 要素の display プロパティの値は block になっています。

詳細を見る

詳細の内容

矢印アイコンを非表示にする

Chrome や Firefox では summary 要素の display の値が list-item になっているため(::marker 擬似要素により)矢印アイコンが表示されているので、矢印のアイコンを非表示にするには、display の値に list-item 以外の値(block など)を指定します。

但し、Safari では ::marker 擬似要素ではなく、::-webkit-details-marker という疑似要素によりアイコンが表示されているため、summary::-webkit-details-markerdisplay:none を指定して非表示にする必要があります。

CSS
summary {
  display: block;  /* 矢印のアイコンを非表示にする */
}

/* Safari で三角形のアイコンを非表示にする */
summary::-webkit-details-marker {
  display: none;
}

list-style-type: none で非表示に

summary 要素の display の値に list-item 以外の値を指定して矢印アイコンを非表示にする以外に、list-style-type: none を指定して矢印アイコンを非表示にすることもできます。

但し、Safari では ::marker 擬似要素を使っていないので、この場合も上記同様、::-webkit-details-marker で非表示にする必要があります。

summary {
  list-style-type: none; /* 矢印のアイコンを非表示にする */
}

/* Safari で三角形のアイコンを非表示にする */
summary::-webkit-details-marker {
  display: none;
}
矢印アイコンをカスタマイズ

summary 要素の三角形の矢印アイコンは ::marker 疑似要素で表示されているので、 ::marker をカスタマイズできれば良いのですが、::marker で利用できるプロパティは限られているためカスタマイズするのは難しく、また、Safari では ::marker を使っていません。。

そのため、summary 要素の三角形の矢印アイコンをカスタマイズするには、::marker により表示されるアイコンを非表示にして、疑似要素の ::before や ::after を使って独自のアイコンを表示します。

以下は疑似要素を使って独自のアイコンを表示する例です。

<details>
  <summary>詳細を見る</summary>
  <div class="details-content">
    <p>詳細の内容</p>
  </div>
</details>

疑似要素の ::before を使って独自のアイコンを summary 要素に挿入しています。

また、summary 要素にマウスオーバーしてもカーソルがポインターにならないので、cursor:pointer を指定することでクリックできることがわかりやすくなります。

この例では、疑似要素に transition プロパティを指定して、details[open] summary::before で details 要素が開いた際に、90度回転するトランジションのアニメーションを設定しています。

details {
  border: 1px solid #aaa;
  max-width: 300px;
}

details .details-content {
  padding: 1rem 1rem 1rem 2rem;
}

summary {
  display: block; /* 矢印アイコンを非表示に */
  cursor: pointer; /* カーソルの形状をポインターに */
  position: relative; /* 絶対配置する疑似要素のアイコンの基準に */
  padding: 0.5rem 0.5rem 0.5rem 36px; /* padding-left にアイコンのスペースを確保 */
}

summary::-webkit-details-marker {
  display: none; /* Safari で三角形のアイコンを非表示に */
}

/* 独自のアイコンを擬似要素で作成 */
summary::before {
  content: ""; /* content は空を指定 */
  position: absolute; /* 絶対配置 */
  top: 0;
  bottom: 0;
  left: 10px;  /* 位置の調整 */
  margin: auto 0;  /* top:0 と bottom:0 と共に指定して垂直方向中央揃え */
  width: 10px; /* アイコンの辺の長さ */
  height: 10px; /* アイコンの辺の長さ */
  border-top: 3px solid #097b27;  /* アイコンの線の太さと種類と色 */
  border-right: 3px solid #097b27;  /* アイコンの線の太さと種類と色 */
  transform: rotate(45deg);  /* 回転して右向きの矢印に */
  transition: transform 0.2s ease-in; /* transition プロパティを設定 */
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

details[open] summary::before {
  transform: rotate(135deg); /* 開いた状態では90度回転 */
}
詳細を見る

詳細の内容

関連ページ:CSS 擬似要素 ::before ::after

※ 現時点(2023年4月)では Safari は summary 要素での疑似要素を使った transition にはバグがあるようで、アニメーションしません。

Safari でもアイコンのアニメーションが動作するようにするには、transition の代わりに keyframes を使ってアニメーションを設定するか、JavaScript(Web Animation API)を使用します。

※ 現時点では details 要素の開閉時のアニメーションは CSS では動作しないため、details 要素の開閉時のアニメーションは JavaScript を使うことになります。そのため、開閉時のアニメーションも設定する場合はアイコンのアニメーションも JavaScript で実装するのが簡単です。

keyframes を使ったアニメーション

以下は、transition の代わりに keyframes を使ったアニメーションで書き換えた例です。

この例では疑似要素でアイコンの SVG 画像を背景画像として設定しています。

keyframes を使う場合は、前述の例のように擬似要素でアイコンを作成したり、データ URL スキームを使って URI エンコードした svg のコードを埋め込んても動作します。

summary::before に transition の代わりに animation で閉じる際のアニメーション(close)を指定し、keyframes アニメーション(close)を設定します。

同様に details[open] summary::before にも animation で開く際のアニメーション(open)を指定し、keyframes アニメーション(open)を設定します。

但し、以下の方法の場合、ページを読み込む際に閉じる際のアニメーションが一度実行されてしまいます。そのため、JavaScript で設定するのがおすすめです。

details {
  border: 1px solid #aaa;
  max-width: 300px;
}

details .details-content {
  padding: 1rem 1rem 1rem 2rem;
}

summary {
  display: block; /* 矢印アイコンを非表示にする */
  cursor: pointer; /* カーソルの形状をポインターに */
  padding: 0.5rem 0.5rem 0.5rem 8px;
}

summary::-webkit-details-marker {
  display: none;
}

summary::before {
  content: '';    /* 値を空に */
  display: inline-block;
  background-image: url(images/chevron-double-right.svg);  /* 背景画像を指定 */
  background-repeat: no-repeat;  /* 繰り返しなし */
  background-size: contain;  /* 画像全体が収まるよう拡大・縮小 */
  vertical-align: -2px;  /* 横並びの場合の垂直方向の位置の調整 */
  width: 17px;  /* 表示する画像の幅 */
  height: 17px;  /* 表示する画像の高さ */
  margin-right: 10px;
  animation: close 0.2s ease-in;  /* animation で閉じる際のアニメーションを設定 */
}

/* 閉じる際の animation */
@keyframes close {
  0% {
    transform: rotate(90deg);
  }
  100% {
    transform: rotate(0deg);
  }
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

details[open] summary::before {
  /* animation で開く際のアニメーションを設定 */
  animation: open 0.2s ease-in forwards;
}

/* 開く際の animation */
@keyframes open {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(90deg);
  }
}
詳細を見る

詳細の内容

表示は上記の SVG 画像を使ったコードと同じ結果になります
details {
  border: 1px solid #aaa;
  max-width: 300px;
}

details .details-content {
  padding: 1rem 1rem 1rem 2rem;
}

summary {
  display: block; /* 矢印アイコンを非表示にする */
  cursor: pointer; /* カーソルの形状をポインターに */
  padding: 0.5rem;
}

summary::-webkit-details-marker {
  display: none;
}

summary::before {
  content: ""; /* 値を空に */
  display: inline-block; /* カーソルの形状をポインターに */
  vertical-align: -3px;  /* 垂直方向の位置の調整 */
  height: 17px;  /* 表示する高さ */
  width: 17px; /* 表示する幅 */
  margin-right: 8px;  /* アイコンの右側のスペース */
  background-repeat: no-repeat; /* 繰り返しなし */
  background-size: contain; /* 以下の svg に width と height がないので省略可能 */
  /* データ URL スキームを使って URI エンコードした svg のコードを埋め込む */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23097b27' viewBox='0 0 16 16'%3E  %3Cpath fill-rule='evenodd' d='M3.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L9.293 8 3.646 2.354a.5.5 0 0 1 0-.708z'/%3E  %3Cpath fill-rule='evenodd' d='M7.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L13.293 8 7.646 2.354a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
  animation: close 0.2s ease-in;  /* animation でアニメーションを設定 */
}

/* 閉じる際の animation */
@keyframes close {
  0% {
    transform: rotate(90deg);
  }
  100% {
    transform: rotate(0deg);
  }
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

details[open] summary::before {
  animation: open 0.2s ease-in forwards;
}

/* 開く際の animation */
@keyframes open {
  0% {
    transform: rotate(0deg);
  }
  100%{
    transform: rotate(90deg);
  }
}

関連ページ:SVG の基本的な使い方

複数の details 要素をまとめて配置

以下は、複数の details 要素を一緒にまとめて配置する例です。この例ではアイコンを右側に配置しています。

詳細を見る

詳細の内容

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eos minima labore quasi iure sit similique, tempore consequatur illum at. Amet deserunt officiis iure, sunt veritatis itaque sed maxime fugit officia.

詳細を見る

詳細の内容

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eos minima labore quasi iure sit similique, tempore consequatur illum at. Amet deserunt officiis iure, sunt veritatis itaque sed maxime fugit officia.

詳細を見る

詳細の内容

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eos minima labore quasi iure sit similique, tempore consequatur illum at. Amet deserunt officiis iure, sunt veritatis itaque sed maxime fugit officia.

この例では、複数の details 要素を囲む div 要素(.details-wrapper)を配置しています。

<div class="details-wrapper">
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content">
      <p>詳細の内容</p>
      <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit..</p>
    </div>
  </details>
  ・・・中略・・・
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content">
      <p>詳細の内容</p>
      <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit...</p>
    </div>
  </details>
</div>

details 要素のスタイルは前述の例と同じですが、.details-wrapper に囲まれた場合、details 要素の border-bottom を none にして、最後の details 要素にのみ border-bottom を指定しています(やり方は色々あるかと思います)。

アイコンは ::before で横棒を、:after で縦棒を、幅16px、高さ3pxの長方形で作成して、縦棒は90度回転させています。アニメーションは Safari でも表示されるように keyframe で設定し、両方の棒に異なるアニメーションを設定しています。

縦棒だけを回転させるアニメーションであれば、横棒の回転のアニメーション(省略可能とコメントのある部分)は不要です。

details {
  border: 1px solid #aaa;
  max-width: 300px;
}

.details-wrapper details {
  border-bottom: none;
}

.details-wrapper details:last-child {
  border-bottom:  1px solid #aaa;
}

details .details-content {
  padding: 1rem 1rem 1rem 2rem;
}

details summary {
  display: block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem;
}

details summary::-webkit-details-marker {
  display: none;
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

/* +のアイコンの疑似要素(縦と横の棒の共通の設定) */
details summary::before,
details summary::after {
  content: "";
  position: absolute;
  right: 1rem;
  top: 0;
  bottom: 0;
  margin: auto 0;
  background-color: #333;
  width: 16px;
  height: 3px;
}

/* 縦棒は90度回転 */
details summary::after {
  transform: rotate(90deg);
}

/* 閉じる際の横棒のアニメーションの指定(省略可能) */
details summary::before {
  animation: closing-before 0.3s ease-in;
}
/* 閉じる際の縦棒のアニメーションの指定 */
details summary::after {
  animation: closing-after 0.3s ease-in;
}
/* 閉じる際の横棒のアニメーション(省略可能) */
@keyframes closing-before {
  0% {
    transform: rotate(180deg);
  }
  100% {
    transform: rotate(0deg);
  }
}
/* 閉じる際の縦棒のアニメーション */
@keyframes closing-after {
  0% {
    transform: rotate(180deg);
    opacity: 0;
  }
  100% {
    transform: rotate(90deg);
    opacity: 1;
  }
}
/* 開く際の横棒のアニメーションの指定(省略可能) */
details[open] summary::before {
  animation: opening-before 0.3s ease-in forwards;
}
/* 開く際の縦棒のアニメーションの指定 */
details[open] summary::after {
  animation: opening-after 0.3s ease-in forwards;
}
/* 開く際の横棒のアニメーション(省略可能) */
@keyframes opening-before {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(180deg);
  }
}
/* 開く際の縦棒のアニメーション */
@keyframes opening-after {
  0% {
    transform: rotate(90deg);
    opacity: 1;
  }
  100% {
    transform: rotate(180deg);
    opacity: 0;
  }
}

JavaScript でアニメーション

Web Animation API を使って summary 要素をクリックして details 要素が開閉する際のアニメーションを実装する例です。

summary 要素のアイコンのアニメーションも Web Animation API で実装しています。

詳細を見る

詳細の内容

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eos minima labore quasi iure sit similique, tempore consequatur illum at. Amet deserunt officiis iure, sunt veritatis itaque sed maxime fugit officia.

HTML では JavaScript で識別するためのクラス(js-animation)を details 要素に指定しています。

details 要素のコンテンツの構造やスタイルによっては、JavaScript で開閉するコンテンツの要素に上下の padding の指定があると、アニメーションする際にカクっとなる可能性があるので、この例では .details-content-wrapper を追加して .details-content をラップしています。

HTML
<details class="js-animation">
  <summary>詳細を見る</summary>
  <div class="details-content-wrapper">
    <div class="details-content">
      <p>詳細の内容</p>
      <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit...</p>
    </div>
  </div>
</details>

CSS は今までの例とほとんど同じです。但し、開閉のアニメーションを適用する .details-content には padding を指定せず、.details-content-wrapper に padding を指定するようにしています。

または .details-content に padding を指定して、開閉のアニメーションを .details-content-wrapper に適用することもできます。

また、.details-content には overflow: hidden を指定し、開閉に合わせてコンテンツが表示されるようにします(指定しないと、コンテンツが一度に表示されてしまいます)。

アイコンのアニメーションは JavaScript で実装するので、前述の例で指定していた transition の部分は削除しています。

CSS
details {
  border: 1px solid #aaa;
  max-width: 300px;
}

/* JavaScriptで開閉する要素(.details-content)には上下のpaddingを指定せずこちらに指定 */
details .details-content-wrapper {
  padding: 1rem 1rem 1rem 2rem;
}

details .details-content {
  overflow: hidden;  /* コンテンツがアニメーションで徐々に表示されるように */
}

details summary {
  display: block; /* 矢印アイコンを非表示に */
  cursor: pointer; /* カーソルの形状をポインターに */
  position: relative; /* 絶対配置する疑似要素のアイコンの基準に */
  padding: 0.5rem 0.5rem 0.5rem 36px; /* padding-left にアイコンのスペースを確保 */
}

details summary::-webkit-details-marker {
  display: none; /* Safari で三角形のアイコンを非表示に */
}

/* 独自のアイコンを擬似要素で作成 */
details summary::before {
  content: ""; /* content は空を指定 */
  position: absolute; /* 絶対配置 */
  top: 0;
  bottom: 0;
  left: 10px;  /* 位置の調整 */
  margin: auto 0;  /* top:0 と bottom:0 と共に指定して垂直方向中央揃え */
  width: 10px; /* アイコンの辺の長さ */
  height: 10px; /* アイコンの辺の長さ */
  border-top: 3px solid #097b27;  /* アイコンの線の太さと種類と色 */
  border-right: 3px solid #097b27;  /* アイコンの線の太さと種類と色 */
  transform: rotate(45deg);  /* 回転して右向きの矢印に */
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

JavaScript では、DOMContentLoaded イベントを使って DOM の解析が完了してからアニメーションの処理を定義した関数を実行する(呼び出す)ようにしています。

関数 setupToggleDetailsAnimation() では js-animation クラスが指定されたすべての details 要素を取得して、それぞれに対して summary 要素がクリックされた際の処理を記述しています。

summary 要素のイベントリスナの設定では、e.preventDefault() によりデフォルトの動作である open 属性の着脱をキャンセルして、JavaScript で制御するようにします。

これは、open 属性が details 要素から削除されると即座にコンテンツが非表示になってしまうので、アニメーション終了後 open 属性を削除するようにし、開く際に open 属性を追加しています。

details 要素の開閉状態は、details 要素の open プロパティ(論理値)で判定しています。

以下の場合、elem が details 要素を表しているので、elem.open で判定しています(23行目)。 elem.hasAttribute('open') でも判定できます。

開閉のアニメーションは、animate() メソッドで .details-content の opacity と height を変化させ、height はボーダー、パディング、水平スクロールバーを含めた要素の高さを取得する offsetHeight で取得しています。

アイコンのアニメーションは animate() メソッドの第2引数に指定する options オブジェクトの pseudoElement プロパティを使って疑似要素 ::before に適用するようにしています。

animate() メソッドは Animation オブジェクトのインスタンスを返すので、そのインスタンスの onfinish イベントハンドラで終了(finish イベント)を検知して、open 属性を削除しています(47〜49行目)。

JavaScript
// DOM ツリーの構築が完了したら定義した関数を呼び出す
document.addEventListener('DOMContentLoaded', () => {
  setupToggleDetailsAnimation();
});

// アニメーションの処理を定義した関数
function setupToggleDetailsAnimation() {
  // js-animation クラスを指定したすべての details 要素
  const details = document.querySelectorAll('details.js-animation');

  // 上記で取得したそれぞれの要素に対して以下を実行(elem は各 details 要素)
  details.forEach(elem => {
    // summary 要素(この要素の疑似要素で作成したアイコンをアニメーション)
    const summary = elem.querySelector('summary');
    // details-content クラスを指定した要素(この要素をアニメーション)
    const content = elem.querySelector('.details-content');

    // summary 要素にクリックイベントのリスナを設定
    summary.addEventListener('click', (e) => {
      // デフォルトの動作(open 属性の着脱)をキャンセル
      e.preventDefault();
      // open 属性が指定されていれば(開いていれば)
      if (elem.open) {
        //閉じるアニメーションを実行(contentはdetails-contentクラスを指定した要素)
        const closeDetails = content.animate(
          {
            opacity: [1, 0],
            height: [content.offsetHeight + 'px', 0],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        //アイコンを回転させるアニメーションを実行(summary 要素)
        const rotateIcon = summary.animate(
          { rotate: ["90deg", "0deg"] },
          {
            duration: 300,
            // summary 要素の疑似要素にアニメーションを適用
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        // 閉じるアニメーションが終了したら open 属性を削除
        closeDetails.onfinish = () => {
          elem.removeAttribute('open');
        }
      } else {
        // open 属性を details 要素に追加
        elem.setAttribute('open', 'true');
        // 開くアニメーションを実行
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        // アイコンを回転させるアニメーションを実行(summary 要素)
        const rotateIcon = summary.animate(
          { rotate: ["0deg", "90deg"] },
          {
            duration: 300,
            // summary 要素の疑似要素にアニメーションを適用
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
      }
    });
  });
}

以下は、上記の details 要素を複数まとめて配置する例です。

JavaScript は同じです。複数の details 要素をラップして、CSS でボーダーを調整します。

詳細を見る

詳細の内容

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eos minima labore quasi iure sit similique, tempore consequatur illum at. Amet deserunt officiis iure, sunt veritatis itaque sed maxime fugit officia.

詳細を見る

詳細の内容

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eos minima labore quasi iure sit similique, tempore consequatur illum at. Amet deserunt officiis iure, sunt veritatis itaque sed maxime fugit officia.

詳細を見る

詳細の内容

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eos minima labore quasi iure sit similique, tempore consequatur illum at. Amet deserunt officiis iure, sunt veritatis itaque sed maxime fugit officia.

<div class="details-wrapper">
  <details class="js-animation">
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit....?</p>
      </div>
    </div>
  </details>
  ・・・中略・・・
  <details class="js-animation">
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Excepturi quis quibusdam animi aliquid voluptatem aut illo...</p>
      </div>
    </div>
  </details>
</div>
/* 追加 */
.details-wrapper details {
  border-bottom: none;
}
/* 追加 */
.details-wrapper details:last-child {
  border-bottom:  1px solid #aaa;
}

details {
  border: 1px solid #aaa;
  max-width: 500px;
}

details .details-content-wrapper {
  padding: 1rem 1rem 1rem 2rem;
}

details .details-content {
  overflow: hidden;
}

details summary {
  display: block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem 0.5rem 0.5rem 36px;
  /* 追加 */
  transition: background-color .3s; /* 背景色のトランジション */
}

/* 追加 */
details summary:hover {
  background-color: #f1fff8; /* ホバー時の背景色 */
}

details summary::-webkit-details-marker {
  display: none;
}

details summary::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 10px;
  margin: auto 0;
  width: 10px;
  height: 10px;
  border-top: 3px solid #097b27;
  border-right: 3px solid #097b27;
  transform: rotate(45deg);
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

以下も複数の details 要素を一緒にまとめて配置するアイコンとその配置位置が異なる例です。

詳細を見る

詳細の内容

Lorem ipsum dolor sit amet consectetur adipisicing elit. Dignissimos aliquam deserunt doloremque maiores incidunt ea recusandae dolores maxime. Voluptates, architecto quae repellat iste incidunt amet dolor sit facilis nulla tempore!

Voluptatibus magni fugiat sequi, nam necessitatibus iure, doloribus repellendus sit mollitia, vel asperiores tenetur veritatis fuga et dolore soluta non dignissimos labore sed alias itaque molestias! Perspiciatis quaerat facilis odit.

詳細を見る

詳細の内容

Tempore esse, tenetur dolore perferendis eum sequi provident minus ab? Ullam reiciendis provident nemo praesentium non tempora quos et, eum voluptatibus, architecto soluta! Ratione quae dolorem quis ipsum et inventore.

詳細を見る

詳細の内容

Vero vitae quibusdam impedit dolore! Ut voluptatibus, hic minima dolore reiciendis libero! Cupiditate ea ratione ullam quo delectus rerum harum, provident totam natus laboriosam nisi incidunt cum at, impedit porro?

Aliquid blanditiis facilis nulla, nemo velit reiciendis provident hic ad nesciunt molestiae quae sunt, aut consequatur dolor nobis est ullam! Consequuntur temporibus rerum magni ipsum excepturi fugiat soluta ipsa quo!

Nesciunt illum eveniet quod assumenda eligendi molestias repellendus, quas corporis dolor cumque numquam maiores ea. Quis deserunt exercitationem perspiciatis cupiditate expedita atque repellat, aliquid molestias excepturi commodi laudantium maiores labore.

前述の例同様、この場合も .details-content-wrapper で .details-content(JavaScript で開閉するコンテンツ)をラップして、.details-content-wrapper にパディングを設定しています。

<div class="details-wrapper">
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit....</p>
      </div>
    </div>
  </details>
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit....</p>
      </div>
    </div>
  </details>
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit....</p>
      </div>
    </div>
  </details>
</div>

この例では、summary 要素に transition を設定して、ホバー時に背景色を変化するようにしています。

.details-wrapper details {
  border: 1px solid #aaa;
  max-width: 500px;
  border-bottom: none;
}

.details-wrapper details:last-child {
  border-bottom:  1px solid #aaa;
}

/* JavaScriptで開閉する要素(.details-content)には上下のpaddingを指定せずこちらに指定 */
details .details-content-wrapper {
  padding: 1rem 1rem 1rem 2rem;
}

details .details-content {
  overflow: hidden;
}

details summary {
  display: block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem;
  transition: background-color .3s; /* transition でホバー時に背景色を変化 */
}

details summary:hover {
  background-color: #eaf0f9; /* ホバー時の背景色 */
}

details summary::-webkit-details-marker {
  display: none;
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

/* +のアイコンの疑似要素(縦と横の棒の共通の設定) */
details summary::before,
details summary::after {
  content: "";
  position: absolute;
  right: 1rem;
  top: 0;
  bottom: 0;
  margin: auto 0;
  background-color: #333;
  width: 16px;
  height: 3px;
}

/* 縦棒は90度回転 */
details summary::after {
  transform: rotate(90deg);
}

前述の例では、.js-animation を指定した details を取得してそれぞれにアニメーションを設定していますが、この例では .details-wrapper 配下のすべての details 要素を取得してそれぞれにアニメーションを設定しています(各 details に js-animation というクラスを指定する必要がありません)。

開閉のアニメーションは前述の例と同じです。summary 要素のアイコンのアニメーションが異なります。

//  DOM ツリーの構築が完了したら定義した関数を呼び出す
document.addEventListener('DOMContentLoaded', () => {
  setupToggleDetailsAnimation2();
});

// アニメーションの処理を定義した関数
function setupToggleDetailsAnimation2() {
  // .details-wrapper 配下のすべての details 要素
  const details = document.querySelectorAll('.details-wrapper details');

  // 上記で取得したそれぞれの要素に対して以下を実行(elem は各 details 要素)
  details.forEach(elem => {
    // summary 要素
    const summary = elem.querySelector('summary');
    // details-content クラスを指定した要素
    const content = elem.querySelector('.details-content');

    // summary 要素にクリックイベントのリスナを設定
    summary.addEventListener('click', (e) => {
      e.preventDefault();
      if (elem.open) {
        // details-content クラスを指定した要素を閉じるアニメーション
        const closeDetails = content.animate(
          {
            opacity: [1, 0],
            height: [content.offsetHeight + 'px', 0],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        //閉じる際の ::before(横棒)のアニメーション
        const rotate1 = summary.animate(
          {
            rotate: ["180deg", "0deg"],
          },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        //閉じる際の ::after(縦棒)のアニメーション
        const rotate2 = summary.animate(
          {
            rotate: ["90deg", "0deg"],
            opacity: [0,1]
          },
          {
            duration: 300,
            pseudoElement: "::after",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        closeDetails.onfinish = () => {
          elem.removeAttribute('open');
        }
      } else {
        elem.setAttribute('open', 'true');
        // details-content クラスを指定した要素を開くアニメーション
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        //開く際の ::before(横棒)のアニメーション
        const rotate1 = summary.animate(
          {
            rotate: ["0deg", "180deg"],
          },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        //開く際の ::after(縦棒)のアニメーション
        const rotate2 = summary.animate(
          {
            rotate: ["0deg", "90deg"],
            opacity: [1,0]
          },
          {
            duration: 300,
            pseudoElement: "::after",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
      }
    });
  });
}

開いたら他を閉じる

以下は、1つの details 要素を開いたら、他の details 要素を閉じる例です。

詳細を見る 1

詳細の内容 1

Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae repudiandae alias distinctio sapiente quo sequi nostrum velit qui voluptates ea harum, facere atque rem ratione impedit amet cumque. Laborum, eaque.

Deleniti aspernatur repellendus hic consectetur, quidem magnam accusamus adipisci error optio nam nulla! Rerum explicabo numquam consequuntur dicta esse impedit eaque autem laborum provident officia, sit fuga fugit quas? Ut.

Deleniti, quisquam iusto vero dolor ut saepe minima debitis. Explicabo assumenda odit iure fugiat hic iste saepe magnam cumque dignissimos illum totam consectetur ipsam modi culpa, minima aliquid excepturi quas!

Illum, enim necessitatibus facere inventore, dolor eaque temporibus, adipisci ipsa iste ullam sapiente? Dolorum facilis reprehenderit atque placeat omnis sapiente. Molestiae quasi adipisci maxime in! Suscipit voluptatum minima ipsa quisquam!

Maiores veniam excepturi quo distinctio. Reprehenderit natus neque provident officia omnis ipsum aut quod mollitia cupiditate eos distinctio harum magni doloribus accusantium blanditiis soluta veritatis, eveniet, libero expedita tenetur eum.

詳細を見る 2

詳細の内容 2

Deleniti, quisquam iusto vero dolor ut saepe minima debitis. Explicabo assumenda odit iure fugiat hic iste saepe magnam cumque dignissimos illum totam consectetur ipsam modi culpa, minima aliquid excepturi quas!

Illum, enim necessitatibus facere inventore, dolor eaque temporibus, adipisci ipsa iste ullam sapiente? Dolorum facilis reprehenderit atque placeat omnis sapiente. Molestiae quasi adipisci maxime in! Suscipit voluptatum minima ipsa quisquam!

Maiores veniam excepturi quo distinctio. Reprehenderit natus neque provident officia omnis ipsum aut quod mollitia cupiditate eos distinctio harum magni doloribus accusantium blanditiis soluta veritatis, eveniet, libero expedita tenetur eum.

詳細を見る 3

詳細の内容 3

Porro ipsam at laudantium distinctio officiis ratione quas asperiores adipisci, fuga incidunt optio obcaecati ea tempora ab, nihil, architecto hic placeat magni tempore. Officiis, dolorum earum dolore dignissimos doloremque ex.

詳細を見る 4

詳細の内容 4

Illum, enim necessitatibus facere inventore, dolor eaque temporibus, adipisci ipsa iste ullam sapiente? Dolorum facilis reprehenderit atque placeat omnis sapiente. Molestiae quasi adipisci maxime in! Suscipit voluptatum minima ipsa quisquam!

Maiores veniam excepturi quo distinctio. Reprehenderit natus neque provident officia omnis ipsum aut quod mollitia cupiditate eos distinctio harum magni doloribus accusantium blanditiis soluta veritatis, eveniet, libero expedita tenetur eum.

Nam impedit eveniet nobis, natus voluptate quia animi maxime provident dolorum culpa quis quibusdam esse, laudantium reprehenderit tempore dolore et delectus assumenda repellat laborum sint atque. In doloribus rem placeat.

詳細を見る 5

詳細の内容 5

Nam impedit eveniet nobis, natus voluptate quia animi maxime provident dolorum culpa quis quibusdam esse, laudantium reprehenderit tempore dolore et delectus assumenda repellat laborum sint atque. In doloribus rem placeat.

Vitae dolores porro dignissimos soluta qui, laborum laboriosam itaque voluptatem at temporibus ea totam eos nostrum minus labore a tempora similique harum expedita! Veniam autem omnis quo, consequatur fugit vel!

この例では、閉じる際の処理を close() という要素を引数に受け取る関数として定義しています。

また、対象の details 要素を引数に受け取り、現在開いている details 要素を閉じる関数 closeOpenedDetails() も定義し、内部では閉じる処理は close() を呼び出します。

summary 要素がクリックされた際には、closeOpenedDetails() で現在開いている details 要素を閉じてから、クリックされた要素を開きます。

summary 要素がクリックされた際の閉じる処理では close() を呼び出します。

内容的には前述の例とほぼ同じです。もっと良い方法があるかもしれません。

//  DOM ツリーの構築が完了したら定義した関数を呼び出す
document.addEventListener('DOMContentLoaded', () => {
  setupToggleDetailsAnimation3();
});

// アニメーションの処理を定義した関数
function setupToggleDetailsAnimation3() {
  // .details-wrapper 配下のすべての details 要素
  const details = document.querySelectorAll('.details-wrapper details');

  // details 要素を閉じる関数
  function close(el) {
    // details-content クラスを指定した要素
    const content = el.querySelector('.details-content');
    // details-content クラスを指定した要素を閉じるアニメーション
    const closeDetails = content.animate(
      {
        opacity: [1, 0],
        height: [content.offsetHeight + 'px', 0],
      },
      {
        duration: 300,
        easing: 'ease-in',
      }
    );
    //summary 要素
    const summary = el.querySelector('summary');
    //閉じる際の ::before(横棒)のアニメーション
    const rotate1 = summary.animate(
      {
        rotate: ["180deg", "0deg"],
      },
      {
        duration: 300,
        pseudoElement: "::before",
        easing: 'ease-in',
        fill: 'forwards',
      }
    );
    //閉じる際の ::after(縦棒)のアニメーション
    const rotate2 = summary.animate(
      {
        rotate: ["90deg", "0deg"],
        opacity: [0, 1]
      },
      {
        duration: 300,
        pseudoElement: "::after",
        easing: 'ease-in',
        fill: 'forwards',
      }
    );
    closeDetails.onfinish = () => {
      el.removeAttribute('open');
    }
  }

  //現在開いている details 要素を閉じる関数
  function closeOpenedDetails(detailsElems) {
    detailsElems.forEach(elem => {
      if (elem.open) {
        //開いた状態であれば close() を呼び出して閉じる
        close(elem);
      }
    });
  }

  // 上記で取得したそれぞれの要素に対して以下を実行(elem は各 details 要素)
  details.forEach(elem => {
    // summary 要素
    const summary = elem.querySelector('summary');
    // details-content クラスを指定した要素
    const content = elem.querySelector('.details-content');

    // summary 要素にクリックイベントのリスナを設定
    summary.addEventListener('click', (e) => {
      e.preventDefault();
      //現在開いている details 要素があれば閉じる
      closeOpenedDetails(details);
      if (elem.open) {
        //開いていれば閉じる
        close(elem);
      } else {
        elem.setAttribute('open', 'true');
        // details-content クラスを指定した要素を開くアニメーション
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        //開く際の ::before(横棒)のアニメーション
        const rotate1 = summary.animate(
          {
            rotate: ["0deg", "180deg"],
          },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        //開く際の ::after(縦棒)のアニメーション
        const rotate2 = summary.animate(
          {
            rotate: ["0deg", "90deg"],
            opacity: [1, 0]
          },
          {
            duration: 300,
            pseudoElement: "::after",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
      }
    });
  });
};

HTML と CSS は前述の例と同じです。

<div class="details-wrapper">
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit....</p>
      </div>
    </div>
  </details>
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit....</p>
      </div>
    </div>
  </details>
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit....</p>
      </div>
    </div>
  </details>
</div>
.details-wrapper details {
  border: 1px solid #aaa;
  max-width: 600px;
  border-bottom: none;
}

.details-wrapper details:last-child {
  border-bottom: 1px solid #aaa;
}

/* JavaScriptで開閉する要素(.details-content)には上下のpaddingを指定せずこちらに指定 */
.details-wrapper details .details-content-wrapper {
  padding: 1rem 1rem 1rem 2rem;
}

.details-wrapper details .details-content {
  overflow: hidden;
}

.details-wrapper details summary {
  display: block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem;
  transition: background-color .3s;
}

.details-wrapper details summary:hover {
  background-color: #eaf0f9;
}

.details-wrapper details summary::-webkit-details-marker {
  display: none;
}

.details-wrapper details[open] summary {
  border-bottom: 1px solid #aaa;
}

.details-wrapper details summary::before,
.details-wrapper details summary::after {
  content: "";
  position: absolute;
  right: 1rem;
  top: 0;
  bottom: 0;
  margin: auto 0;
  background-color: #333;
  width: 16px;
  height: 3px;
}

.details-wrapper details summary::after {
  transform: rotate(90deg);
}

連続するクリックの制御

例えば、ユーザーが summary 要素の部分をダブルクリックすると、一瞬開いてすぐに閉じてしまいます。

以下はアニメーション中に連続してクリックした場合は、後続のアニメーションをしないようにする例です(ダブルクリックしても、シングルクリックと同じように動作します)。

詳細を見る

詳細の内容

Lorem ipsum dolor sit, amet consectetur adipisicing elit. Eos minima labore quasi iure sit similique, tempore consequatur illum at. Amet deserunt officiis iure, sunt veritatis itaque sed maxime fugit officia.

アニメーション中にクリックしても、アニメーションを行わないようにするために isAnimating という変数(フラグ)を用意して、その値によりアニメーションを実行するかどうかを判定します。

isAnimating の値は初期状態では false にしておき、アニメーションを開始したら値を true に変更し、アニメーションが終了したら false にします。

クリックイベントのリスナーで isAnimating の値が true の場合は何もしないように return します。

//  DOM ツリーの構築が完了したら定義した関数を呼び出す
document.addEventListener('DOMContentLoaded', () => {
  setupToggleDetailsAnimation4();
});

// アニメーションの処理を定義した関数
function setupToggleDetailsAnimation4() {
  const details = document.querySelectorAll('details.js-animation');
  details.forEach(elem => {
    const summary = elem.querySelector('summary');
    const content = elem.querySelector('.details-content');
    //アニメーション中かどうかを表すフラグ
    let isAnimating = false;
    summary.addEventListener('click', (e) => {
      e.preventDefault();
      // 連続するクリックの制御。アニメーション中の場合はリターン(何もしない)
      if (isAnimating === true) {
        return;
      }
      if (elem.open) {
        // 連続するクリックの制御。アニメーション中とする(isAnimating を true に)
        isAnimating = true;
        const closeDetails = content.animate(
          {
            opacity: [1, 0],
            height: [content.offsetHeight + 'px', 0],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotateIcon = summary.animate(
          { rotate: ["90deg", "0deg"] },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        closeDetails.onfinish = () => {
          elem.removeAttribute('open');
          // 連続するクリックの制御。アニメーション終了(isAnimating を false に)
          isAnimating = false;
        }
      } else {
        elem.setAttribute('open', 'true');
        // 連続するクリックの制御。アニメーション中(isAnimating を true に)
        isAnimating = true;
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotateIcon = summary.animate(
          { rotate: ["0deg", "90deg"] },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        openDetails.onfinish = () => {
          // 連続するクリックの制御。アニメーション終了(isAnimating を false に)
          isAnimating = false;
        }
      }
    });
  });
};
<details class="js-animation">
  <summary>詳細を見る</summary>
  <div class="details-content-wrapper">
    <div class="details-content">
      <p>詳細の内容</p>
      <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit...</p>
    </div>
  </div>
</details>
details {
  border: 1px solid #aaa;
  max-width: 300px;
}

details .details-content-wrapper {
  padding: 1rem 1rem 1rem 2rem;
  overflow: hidden;
}

details summary {
  display: block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem 0.5rem 0.5rem 36px;
}

details summary::-webkit-details-marker {
  display: none;
}

details summary::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 10px;
  margin: auto 0;
  width: 10px;
  height: 10px;
  border-top: 3px solid #097b27;
  border-right: 3px solid #097b27;
  transform: rotate(45deg);
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

以下は前述の「複数の details 要素をまとめて配置する」例を連続してクリックしてもアニメーションしないようにしたものです。

詳細を見る

詳細の内容

Lorem ipsum dolor sit amet consectetur adipisicing elit. Dignissimos aliquam deserunt doloremque maiores incidunt ea recusandae dolores maxime. Voluptates, architecto quae repellat iste incidunt amet dolor sit facilis nulla tempore!

Voluptatibus magni fugiat sequi, nam necessitatibus iure, doloribus repellendus sit mollitia, vel asperiores tenetur veritatis fuga et dolore soluta non dignissimos labore sed alias itaque molestias! Perspiciatis quaerat facilis odit.

詳細を見る

詳細の内容

Tempore esse, tenetur dolore perferendis eum sequi provident minus ab? Ullam reiciendis provident nemo praesentium non tempora quos et, eum voluptatibus, architecto soluta! Ratione quae dolorem quis ipsum et inventore.

詳細を見る

詳細の内容

Vero vitae quibusdam impedit dolore! Ut voluptatibus, hic minima dolore reiciendis libero! Cupiditate ea ratione ullam quo delectus rerum harum, provident totam natus laboriosam nisi incidunt cum at, impedit porro?

Aliquid blanditiis facilis nulla, nemo velit reiciendis provident hic ad nesciunt molestiae quae sunt, aut consequatur dolor nobis est ullam! Consequuntur temporibus rerum magni ipsum excepturi fugiat soluta ipsa quo!

Nesciunt illum eveniet quod assumenda eligendi molestias repellendus, quas corporis dolor cumque numquam maiores ea. Quis deserunt exercitationem perspiciatis cupiditate expedita atque repellat, aliquid molestias excepturi commodi laudantium maiores labore.

//  DOM ツリーの構築が完了したら定義した関数を呼び出す
document.addEventListener('DOMContentLoaded', () => {
  setupToggleDetailsAnimation5();
});

// アニメーションの処理を定義した関数
function setupToggleDetailsAnimation5() {
  const details = document.querySelectorAll('.details-wrapper details');
  details.forEach(elem => {
    const summary = elem.querySelector('summary');
    const content = elem.querySelector('.details-content');
    //アニメーション中かどうかを表すフラグ
    let isAnimating = false;
    summary.addEventListener('click', (e) => {
      e.preventDefault();
      // 連続するクリックの制御。アニメーション中の場合はリターン(何もしない)
      if (isAnimating === true) {
        return;
      }
      if (elem.open) {
        // 連続するクリックの制御。アニメーション中
        isAnimating = true;
        const closeDetails = content.animate(
          {
            opacity: [1, 0],
            height: [content.offsetHeight + 'px', 0],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotate1 = summary.animate(
          {
            rotate: ["180deg", "0deg"],
          },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        const rotate2 = summary.animate(
          {
            rotate: ["90deg", "0deg"],
            opacity: [0,1]
          },
          {
            duration: 300,
            pseudoElement: "::after",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        closeDetails.onfinish = () => {
          elem.removeAttribute('open');
          // 連続するクリックの制御。アニメーション終了(isAnimating の値を false に変更)
          isAnimating = false;
        }
      } else {
        elem.setAttribute('open', 'true');
        // 連続するクリックの制御。アニメーション中
        isAnimating = true;
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotate1 = summary.animate(
          {
            rotate: ["0deg", "180deg"],
          },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        const rotate2 = summary.animate(
          {
            rotate: ["0deg", "90deg"],
            opacity: [1,0]
          },
          {
            duration: 300,
            pseudoElement: "::after",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        openDetails.onfinish = () => {
          // 連続するクリックの制御。アニメーション終了(isAnimating の値を false に変更)
          isAnimating = false;
        }
      }
    });
  });
};
<div class="details-wrapper">
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Lorem ipsum dolor sit amet consectetur adipisicing elit..</p>
      </div>
    </div>
  </details>
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Tempore esse, tenetur dolore perferendis eum sequi ..</p>
      </div>
    </div>
  </details>
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Vero vitae quibusdam impedit dolore! Ut voluptatibus, ...</p>
      </div>
    </div>
  </details>
</div>
.details-wrapper details {
  border: 1px solid #aaa;
  max-width: 500px;
  border-bottom: none;
}

.details-wrapper details:last-child {
  border-bottom:  1px solid #aaa;
}

details .details-content-wrapper {
  padding: 1rem 1rem 1rem 2rem;
}

details .details-content {
  overflow: hidden;
}

details summary {
  display: block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem;
  transition: background-color .3s;
}

details summary:hover {
  background-color: #eaf0f9;
}

details summary::-webkit-details-marker {
  display: none;
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

details summary::before,
details summary::after {
  content: "";
  position: absolute;
  right: 1rem;
  top: 0;
  bottom: 0;
  margin: auto 0;
  background-color: #333;
  width: 16px;
  height: 3px;
}

details summary::after {
  transform: rotate(90deg);
}

以下は前述の「開いたら他を閉じる」の例を連続してクリックしてもアニメーションしないようにしたものです。

詳細を見る 1

詳細の内容 1

Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae repudiandae alias distinctio sapiente quo sequi nostrum velit qui voluptates ea harum, facere atque rem ratione impedit amet cumque. Laborum, eaque.

Deleniti aspernatur repellendus hic consectetur, quidem magnam accusamus adipisci error optio nam nulla! Rerum explicabo numquam consequuntur dicta esse impedit eaque autem laborum provident officia, sit fuga fugit quas? Ut.

Deleniti, quisquam iusto vero dolor ut saepe minima debitis. Explicabo assumenda odit iure fugiat hic iste saepe magnam cumque dignissimos illum totam consectetur ipsam modi culpa, minima aliquid excepturi quas!

Illum, enim necessitatibus facere inventore, dolor eaque temporibus, adipisci ipsa iste ullam sapiente? Dolorum facilis reprehenderit atque placeat omnis sapiente. Molestiae quasi adipisci maxime in! Suscipit voluptatum minima ipsa quisquam!

Maiores veniam excepturi quo distinctio. Reprehenderit natus neque provident officia omnis ipsum aut quod mollitia cupiditate eos distinctio harum magni doloribus accusantium blanditiis soluta veritatis, eveniet, libero expedita tenetur eum.

詳細を見る 2

詳細の内容 2

Deleniti, quisquam iusto vero dolor ut saepe minima debitis. Explicabo assumenda odit iure fugiat hic iste saepe magnam cumque dignissimos illum totam consectetur ipsam modi culpa, minima aliquid excepturi quas!

Illum, enim necessitatibus facere inventore, dolor eaque temporibus, adipisci ipsa iste ullam sapiente? Dolorum facilis reprehenderit atque placeat omnis sapiente. Molestiae quasi adipisci maxime in! Suscipit voluptatum minima ipsa quisquam!

Maiores veniam excepturi quo distinctio. Reprehenderit natus neque provident officia omnis ipsum aut quod mollitia cupiditate eos distinctio harum magni doloribus accusantium blanditiis soluta veritatis, eveniet, libero expedita tenetur eum.

詳細を見る 3

詳細の内容 3

Porro ipsam at laudantium distinctio officiis ratione quas asperiores adipisci, fuga incidunt optio obcaecati ea tempora ab, nihil, architecto hic placeat magni tempore. Officiis, dolorum earum dolore dignissimos doloremque ex.

詳細を見る 4

詳細の内容 4

Illum, enim necessitatibus facere inventore, dolor eaque temporibus, adipisci ipsa iste ullam sapiente? Dolorum facilis reprehenderit atque placeat omnis sapiente. Molestiae quasi adipisci maxime in! Suscipit voluptatum minima ipsa quisquam!

Maiores veniam excepturi quo distinctio. Reprehenderit natus neque provident officia omnis ipsum aut quod mollitia cupiditate eos distinctio harum magni doloribus accusantium blanditiis soluta veritatis, eveniet, libero expedita tenetur eum.

Nam impedit eveniet nobis, natus voluptate quia animi maxime provident dolorum culpa quis quibusdam esse, laudantium reprehenderit tempore dolore et delectus assumenda repellat laborum sint atque. In doloribus rem placeat.

詳細を見る 5

詳細の内容 5

Nam impedit eveniet nobis, natus voluptate quia animi maxime provident dolorum culpa quis quibusdam esse, laudantium reprehenderit tempore dolore et delectus assumenda repellat laborum sint atque. In doloribus rem placeat.

Vitae dolores porro dignissimos soluta qui, laborum laboriosam itaque voluptatem at temporibus ea totam eos nostrum minus labore a tempora similique harum expedita! Veniam autem omnis quo, consequatur fugit vel!

この例では、アニメーション中かどうかの判定は isAnimating という変数の代わりに、dataset プロパティを使って summary 要素に data-is-animating というカスタム属性を設定し、このカスタム属性の値によって判定するようにしています。

そして、カスタム属性 data-is-animating の値に true という文字列が設定されている場合は何もしないように return します(68〜70行目)。

dataset プロパティで summary 要素のカスタム属性 data-is-animating にアクセスするには summary.dataset.isAnimating のように属性名の data- を除いた部分をキャメルケースにします。

アニメーションを開始したら summary.dataset.isAnimating = 'true' で要素の data-is-animating カスタム属性の値を true にして、終了したら値を true 以外に変更します。

この例ではカスタム属性の値を true や false としていますが、カスタム属性の値は文字列として扱われるため、この場合の true や false は単なる文字列で、真偽値ではありません。

//  DOM ツリーの構築が完了したら定義した関数を呼び出す
document.addEventListener('DOMContentLoaded', () => {
  setupToggleDetailsAnimation6();
});

// アニメーションの処理を定義した関数
function setupToggleDetailsAnimation6() {
  const details = document.querySelectorAll('.details-wrapper details');
  function close(el) {
    const content = el.querySelector('.details-content');
    const summary = el.querySelector('summary');
    // 連続するクリックの制御。アニメーション中
    summary.dataset.isAnimating = 'true';
    const closeDetails = content.animate(
      {
        opacity: [1, 0],
        height: [content.offsetHeight + 'px', 0],
      },
      {
        duration: 300,
        easing: 'ease-in',
      }
    );
    const rotate1 = summary.animate(
      {
        rotate: ["180deg", "0deg"],
      },
      {
        duration: 300,
        pseudoElement: "::before",
        easing: 'ease-in',
        fill: 'forwards',
      }
    );
    const rotate2 = summary.animate(
      {
        rotate: ["90deg", "0deg"],
        opacity: [0, 1]
      },
      {
        duration: 300,
        pseudoElement: "::after",
        easing: 'ease-in',
        fill: 'forwards',
      }
    );
    closeDetails.onfinish = () => {
      el.removeAttribute('open');
      // 連続するクリックの制御。アニメーション終了(isAnimating の値を true 以外に変更)
      summary.dataset.isAnimating = 'false';
    }
  }

  function closeOpenedDetails(detailsElems) {
    detailsElems.forEach(elem => {
      if (elem.open) {
        close(elem);
      }
    });
  }

  details.forEach(elem => {
    const summary = elem.querySelector('summary');
    const content = elem.querySelector('.details-content');
    summary.addEventListener('click', (e) => {
      e.preventDefault();
      // 連続するクリックの制御。アニメーション中の場合はリターン(何もしない)
      if (summary.dataset.isAnimating === 'true') {
        return;
      }
      closeOpenedDetails(details);
      if (elem.open) {
        close(elem);
      } else {
        elem.setAttribute('open', 'true');
        // 連続するクリックの制御。アニメーション中
        summary.dataset.isAnimating = 'true';
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotate1 = e.currentTarget.animate(
          {
            rotate: ["0deg", "180deg"],
          },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        const rotate2 = e.currentTarget.animate(
          {
            rotate: ["0deg", "90deg"],
            opacity: [1, 0]
          },
          {
            duration: 300,
            pseudoElement: "::after",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        openDetails.onfinish = () => {
          // 連続するクリックの制御。アニメーション終了(isAnimating の値を true 以外に変更)
          summary.dataset.isAnimating = 'false';
        }
      }
    });
  });
};

以下は上記の JavaScript を前述の例と同様、アニメーション中かどうかを isAnimating という変数を使って判定するように書き換えたものです。

別途定義した関数 close() と closeOpenedDetails() では、details 要素を閉じるアニメーションのオブジェクトを返すようにして、呼び出し側で戻り値のオブジェクトの finished プロパティを使って、アニメーションが終了したら open 属性を削除したり、isAnimating の値を更新しています。

この例の場合は finished プロパティの代わりに前述の例同様 onfinish イベントハンドラを使っても同じです。

//  DOM ツリーの構築が完了したら定義した関数を呼び出す
document.addEventListener('DOMContentLoaded', () => {
  setupToggleDetailsAnimation7();
});

// アニメーションの処理を定義した関数
function setupToggleDetailsAnimation7() {
  const details = document.querySelectorAll('.details-wrapper details');

  // details 要素を閉じる関数
  function close(el) {
    const content = el.querySelector('.details-content');
    const summary = el.querySelector('summary');
    const closeDetails = content.animate(
      {
        opacity: [1, 0],
        height: [content.offsetHeight + 'px', 0],
      },
      {
        duration: 300,
        easing: 'ease-in',
      }
    );
    const rotate1 = summary.animate(
      {
        rotate: ["180deg", "0deg"],
      },
      {
        duration: 300,
        pseudoElement: "::before",
        easing: 'ease-in',
        fill: 'forwards',
      }
    );
    const rotate2 = summary.animate(
      {
        rotate: ["90deg", "0deg"],
        opacity: [0, 1]
      },
      {
        duration: 300,
        pseudoElement: "::after",
        easing: 'ease-in',
        fill: 'forwards',
      }
    );
    //アニメーションオブジェクトを返す
    return closeDetails;
  }

  //現在開いている details 要素を閉じる関数
  function closeOpenedDetails(detailsElems) {
    detailsElems.forEach(elem => {
      if (elem.open) {
        // close() を実行して戻り値を変数に代入
        const closeDetails = close(elem);
        // 戻り値のアニメーションが終了したら open 属性を削除
        closeDetails.finished.then( () => {
          // open 属性を削除
          elem.removeAttribute('open');
        });
      }
    });
  }

  details.forEach(elem => {
    const summary = elem.querySelector('summary');
    const content = elem.querySelector('.details-content');
    //アニメーション中かどうかを表すフラグ
    let isAnimating = false;
    summary.addEventListener('click', (e) => {
      e.preventDefault();
      // アニメーション中の場合はリターン(何もしない)
      if (isAnimating === true) {
        return;
      }
      closeOpenedDetails(details);
      if (elem.open) {
        // アニメーション中
        isAnimating = true;
        // close() を実行して戻り値を変数に代入
        const closeDetails = close(elem);
        // 戻り値のアニメーションが終了したら
        closeDetails.finished.then( () => {
          // open 属性を削除
          elem.removeAttribute('open');
          // アニメーション終了
          isAnimating = false;
        });
      } else {
        elem.setAttribute('open', 'true');
        // アニメーション中
        isAnimating = true;
        const openDetails = content.animate(
          {
            opacity: [0, 1],
            height: [0, content.offsetHeight + 'px'],
          },
          {
            duration: 300,
            easing: 'ease-in',
          }
        );
        const rotate1 = e.currentTarget.animate(
          {
            rotate: ["0deg", "180deg"],
          },
          {
            duration: 300,
            pseudoElement: "::before",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        const rotate2 = e.currentTarget.animate(
          {
            rotate: ["0deg", "90deg"],
            opacity: [1, 0]
          },
          {
            duration: 300,
            pseudoElement: "::after",
            easing: 'ease-in',
            fill: 'forwards',
          }
        );
        openDetails.finished.then( () => {
          // アニメーション終了
          isAnimating = false;
        });
      }
    });
  });
};
前述の例の HTML と同じ
<div class="details-wrapper">
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Lorem ipsum dolor sit amet consectetur adipisicing elit..</p>
      </div>
    </div>
  </details>
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Tempore esse, tenetur dolore perferendis eum sequi ..</p>
      </div>
    </div>
  </details>
  <details>
    <summary>詳細を見る</summary>
    <div class="details-content-wrapper">
      <div class="details-content">
        <p>詳細の内容</p>
        <p>Vero vitae quibusdam impedit dolore! Ut voluptatibus, ...</p>
      </div>
    </div>
  </details>
</div>
前述の例の CSS と同じ
.details-wrapper details {
  border: 1px solid #aaa;
  max-width: 500px;
  border-bottom: none;
}

.details-wrapper details:last-child {
  border-bottom:  1px solid #aaa;
}

details .details-content-wrapper {
  padding: 1rem 1rem 1rem 2rem;
}

details .details-content {
  overflow: hidden;
}

details summary {
  display: block;
  cursor: pointer;
  position: relative;
  padding: 0.5rem;
  transition: background-color .3s;
}

details summary:hover {
  background-color: #eaf0f9;
}

details summary::-webkit-details-marker {
  display: none;
}

details[open] summary {
  border-bottom: 1px solid #aaa;
}

details summary::before,
details summary::after {
  content: "";
  position: absolute;
  right: 1rem;
  top: 0;
  bottom: 0;
  margin: auto 0;
  background-color: #333;
  width: 16px;
  height: 3px;
}

details summary::after {
  transform: rotate(90deg);
}