Next.jsでBacklog風のサイドメニューを作ってみよう!
作成するもの
https://side-menu-template.vercel.app/
Backlogのようなよくあるサイドメニュー
要件
- 開いているページは色を変える
- ハンバーガーメニューはhoverで矢印が出るアニメーションを入れる(Backlogのパクり)
サイドバー
- はスクロール不可、コンテンツ部分はスクロールできるようにする
- Next.js, TypeScriptを使用する
今回のソースコード
ソースだけ見たい方はこちら
準備
まずは使用するアイコンを準備します。
// styles/HomeIcon.tsx
/* --- ライブラリー --------------------------------------------------------------------------------------------------- */
import React, { memo, VFC } from "react";
type Props = {
className: string;
}
/* eslint-disable-next-line react/display-name */
export const HomeIcon: VFC<Props> = memo((props) => {
const { className } = props
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 511.9 512.1">
<path d="M498.2 222.7 289.3 13.8C280.4 4.9 268.6 0 256 0s-24.4 4.9-33.3 13.8L13.9 222.6l-.2.2c-18.3 18.4-18.2 48.2.1 66.6 8.4 8.4 19.4 13.2 31.3 13.7.5 0 1 .1 1.5.1h8.3v153.7c0 30.4 24.7 55.2 55.2 55.2h81.7c8.3 0 15-6.7 15-15V376.5c0-13.9 11.3-25.2 25.2-25.2h48.2c13.9 0 25.2 11.3 25.2 25.2V497c0 8.3 6.7 15 15 15H402c30.4 0 55.2-24.7 55.2-55.2V303.1h7.7c12.6 0 24.4-4.9 33.3-13.8 18.3-18.3 18.3-48.2 0-66.6z"/>
</svg>
);
});
上記を使用してください。
今回はアイコンを準備するのが面倒だったので、全て同じアイコンで進んでいきます。
次にLayout.tsxを作成しましょう!
とりあえず下記のような感じで作成しこの時components/Layout/Layout.module.scssも作成しましょう!
// src/components/Layout/Layout.tsx
import { VFC, memo, ReactNode, useState } from "react";
import styles from "./Layout.module.scss";
import { HomeIcon } from "../../styles/Home";
type Props = {
children: ReactNode;
}
type Navigation = {
pageName: string;
path: string;
icon: JSX.Element;
}
const navigations: Navigation[] = [
{
pageName: "トップ",
path: "/",
icon: <HomeIcon className={styles.icon}/>
},
{
pageName: "ページ2",
path: "/page2",
icon: <HomeIcon className={styles.icon}/>
},
{
pageName: "ページ3",
path: "/page3",
icon: <HomeIcon className={styles.icon}/>
},
{
pageName: "ページ4",
path: "/page4",
icon: <HomeIcon className={styles.icon}/>
},
]
/* eslint-disable-next-line react/display-name */
export const Layout: VFC<Props> = memo((props) => {
const { children } = props;
return (
null
)
})
簡単に説明していきます。
type Props = {
children: ReactNode;
}
今回Propsでchildrenを受け取っていきます。
画像のようにchildrenで受け取ったものをコンテンツ部分に表示させる想定です。
type Navigation = {
pageName: string;
path: string;
icon: JSX.Element;
}
次にナビゲーションは配列で定義します。
"ナビゲーションに表示するページ名", "遷移先のpath", "先ほど作成したicon"といった感じです。
これで準備は完了です。
メイン部分
import { VFC, memo, ReactNode, useState } from "react";
import Link from "next/link";
import styles from "./Layout.module.scss";
import { HomeIcon } from "../../styles/Home";
import { useRouter } from "next/router";
type Props = {
children: ReactNode;
}
type Navigation = {
pageName: string;
path: string;
icon: JSX.Element;
}
const navigations: Navigation[] = [
{
pageName: "トップ",
path: "/",
icon: <HomeIcon className={styles.icon}/>
},
{
pageName: "ページ2",
path: "/page2",
icon: <HomeIcon className={styles.icon}/>
},
{
pageName: "ページ3",
path: "/page3",
icon: <HomeIcon className={styles.icon}/>
},
{
pageName: "ページ4",
path: "/page4",
icon: <HomeIcon className={styles.icon}/>
},
]
/* eslint-disable-next-line react/display-name */
export const Layout: VFC<Props> = memo((props) => {
const { children } = props;
const [ menuOpen, setMenuOpen ] = useState(true);
const router = useRouter();
const isPageActive = (pagePath: string): boolean => {
return pagePath === String(router.route)
}
return (
<div className={styles.root}>
<aside className={styles.sidebar} style={{ width: menuOpen ? "200px" : "60px" }}>
<div className={styles.hamburger} role="button" onClick={() => setMenuOpen(!menuOpen)}>
{[...Array(3)].map((_, index: number) => (
<span className={menuOpen ? styles.menuCloseArrow : styles.menuOpenArrow} key={index}></span>
))}
</div>
{navigations.map((navigation) => (
<Link href={navigation.path} key={navigation.pageName}>
<a
className={styles.flexContainer}
style={{ background: isPageActive(navigation.path) ? "#1B555A" : "none" }}>
{ navigation.icon }
{ menuOpen && <p className={styles.pageName}>{ navigation.pageName }</p> }
</a>
</Link>
))}
</aside>
<main className={styles.mainContent}>
{children}
</main>
</div>
)
})
ざっと説明するとまず下記の部分でmenuが開いているかどうかのbooleanをstateとして保持します。
const [ menuOpen, setMenuOpen ] = useState(true);
const router = useRouter();
const isPageActive = (pagePath: string): boolean => {
return pagePath === String(router.route)
}
isPageActiveは画像のように色を変える必要があるのでそのbooleanです。
今回はあらかじめ配列で指定したpathと現在のpageのpathが揃っていればtrueという処理にしています。
router.routeで現在のpathが取得できます。
<aside className={styles.sidebar} style={{ width: menuOpen ? "200px" : "60px" }}>
ここの部分ではメニューが開いている時と閉じている時で横幅を指定しています。
<div className={styles.hamburger} role="button" onClick={() => setMenuOpen(!menuOpen)}>
ここではハンバーガーメニューの親クラスなのでクリックするたびに、menuOpenのstateを切り替えています。
role
をしっかり指定するとアクセシビリティ的にも良いです。
{[...Array(3)].map((_, index: number) => (
<span className={menuOpen ? styles.menuCloseArrow : styles.menuOpenArrow} key={index}></span>
))}
この部分はハンバーガーメニューを作るためにからのspanタグを3つ生成しています。
menuOpenがtrueがfalseによって与えるスタイルを分けていますが、これは閉じる時と開くときの矢印のアニメーションが逆になるからです!
<main className={styles.mainContent}>
{children}
</main>
あとはコンテンツ部分はchildren部分を表示するだけです。
cssを追加する
ではcssを追加していきましょう
.root {
$paddings: 15px;
@mixin borderStyle() {
display: block;
height: 1px;
width: 30px;
background: white;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
display: flex;
width: 100%;
.sidebar {
position: -webkit-sticky; /* Safari対応 */
position: sticky;
top: 0;
height: 100vh;
background-color: #4CAF93;
padding-left: $paddings;
padding-right: $paddings;
}
.hamburger {
cursor: pointer;
width: 40px;
margin-top: 15px;
/* メニューを閉じるアニメーション ==================================================================================== */
.menuCloseArrow {
@include borderStyle
}
&:hover > .menuCloseArrow {
transition: 0.2s;
&:nth-child(1) {
width: 15px;
height: 2px;
transform: translateY(7px) translateX(-1px) rotate(140deg);
}
&:nth-child(2) {
height: 2px;
}
&:nth-child(3) {
width: 15px;
height: 2px;
transform: translateY(-7px) translateX(-1px) rotate(-140deg);
margin-bottom: -3px; /* アニメーションの際の調整 */
}
}
/* メニューを開くアニメーション ====================================================================================== */
.menuOpenArrow {
@include borderStyle
}
&:hover > .menuOpenArrow {
transition: 0.2s;
&:nth-child(1) {
width: 15px;
height: 2px;
transform: translateY(7px) translateX(16px) rotate(-140deg);
}
&:nth-child(2) {
height: 2px;
}
&:nth-child(3) {
width: 15px;
height: 2px;
transform: translateY(-7px) translateX(16px) rotate(140deg);
margin-bottom: -3px; /* アニメーションの際の調整 */
}
}
}
.flexContainer {
display: flex;
align-items: center;
column-gap: 10px;
margin-top: 10px;
margin-right: -$paddings;
margin-left: -$paddings;
padding-right: $paddings;
padding-left: $paddings;
&:hover {
background-color: #1B555A;
}
}
.icon {
display: block;
flex-shrink: 0;
fill: white;
width: 25px;
margin-top: 12px; // 微調整
margin-bottom: 12px; // 微調整
}
.pageName {
font-size: 14px;
color: white;
}
.mainContent {
background: #F6F8FA;
width: 100%;
}
}
まず重要なのは下記の部分です
/* メニューを閉じるアニメーション ==================================================================================== */
.menuCloseArrow {
@include borderStyle
}
&:hover > .menuCloseArrow {
transition: 0.2s;
&:nth-child(1) {
width: 15px;
height: 2px;
transform: translateY(7px) translateX(-1px) rotate(140deg);
}
&:nth-child(2) {
height: 2px;
}
&:nth-child(3) {
width: 15px;
height: 2px;
transform: translateY(-7px) translateX(-1px) rotate(-140deg);
margin-bottom: -3px; /* アニメーションの際の調整 */
}
}
ハンバーガーメニューをhoverした際にtransformのrotateで矢印の形を作っています。
transitionをつけるとそれっぽくなります♪
そして下記の部分も重要です
.sidebar {
position: -webkit-sticky; /* Safari対応 */
position: sticky;
top: 0;
height: 100vh;
background-color: #4CAF93;
padding-left: $paddings;
padding-right: $paddings;
}
sidebarにpostion: stickyを指定することにより、コンテンツだけがスクロールされる状態になります。
こちらを付けないとコンテンツがスクロールされた際に、サイドバーもスクロールされ、UIが悪い印象になってしまします。
ページを追加する
あとはページを追加するだけです。
src/pages/index.tsx
src/pages/page1.tsx
src/pages/page2.tsx
src/pages/page3.tsx
を作成しましょう。
中身はこんな感じで今回は適当です。
import type { NextPage } from 'next'
const Page2: NextPage = () => {
return (
<div>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
<p>ページ2</p>
</div>
)
}
export default Page2;
Layoutコンポーネントを使用する
最後にLayoutコンポーネントを呼び出しましょう
// pages/_app.tsx
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { Layout } from "../components/layouts/Layout";
function MyApp({ Component, pageProps }: AppProps) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
)
}
export default MyApp
LayoutでComponet部分をつつんであげることによりPageComponentがコンテンツ部分になります。
お疲れさまでした!!!