🌀

React(Next.js)で滑らかな開閉アニメーションとアクセシビリティを考慮したアコーディオンを実装する

おとの2023/01/07に公開2件のコメント

はじめに

今回は、React(Next.js)を使って、滑らかな開閉アニメーションとアクセシビリティを考慮したアコーディオンの実装方法を紹介します。

本実装を進めるにあたって、実現したいポイントは5点あります。

  • 高さが決まっていない(高さの最大値が決まっていない)コンテンツを表示させたい
  • ウィンドウ幅によって高さが変化する可能性のあるコンテンツを表示させたい
  • アコーディオンが開く時も閉じる時も同じ速度感のアニメーションを付与したい(jQueryのslideToggleのような動き)
  • スクリーンリーダーによる開閉状態読み上げ、Tabキーによるフォーカス、EnterキーやSpaceキーでの開閉操作可能な(アクセシビリティが考慮された)アコーディオンを実現したい
  • Webページ内検索(command + F)でアコーディオン内の単語がヒットした場合、アコーディオンが自動で開かれるようにしたい(Chrome/Edge)

上記すべて実現するためには、CSSだけでなく、JavaScriptによる実装も必要になります。次の章から順を追って実装を進めていきます。

アコーディオンの実装方法

本記事では、Next.js(React)を用いて実装を進めていきます。

※今回はNext.jsで扱えるCSSライブラリstyled-jsxを活用して動的なスタイルを実装します。css modulesstyled-componentsなど、他のCSSライブラリを利用している方は適宜書き換えて実装を進めてください。

アコーディオンのUIと機能を実装

アコーディオンを追加したいコンポーネント内に以下の記述を追加してください。

index.tsx
import React, { useState } from "react";

{/* 中略 */}

const [isOpen, setIsOpen] = useState<boolean>(false)

return (
  <>
    <div role="group">
      <button
        type="button"
        aria-controls="contents"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
      >
        {isOpen ? "アコーディオンを閉じる" : "アコーディオンを開く"}
      </button>
      <div
        id="contents"
        className="accordion-body"
        aria-hidden={!isOpen}
      >
        ここに表示させたいコンテンツを挿入します。
      </div>
    </div>
    <style jsx>{`
      .accordion-body {
        height: ${isOpen ? "auto" : 0};
        transition: height 0.3s ease-out;
        overflow: hidden;
      }
    `}</style>
  </>
)

開閉のトリガーとなるボタンをbutton要素を使って実装することが今回のポイントです。

button要素を利用することで、Tabキーでのフォーカス、EnterキーやSpaceキーでの開閉操作がデフォルトで実現可能になります。また、aria属性を付与することでスクリーンリーダーによる開閉状態の読み上げも実現しています。

開閉動作の仕様は、button要素のクリック時にコンテンツの表示・非表示を切り替えられるようにsetIsOpenが実行されるように実装しています。

ただしこの時点では、jQueryのslideToggleメソッドのような滑らかなアニメーションは実現できていません。heightを固定値ではなくautoに設定する場合、transitionの設定が効かないためです。

<details><summary>要素に関して

ちなみに、button要素ではなく、<details><summary>要素を使う場合、CSSやJavaScriptの実装がなくともアコーディオンの開閉操作を容易に実現できます。

その上、キーボード操作やスクリーンリーダーによる読み上げ、ページ内検索にもデフォルトで対応するなど、アクセシビリティにも考慮したアコーディオンを容易に実装することができます(アニメーションは別途実装必要)

ただし、Safariで発生するバグを考慮した実装が必要になることと、Chromeでアコーディオンが開かれる時に黒い箱のような表示が時折チラついてしまうことを理由に、今回は<details><summary>の利用を見送っています(本記事執筆時の2022年12月31日時点での挙動のため、今後は解決されるかもしれません)

アコーディオン開閉時にアニメーションを付与する

今回は、表示させたいコンテンツの高さが固定値で設定できるかそうでないかで実装パターンを分けて紹介します。前者はググったらよく上位に表示される内容です。本記事で挙げた目的を実現するアコーディオンの実装とは異なるため、読み飛ばして構いません。

表示させるコンテンツの高さが固定値の場合

アコーディオンで表示されるコンテンツの高さが決まっている場合、heightに固定値を設定することで開閉アニメーションを容易に実装できます。

index.tsx
    {/* 上記省略 */}

    <style jsx>{`
      .accordion-body {
-       height: ${isOpen ? "auto" : 0};
+       height: ${isOpen ? "100px" : 0}; /* コンテンツの高さが100pxの場合 */
        transition: height 0.3s ease-out;
        overflow: hidden;
      }
    `}</style>

滑らかな開閉アニメーションを実現することができました🌟

しかし、コンテンツの高さを固定値で設定できるケースは多くありません。ウィンドウ幅によって高さが変動するものの、ある程度までの高さが想定できる場合はheightの代わりにmax-heightを利用することで開閉アニメーションを実現できます。

index.tsx
    {/* 上記省略 */}

    <style jsx>{`
      .accordion-body {
-       height: ${isOpen ? "100px" : 0}; /* コンテンツの高さが100pxの場合 */
+       max-height: ${isOpen ? "1000px" : 0}; /* ウィンドウ幅が変わっても高さが1000pxを超えないことを想定 */
-       transition: height 0.3s ease-out;
+	transition: max-height 0.3s ease-out;
        overflow: hidden;
      }
    `}</style>

ただ、上記を見るとわかるように、heightに固定値を設定して実現した最初のアニメーションとは異なった動き(速度感)で開閉動作が行われています。開く動作が速すぎたり、閉じる動作はワンテンポ遅れてしまったりと、不自然な動きが確認できます。実際に表示されているコンテンツの高さより大きいmax-heightが指定されていることが原因です。

コンテンツの高さを固定値で設定できないものの、滑らかなアニメーションを表示させたい場合は以下の方法を採用してください。

表示させるコンテンツの高さが動的な場合

最初に追加した記述に対して以下の修正を行います。新しく作成するuseAccordionからアニメーションと開閉機能を参照するように変更します。

index.tsx
- import React, { useState } from "react";
+ import { useAccordion } from "../hooks/useAccordion";

{/* 中略 */}

- const [isOpen, setIsOpen] = useState<boolean>(false)
+ const { isOpen, setIsOpen, accordionRef } = useAccordion();

return (
  <>
    <div role="group">
      <button
        type="button"
        aria-controls="contents"
        aria-expanded={!isOpen}
        onClick={() => setIsOpen(!isOpen)}
      >
        {isOpen ? "閉じる" : "開く"}
      </button>
      <div
        id="contents"
        className="accordion-body"
        aria-hidden={!isOpen}
      >
-       ここに表示させたいコンテンツを挿入します
+       {/* refを設定した要素の直下の子要素の高さをもとにアニメーションを実現する */}
+       <div>ここに表示させたいコンテンツを挿入します</div>
      </div>
    </div>
    <style jsx>{`
      .accordion-body {
-       height: ${isOpen ? "auto" : 0};
+       height: 0;
-       transition: height 0.3s ease-out;
        overflow: hidden;
      }
    `}</style>
  </>
)

続けて、useAccordion.tsを作成します。下記記述を新しく追加してください。

hooks/useAccordion.ts
import { useEffect, useRef, useState } from "react";

const openingKeyframes = (elementHeight: number): Keyframe[] => {
  return [
    {
      height: "0px",
      offset: 0,
    },
    {
      height: `${elementHeight}px`,
      offset: 0.999,
    },
    {
      // 最終的にautoに変更することで、ウィンドウ幅が変更されても高さを可変にする
      height: "auto",
      offset: 1,
    },
  ];
};

const closingKeyframes = (elementHeight: number): Keyframe[] => {
  return [
    {
      height: "auto",
      offset: 0,
    },
    {
      height: `${elementHeight}px`,
      offset: 0.001,
    },
    {
      height: "0px",
      offset: 1,
    },
  ];
};

const option: KeyframeAnimationOptions = {
  duration: 200,
  easing: "ease-out",
  fill: "forwards",
};

export const useAccordion = () => {
  const accordionRef = useRef<HTMLDivElement>(null);
  const [isOpen, setIsOpen] = useState<boolean>(false);

  useEffect(() => {
    // refを設定したアコーディオンと直下の子要素の高さを取得する
    const element = accordionRef.current;
    if (element === null) return;
    if (element.firstElementChild === null) return;
    const elementHeight = element.clientHeight;
    const elementChildHeight = element.firstElementChild.clientHeight;

    // アコーディオンの子要素の高さをもとに、アコーディオンの開閉アニメーションを実行する
    if (isOpen) {
      element.animate(openingKeyframes(elementChildHeight), option);
    } else {
      // 初回レンダリング時にアニメーションが実行されないように、アコーディオンが開かれている場合のみアニメーションを実行する
      if (elementHeight > 0) {
        element.animate(closingKeyframes(elementChildHeight), option);
      }
    }

  }, [isOpen]);

  return {
    isOpen,
    setIsOpen,
    accordionRef,
  };
};

accordionRefを設定した要素の子要素となるdivを追加することで、アコーディオンが閉じているときもコンテンツの高さを取得できるようにしています。その高さをもとに、Web Animations APIを利用してアニメーションを実装しています。

https://developer.mozilla.org/ja/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API

ここまで実装できれば、高さが決まっておらずウィンドウ幅によって可変なコンテンツを含むアコーディオンでも、滑らかなアニメーションを実現することができました。

ページ内検索時にアコーディオン内の単語がヒットした場合に自動で開かれるようにする(Chrome/Edge)

最後に、Webページ内検索(command + F)でアコーディオン内の単語がヒットした場合にアコーディオンが自動で開かれるように実装を進めます。useAccordion.tsに以下の修正を行います。

hooks/useAccordion.ts

{/* 上記省略 */}

+ const agentIsChromium = (): boolean => {
+  const agent = window.navigator.userAgent.toLowerCase();
+  return agent.indexOf("chrome") !== -1 ? true : false;
+ };

export const useAccordion = () => {
  const accordionRef = useRef<HTMLDivElement>(null);
  const [isOpen, setIsOpen] = useState<boolean>(false);

  useEffect(() => {
    // refを設定したアコーディオンと直下の子要素の高さを取得する
    const element = accordionRef.current;
    if (element === null) return;
    if (element.firstElementChild === null) return;
    const elementHeight = element.clientHeight;
    const elementChildHeight = element.firstElementChild.clientHeight;

+   // 利用ブラウザがChromeかどうかチェックする
+   const isChromium = agentIsChromium();

    // アコーディオンの子要素の高さをもとに、アコーディオンの開閉アニメーションを実行する
    if (isOpen) {
+      // 2023年1月現在、Chromiumのみuntil-foundに対応しているため、isChromiumがtrueの場合のみ属性を追加する
+     if (isChromium) element.removeAttribute("hidden");
      element.animate(openingKeyframes(elementChildHeight), option);
    } else {
      // 初回レンダリング時にアニメーションが実行されないように、アコーディオンが開かれている場合のみアニメーションを実行する
      if (elementHeight > 0) {
-       element.animate(closingKeyframes(elementChildHeight), option);
+       element.animate(closingKeyframes(elementChildHeight), option).onfinish = () =>
+         isChromium && element.setAttribute("hidden", "until-found");
+     } else {
+       if (isChromium) element.setAttribute("hidden", "until-found");
+     }
    }
 
+   // ページ内検索がヒットした時に、アコーディオンを開く
+   element.addEventListener("beforematch", () => setIsOpen(true));
  }, [isOpen]);

  return {
    isOpen,
    setIsOpen,
    accordionRef,
  };
};

本実装のポイントは、アコーディオン要素にhidden="until-found"を追加するように実装したことです。これまで、HTML要素に付与できるhidden属性は真偽属性でしたが、until-foundを設定できるようにHTML Living Standardが更新されました。

hidden="until-found"を追加し、beforematchイベント発生時にアコーディオンを開く処理を実行することで、ページ内検索時にアコーディオンで隠れている内容がヒットした場合に自動で開かれるようになります。

試しに「開けゴマ」とページ内検索すると、上記アコーディオンが自動で開かれるはずです🌟

※ちなみに、昨年春頃にリリースされたChrome 102からhidden="until-found"が利用できるようになり、最新のChromeやChromiumを利用したEdge等のブラウザで利用可能です。Safariでは未対応のため、Chromiumのブラウザでのみ発火するように上記で実装しています。

https://developer.chrome.com/articles/hidden-until-found/

今回は以上になります。

ここまで読んでいただき、ありがとうございます🙆‍♂️

LCL Engineers

業界最大手高速バス料金比較サイト「バス比較なび」や、LCCなど飛行機との比較ができる「格安移動」を運営している、株式会社LCLのエンジニアの個人記事です。

Discussion

初めまして!

おとのさんと同じく、
Web制作会社のコーダーから自社開発のフロントエンドエンジニアに転職したいと思っているものです!

Reactの学習をしておりまして、
お話を伺うことは可能でしょうか?

ログインするとコメントできます