detailsを使ったアコーディオン

JSでアニメーションアニメーションを付与


概要

- JavaScript `details` 要素を使ったアコーディオンの開閉動作にアニメーションを付与する形式 - `Web Animation API` を使用してJSから `height` `opacity` を操作して、アニメーションを実装 - `summary` 要素のデフォルトのクリック動作を無効化 - `summary` クリック時の挙動は `display: none;` のような挙動に近く、 `transition` などでアニメーションを設定しても、即開閉となってしまう - `open` 属性の着脱は、JavaScriptで制御

デモ

メニュー1
内容1
内容1
内容1
内容1
内容1
メニュー2
data-duration="100" data-easing="linear" の指定で、アニメーションのオプション指定あり
内容2
内容2
内容2
内容2
内容2

コード

HTML

<details class="accordion">
  <summary>メニュー1</summary>
  <div class="accordion_content">
    <div class="inner_accordion_content">
      内容1<br />内容1<br />内容1<br />内容1<br />内容1
    </div>
  </div>
</details>
<details class="accordion" data-duration="100" data-easing="linear">
  <summary>メニュー2</summary>
  <div class="accordion_content">
    <div class="inner_accordion_content">
      data-duration="100" data-easing="linear" の指定で、アニメーションのオプション指定あり<br />
      内容2<br />内容2<br />内容2<br />内容2<br />内容2
    </div>
  </div>
</details>

CSS

.accordion_content {
  overflow: hidden;
  /* paddingやmarginはここでは設定しない */	
}
.inner_accordion_content {
  /* 内側でmarginやpaddingを指定 */
  padding: 0 20px 20px;
}

JavaScript


  /** アニメーションの時間とイージング */
  const DEFAULT_ANIMATION_TIMING = {
    duration: 300,
    easing: 'ease-in-out',
  };
  
  /**
   * アコーディオンを閉じるときのキーフレームを返す
   * @param {HTMLElement} contentElement 
   * @returns 
   */
  const closingAnimation = (contentElement) => [
    {
      height: contentElement.offsetHeight + 'px',
      opacity: 1,
    },
    {
      height: 0,
      opacity: 0,
    },
  ];
  
  // アコーディオンを開くときのキーフレーム
  /**
   * アコーディオンを開くときのキーフレームを返す
   * @param {HTMLElement} contentElement 
   * @returns 
   */
  const openingAnimation = (contentElement) => [
    {
      height: 0,
      opacity: 0,
    },
    {
      height: contentElement.offsetHeight + 'px',
      opacity: 1,
    },
  ];
  
  /**
   * summary要素をクリックした際に実行するイベントハンドラー
   * @param {MouseEvent} event 
   */
  function handleClickSummary(event) {
    const summaryElement = event.currentTarget;
    if (summaryElement instanceof HTMLElement === false) {
      throw new Error('Error :: summary element not found');
    }
    const detailsElement = summaryElement.closest('details');
    if (detailsElement instanceof HTMLDetailsElement === false) {
      throw new Error('Error :: details element not found');
    }
    const contentElement = detailsElement.querySelector('.accordion_content');
    if (contentElement instanceof HTMLElement === false) {
      throw new Error('Error :: content element not found');
    }
    // summary要素click時のデフォルトの挙動を無効化
    event.preventDefault();
  
    // アニメーションのオプション指定
    const animationTiming = {
      duration: detailsElement.dataset.duration
        ? Number(detailsElement.dataset.duration)
        : DEFAULT_ANIMATION_TIMING.duration,
      easing: detailsElement.dataset.easing || DEFAULT_ANIMATION_TIMING.easing,
    };
  
  
    if (detailsElement.getAttribute('open') === null) {
      // 閉じている場合で、open属性を付与して「開く」
      // open属性を付与
      detailsElement.setAttribute('open', 'true');
      // アコーディオンを開くときの処理
      contentElement.animate(openingAnimation(contentElement), animationTiming);
      return;
    }
    // 開いている場合、open属性を取り除いて「閉じる」
    const closingAnim = contentElement.animate(closingAnimation(contentElement), animationTiming);
    // アニメーション完了後の処理
    closingAnim.onfinish = () => {
      // アニメーションの完了後にopen属性を取り除く
      detailsElement.removeAttribute('open');
    };
  }
  
  /**
   * details要素をアコーディオンの初期化処理
   * @param {string} wrapSelector 対象details要素のセレクター
   */
  function setupDetailsAccordion(wrapSelector) {
    document.querySelectorAll(wrapSelector).forEach(function (detailsElement) {
      detailsElement.querySelector('summary')
        ?.addEventListener('click', handleClickSummary);
    });
  }
  
  // 画面読み込み完了後に初期化処理を実行
  document.addEventListener('DOMContentLoaded', () => {
    setupDetailsAccordion('details.accordion');
  });