abeshi blog
カテゴリーで検索

Next.jsでBacklog風のサイドメニューを作ってみよう!

2022年2月26日

作成するもの




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がコンテンツ部分になります。


お疲れさまでした!!!