Reactによるアコーディオンメニューの実装方法

JavaScript

アコーディオンとはクリックをトリガとした開閉式のUIコンテンツのことを指します。
今回はReactで実装したアコーディオンメニューのサンプルコードについて紹介します。

今回紹介するアコーディオンのサンプルコード

App.tsx

import { FC } from "react";
import { Menu } from "./Menu";

const App: FC = () => {
  return (
    <Menu title="親メニュー" withIcon={true}>
      <Menu title="子メニュー1" />
      <Menu title="子メニュー2" />
    </Menu>
  );
};

export default App;

Menu.tsx

import { FC, ReactNode, useEffect, useRef, useState } from "react";
import { ReactSVG } from 'react-svg'
import styles from "./Menu.module.css";

type Props = {
  title: string;
  withIcon?: boolean;
  children?: ReactNode;
};

export const Menu: FC<Props> = ({ title, withIcon, children }) => {
  const childElement = useRef<HTMLDivElement>(null);
  const [showChildren, setshowChildren] = useState(false);
  const [childHeight, setChildHeight] = useState(0);
  const [reverseIcon, setReverseIcon] = useState(false);

  useEffect(() => {
    if (childElement.current) {
      const height = childElement.current?.clientHeight;
      setChildHeight(height);
    }
  }, []);

  const handleClick = () => {
    if (childElement.current) {
      setshowChildren(!showChildren);
    }
    setReverseIcon(!reverseIcon);
  };

  return (
    <>
      <div onClick={handleClick} className={styles.item}>
        {title}
        {withIcon && (
          <div className={`${styles.icon} ${reverseIcon && styles.reverse}`}>
            <ReactSVG
              style={{ height: 24, width: 24 }}
              src="images/expand_more.svg"
            />
          </div>
        )}
      </div>
      <div
        className={styles.childItem}
        style={{
          height: children && showChildren ? `${childHeight}px` : "0px",
          opacity: children && showChildren ? 1 : 0,
        }}
      >
        <div ref={childElement}>{children}</div>
      </div>
    </>
  );
};

Menu.module.css

.item {
  width: 200px;
  height: 50px;
  border: 1px solid;
  padding: 10px 20px;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.childItem {
  transition: height 0.2s linear, opacity 0.2s ease-in;

  overflow: hidden; /* heightをはみ出す子要素を表示させないようにすることで、上から徐々に子要素が表示されるアニメーションを実現 */
}

.icon {
  position: absolute;
  right: 15px;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  transition: transform 0.3s;
}

.reverse {
  transform: rotate(-180deg) translateY(50%);
}

public/images/expand_more.svg

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="m24 30.75-12-12 2.15-2.15L24 26.5l9.85-9.85L36 18.8Z"/></svg>

上記の実装で利用されているテクニックは以下の通りです。

  • useRefによるDOMの高さの取得
  • useStateによるアコーディオンの開閉制御
  • transitionによるアニメーション
  • 条件付きレンダーによる矢印アイコンの有無の制御

以下では上記のテクニックについてそれぞれ解説します。

useRefによるDOMの高さの取得

今回の実装ではchildrenheightがアコーディオンによって展開されるコンテンツの高さとなります。
つまり、childrenheightを制御することでアコーディオンの開閉を実現しています。

DOMの参照はuseRefを利用することで取得できます。以下はuseRefを利用してDOMの高さを取得する例です。

App.tsx

import { FC } from "react";
import { Menu } from "./Menu";

const App: FC = () => {
  return (
    <Menu>
      <Menu />
    </Menu>
  );
};

export default App;

Menu.tsx

import { FC, ReactNode, useRef } from "react";
import styles from "./Menu.module.css";

type Props = {
  children?: ReactNode;
};

export const Menu: FC<Props> = ({ children }) => {
  const childElement = useRef<HTMLDivElement>(null);

  const handleClick = () => {
    if (childElement.current) {
      const childheight = childElement.current?.clientHeight;
      console.log("childheight:", childheight);
    }
  };

  return (
    <>
      <div onClick={handleClick} className={styles.item}>
        Menu
      </div>
      <div ref={childElement}>{children}</div>
    </>
  );
};

なお、useRefの詳細解説は【React】useRef/forwardRefの概要と使いどころで紹介しています。

useStateによるアコーディオンの開閉制御

アコーディオンの開閉制御はローカルStateで管理しています。
クリックをトリガにローカルStateをトグルさせることでアコーディオンの開閉を実現しています。

以下はローカルStateでDOMの表示・非表示を制御する例です。

App.tsx

import { FC, useRef, useState } from "react";
import styles from "./App.module.css";

const App: FC = () => {
  const element = useRef<HTMLDivElement>(null);
  const [showItem, setShowItem] = useState(false);

  const handleClick = () => {
    if (element.current) {
      setShowItem(!showItem);
    }
  };

  return (
    <>
      <div onClick={handleClick} className={styles.item}>
        Menu1
      </div>
      <div
        style={{
          height: showItem ? "auto" : "0px",
          opacity: showItem ? 1 : 0,
        }}
      >
        <div className={styles.item} ref={element}>
          Menu2
        </div>
      </div>
    </>
  );
};

export default App;

transitionによるアニメーション

アコーディオンの開閉のアニメーションはtransitionで実現しています。
以下はローカルStateの変更によって適用されるCSSの変化を、transitionによってアニメーション化する例です。

App.tsx

import { FC, useRef, useState } from "react";
import styles from "./App.module.css";

const App: FC = () => {
  const element = useRef<HTMLDivElement>(null);
  const [showItem, setShowItem] = useState(false);

  const handleClick = () => {
    if (element.current) {
      setShowItem(!showItem);
    }
  };

  return (
    <>
      <div onClick={handleClick} className={styles.item}>
        Menu1
      </div>
      <div
        className={styles.animation}
        style={{
          height: showItem ? "50px" : "0px",
          opacity: showItem ? 1 : 0,
        }}
      >
        <div className={styles.item} ref={element}>
          Menu2
        </div>
      </div>
    </>
  );
};

export default App;

App.module.css

.item {
  width: 200px;
  height: 50px;
  border: 1px solid;
  padding: 10px 20px;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.animation {
  transition: height 0.2s linear, opacity 0.2s ease-in;

  overflow: hidden; /* heightをはみ出す子要素を表示させないようにすることで、上から徐々に子要素が表示されるアニメーションを実現 */
}

なお、transitionの詳細解説は【CSS】transitionでアニメーションを実現する方法で紹介しています。

条件付きレンダーによる矢印アイコンの有無の制御

アコーディオンのクリック領域は矢印アイコンの有無で識別しています。アイコンの表示制御はPropsから渡された制御フラグによる条件付きレンダーで実現しています。
また、アイコンの向きが変わるアニメーションはtransitionとtransformを組み合わせて実現しています。
以下は条件付きレンダーによってアイコンの表示制御を行う例です。

App.tsx

import { FC } from "react";
import { Menu } from "./Menu";

const App: FC = () => {
  return (
    <>
      <Menu title="矢印メニュー" withIcon={true} />
      <Menu title="メニュー" />
    </>
  );
};

export default App;

Menu.tsx

import { FC, useState } from "react";
import styles from "./Menu.module.css";
import ExpandMoreIcon from "public/images/expand_more.svg";

type Props = {
  title: string;
  withIcon?: boolean;
};

export const Menu: FC<Props> = ({ title, withIcon }) => {
  const [reverseIcon, setReverseIcon] = useState(false);

  const handleClick = () => {
    setReverseIcon(!reverseIcon);
  };

  return (
    <div onClick={handleClick} className={styles.item}>
      {title}
      {withIcon && (
        <div className={`${styles.icon} ${reverseIcon && styles.reverse}`}>
          <ExpandMoreIcon width={24} height={24} />
        </div>
      )}
    </div>
  );
};

Menu.module.css

.item {
  width: 200px;
  height: 50px;
  border: 1px solid;
  padding: 10px 20px;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.icon {
  position: absolute;
  right: 15px;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  transition: transform 0.3s; /* transformに動きをつける */
}

/* ローカルState(reverseIcon)がtrueの時、以下のCSSが付与される(= アイコンが回転する) */
.reverse {
  transform: rotate(-180deg) translateY(50%);
}

なお、条件付きレンダーの詳細解説は【React】JSXでif文を利用して要素を出し分ける(条件付きレンダー)方法で紹介しています。

また、今回SVGアイコンの表示にはreact-svgを利用しています。React環境でSVGを表示する方法についてはReact環境で手軽にSVGを表示する方法で紹介しています。

再掲: アコーディオンのサンプルコード

冒頭で紹介したサンプルコードを再掲します。

App.tsx

import { FC } from "react";
import { Menu } from "./Menu";

const App: FC = () => {
  return (
    <Menu title="親メニュー" withIcon={true}>
      <Menu title="子メニュー1" />
      <Menu title="子メニュー2" />
    </Menu>
  );
};

export default App;

Menu.tsx

import { FC, ReactNode, useEffect, useRef, useState } from "react";
import styles from "./Menu.module.css";
import ExpandMoreIcon from "public/images/expand_more.svg";

type Props = {
  title: string;
  withIcon?: boolean;
  children?: ReactNode;
};

export const Menu: FC<Props> = ({ title, withIcon, children }) => {
  const childElement = useRef<HTMLDivElement>(null);
  const [showChildren, setshowChildren] = useState(false);
  const [childHeight, setChildHeight] = useState(0);
  const [reverseIcon, setReverseIcon] = useState(false);

  useEffect(() => {
    if (childElement.current) {
      const height = childElement.current?.clientHeight;
      setChildHeight(height);
    }
  }, []);

  const handleClick = () => {
    if (childElement.current) {
      setshowChildren(!showChildren);
    }
    setReverseIcon(!reverseIcon);
  };

  return (
    <>
      <div onClick={handleClick} className={styles.item}>
        {title}
        {withIcon && (
          <div className={`${styles.icon} ${reverseIcon && styles.reverse}`}>
            <ExpandMoreIcon width={24} height={24} />
          </div>
        )}
      </div>
      <div
        className={styles.childItem}
        style={{
          height: children && showChildren ? `${childHeight}px` : "0px",
          opacity: children && showChildren ? 1 : 0,
        }}
      >
        <div ref={childElement}>{children}</div>
      </div>
    </>
  );
};

Menu.module.css

.item {
  width: 200px;
  height: 50px;
  border: 1px solid;
  padding: 10px 20px;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}

.childItem {
  transition: height 0.2s linear, opacity 0.2s ease-in;

  overflow: hidden; /* heightをはみ出す子要素を表示させないようにすることで、上から徐々に子要素が表示されるアニメーションを実現 */
}

.icon {
  position: absolute;
  right: 15px;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  transition: transform 0.3s;
}

.reverse {
  transform: rotate(-180deg) translateY(50%);
}

public/images/expand_more.svg

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="m24 30.75-12-12 2.15-2.15L24 26.5l9.85-9.85L36 18.8Z"/></svg>

参考: react-springを利用した実装例

アコーディオンの開閉アニメーションをreact-springで実装した例について紹介します。

Menu.tsx

+ import { useSpring, animated } from 'react-spring';

(略)

+ const springStyle = useSpring({
+   height: children && showChildren ? `${childHeight}px` : "0px",
+   opacity: children && showChildren ? 1 : 0,
+ });

  return (
    <>
      <div onClick={handleClick} className={styles.item}>
        Menu
        {withIcon && (
          <div className={`${styles.icon} ${reverseIcon && styles.reverse}`}>
            <ReactSVG
              style={{ height: 24, width: 24 }}
              src="images/expand_more.svg"
            />
          </div>
        )}
      </div>

-     <div
-       className={styles.childItem}
-       style={{
-         height: children && showChildren ? `${childHeight}px` : "0px",
-         opacity: children && showChildren ? 1 : 0,
-         config: { tension: 0 },
-       }};
-     >
-       <div ref={childElement}>{children}</div>
-     </div>

+     <animated.div
+       className={styles.childItem}
+       style={springStyle}
+     >
+       <div ref={childElement}>{children}</div>
+     </animated.div>
    </>
  );
};

さいごに

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!