For

2023.4.22

Next.js + Tailwind + recoilで管理画面系のSidebarをいい感じに実装する

概要

Next.js + Tailwindのプロジェクトで、Tailwindのスタイルとrecoilの状態管理でいい感じのSidebarを実装したいと思います。

ゴール


要件

簡単ではありますが以下の機能や効果を持ちます。

・hoverで色が変わる
・現在開いているページのナビゲーションアイテムは背景色が変わる
・ハンバーガーメニューボタンクリックで開閉する

実装

Next.jsプロジェクトの作成

まずは以下のコマンドでプロジェクトを作成します。

bash_____terminal_____$ yarn create next-app


いくつかオプションを選択することになりますが、全てデフォルトで選択されているものを選択してください。
すると2023/04/22時点での最新バージョンであるnext@13.3.0であればTailwindをインストールしておいてくれます。

Tailwindのコンフィグを設定

これは完全にお好みでしかないのですが、私はTailwindで細かいスタイル調整まで済ませたいため、以下のようなconfigにしています。
このとおりにする必要はありませんが、この記事で登場するclass名は以下のconfigの影響を受けるのでご注意ください。

JavaScript_____tailwind.config.js_____/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {
      scale: {
        flip: '-1',
      },
      minHeight: {
        '100vh': '100vh',
      },
      borderWidth: {
        3: '3px',
        5: '5px',
      },
    },
    screens: {
      xs: '375px',
      sm: '600px',
      md: '768px',
      lg: '992px',
      xl: '1200px',
      xxl: '1360px',
    },
    maxWidth: {
      ...[...Array(1201)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}p`] = `${i}%`;
        return m;
      }, {}),
      xs: '375px',
      sm: '600px',
      md: '768px',
      lg: '992px',
      xl: '1200px',
      xxl: '1360px',
      none: 'none',
    },
    minWidth: {
      ...[...Array(1201)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}p`] = `${i}%`;
        return m;
      }, {}),
      xs: '375px',
      sm: '600px',
      md: '768px',
      lg: '992px',
      xl: '1200px',
      xxl: '1360px',
      none: 'none',
    },
    maxHeight: {
      ...[...Array(1201)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}p`] = `${i}%`;
        return m;
      }, {}),
    },
    minHeight: {
      ...[...Array(1201)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}p`] = `${i}%`;
        return m;
      }, {}),
    },
    borderRadius: {
      ...[...Array(32)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      '100vh': '100vh',
    },
    fontSize: {
      ...[...Array(101)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}vw`] = `${i}vw`;
        return m;
      }, {}),
    },
    padding: {
      ...[...Array(1001)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}p`] = `${i}%`;
        return m;
      }, {}),
      ...[...Array(5)].reduce((m, _, i) => {
        m[`${i}em`] = `${i}em`;
        return m;
      }, {}),
    },
    margin: {
      ...[...Array(1001)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(1001)].reduce((m, _, i) => {
        m[`minus-${i}`] = `-${i}px`;
        return m;
      }, {}),
      auto: 'auto',
    },
    width: {
      ...[...Array(1001)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}p`] = `${i}%`;
        return m;
      }, {}),
      auto: 'auto',
      fit: 'fit-content',
    },
    height: {
      ...[...Array(1001)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}p`] = `${i}%`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}vh`] = `${i}vh`;
        return m;
      }, {}),
      fit: 'fit-content',
    },
    lineHeight: {
      ...[...Array(101)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      '1em': '1em',
    },
    zIndex: {
      ...[...Array(10001)].reduce((m, _, i) => {
        m[i] = `${i}`;
        return m;
      }, {}),
      'minus-1': '-1',
      auto: 'auto',
    },
    translate: {
      ...[...Array(1001)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(1001)].reduce((m, _, i) => {
        m[`minus-${i}`] = `-${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}p`] = `${i}%`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`minus-${i}p`] = `-${i}%`;
        return m;
      }, {}),
    },
    inset: {
      ...[...Array(1001)].reduce((m, _, i) => {
        m[i] = `${i}px`;
        return m;
      }, {}),
      ...[...Array(1001)].reduce((m, _, i) => {
        m[`minus-${i}`] = `-${i}px`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`${i}p`] = `${i}%`;
        return m;
      }, {}),
      ...[...Array(101)].reduce((m, _, i) => {
        m[`minus-${i}p`] = `-${i}%`;
        return m;
      }, {}),
      auto: 'auto',
    },
  },
  variants: {
    extend: {},
  },
  plugins: [],
};


パッケージ追加

react-iconsrecoil を使うのでインストールします。

bash_____terminal_____$ yarn add react-icons recoil


recoilでSidebar用のstateを作成

サンプルなのでReactのContextでもReduxでも親コンポーネントで定義したstateの受け渡しでもなんでも良いのですが今回はrecoiiにしました。
以下で定義するstateでSidebarが開閉を管理します。

TypeScript_____isSidebarOpen.ts_____import { useCallback } from 'react';
import { atom, useRecoilValue, useSetRecoilState } from 'recoil';

const isSidebarOpenRecoilState = atom<boolean>({
  key: 'isSidebarOpenRecoilState',
  default: true,
});

export const useIsSidebarOpenState = () => {
  return useRecoilValue(isSidebarOpenRecoilState);
};

export const useIsSidebarOpenMutator = () => {
  const setState = useSetRecoilState(isSidebarOpenRecoilState);

  const setIsSidebarOpen = useCallback(
    (isSidebarOpen: boolean) => setState(isSidebarOpen),
    [setState]
  );

  const toggleIsSidebarOpen = useCallback(
    () => setState((state) => !state),
    [setState]
  );
  return { setIsSidebarOpen, toggleIsSidebarOpen };
};


stateが用意できたのでRecoilRootを _app.tsx に追加しておきます。

TypeScript_____src/pages/_app.tsx_____import '@/styles/globals.css';
import type { AppProps } from 'next/app';
import { RecoilRoot } from 'recoil';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <RecoilRoot>
      <Component {...pageProps} />
    </RecoilRoot>
  );
}


Headerを作成

Tailwindでスタイリングしつつ、上記で定義したstateの真偽値をトグルするハンバーガーメニューボタンを持つHeaderコンポーネントを作成します。

tsx_____Header.tsx_____import { useIsSidebarOpenMutator } from '@/recoil/isSidebarOpen/isSidebarOpen';
import { RxHamburgerMenu } from 'react-icons/rx';
import { FaReact } from 'react-icons/fa';

export const Header = () => {
  const { toggleIsSidebarOpen } = useIsSidebarOpenMutator();
  return (
    <header className={'h-88 flex justify-between items-center p-20'}>
      <button
        className={'cursor-pointer'}
        onClick={() => toggleIsSidebarOpen()}
      >
        <RxHamburgerMenu size={24} />
      </button>
      <div className={'flex items-center'}>
        <FaReact size={24} />
        <p className={'ml-10 text-18'}>React App</p>
      </div>
      <p>example@kobayashiii.dev</p>
    </header>
  );
};


Sidebarを作成

Sidebarは、NavigationItemコンポーネントと、それをまとめたSidebarコンポーネントという構成で作っていきます。
まずは NavigationItem.tsx から作りますが、このコンポーネントはURLのパスを見て自身のリンク先パスと同じだったら背景色をつけるロジックを持ちます。
ちなみに色はTailwindCSS公式 Customizing Colors からお好きな色に変えてください。
シャドウに関してはTailwindCSS公式 Box Shadow Color でわかりやすくまとまっています。

tsx_____NavigationItem.tsx_____import Link from 'next/link';
import { useRouter } from 'next/router';
import { IconType } from 'react-icons/lib';

type Props = {
  Icon: IconType;
  href: string;
  name: string;
};

export const NavigationItem = ({ Icon, href, name }: Props) => {
  const router = useRouter();
  return (
    <>
      <Link
        href={href}
        className={`${
          router.pathname == href
            ? 'bg-emerald-500 shadow-lg shadow-emerald-500/50'
            : ''
        } w-100p rounded-8 flex items-center p-12 text-14 text-white duration-200 hover:bg-emerald-500 hover:shadow-lg hover:shadow-emerald-500/50`}
      >
        <Icon color={'white'} size={24} />
        <p className={'ml-10'}>{name}</p>
      </Link>
    </>
  );
};


続いてNvigationItemをまとめた Sidebar.tsx を作ります。

tsx_____Sidebar.tsx_____import { useState } from 'react';
import { AiFillDashboard } from 'react-icons/ai';
import { BsGraphUpArrow } from 'react-icons/bs';
import { NavigationItem } from '../NavigationItem';
import { HiOutlineUserGroup } from 'react-icons/hi';
import { useIsSidebarOpenState } from '@/recoil/isSidebarOpen/isSidebarOpen';

export const Sidebar = () => {
  const isSidebarOpen = useIsSidebarOpenState();
  return (
    <>
      <nav
        className={`${
          isSidebarOpen ? '' : 'ml-minus-256'
        } w-256 bg-gray-900 p-20 duration-300`}
      >
        <NavigationItem Icon={AiFillDashboard} href={'/'} name={'HOME'} />
        <NavigationItem
          Icon={HiOutlineUserGroup}
          href={'/users'}
          name={'Users'}
        />
        <NavigationItem Icon={BsGraphUpArrow} href={'/sales'} name={'Sales'} />
      </nav>
    </>
  );
};


Sidebarが格納されるときのスタイルはマイナスマージンを当てると手軽なのでそのようにしています。

Layoutを追加

Layout作るまでもないサンプルプロジェクトではありますが作りました。

TypeScript_____DefaultLayout.tsx_____import * as React from 'react';
import Head from 'next/head';
import { Sidebar } from '@/components/Sidebar';
import { Header } from '@/components/Header';

type Props = {
  children: React.ReactNode;
};

const DefaultLayout = ({ children }: Props) => {
  return (
    <>
      <Head>
        <title>charts-sample</title>
      </Head>
      <div className={'flex min-h-100vh'}>
        <Sidebar />
        <div className={'flex flex-col flex-grow bg-gray-800'}>
          <Header />
          <main className={'bg-gray-800 flex-grow p-20'}>{children}</main>
        </div>
      </div>
    </>
  );
};

export default DefaultLayout;


各ページを作成

まずはHomeを更新します。

tsx_____src/pages/index.tsx_____import DefaultLayout from '@/layouts/DefaultLayout';

export const Index = () => {
  return (
    <>
      <DefaultLayout>
        <h1>Home</h1>
      </DefaultLayout>
    </>
  );
};

export default Index;


続いてページ遷移確認用のUsersページとSalesページをコピペで追加します。

tsx_____src/pages/users/index.tsx_____import DefaultLayout from '@/layouts/DefaultLayout';

export const Users = () => {
  return (
    <>
      <DefaultLayout>
        <h1>Users</h1>
      </DefaultLayout>
    </>
  );
};

export default Users;


tsx_____src/pages/sales/index.tsx_____import DefaultLayout from '@/layouts/DefaultLayout';

export const Sales = () => {
  return (
    <>
      <DefaultLayout>
        <h1>Sales</h1>
      </DefaultLayout>
    </>
  );
};

export default Sales;


完成

以上で完成したはずですので yarn dev で確認してみましょう。
最初にgifファイルで確認したようなものができあがっているかと思います。