アコーディオンとはクリックをトリガとした開閉式の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の高さの取得
今回の実装ではchildren
のheight
がアコーディオンによって展開されるコンテンツの高さとなります。
つまり、children
のheight
を制御することでアコーディオンの開閉を実現しています。
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)やってます。フォローしてもらえるとうれしいです!