• トップ
  • ブログ一覧
  • Next.jsでNotionライクなリサイズ可能なサイドバーを実装しよう
  • Next.jsでNotionライクなリサイズ可能なサイドバーを実装しよう

    ロン(エンジニア)ロン(エンジニア)
    2024.12.16

    IT技術

    はじめに

    こんにちは! ふとNotionのようなリサイズ可能なサイドバーの実装方法が気になったので、調べたことをコードを交えてステップバイステップで実装し共有しようと思います。

    ステップ1 基本構造の作成

    今回は以下のようなUIのダッシュボードをSidebar / Navbar / MainContentコンポーネントを元に作成したいと思います。まずはこれら各コンポーネントの基本的なコードを実装していきます。
    resizeble_sidebar 構造

    src/app/page.tsx

    1import MainContent from "@/components/MainContent";
    2import Navbar from "@/components/Navbar";
    3import Sidebar from "@/components/Sidebar";
    4
    5export default function Home() {
    6  return (
    7    <div className="h-screen flex flex-col">
    8      <div className="flex-1 flex overflow-hidden">
    9        <Sidebar />
    10        <div className="flex-1 flex flex-col">
    11          <Navbar />
    12          <MainContent />
    13        </div>
    14      </div>
    15    </div>
    16  );
    17}

    src/components/Sidebar/index.tsx

    1import React from 'react';
    2
    3const Sidebar = () => {
    4  return (
    5    <aside className="h-full overflow-y-auto p-4 font-semibold">
    6      Sidebar
    7    </aside>
    8  );
    9};
    10
    11export default Sidebar;

    src/components/Navbar/index.tsx

    1import React from 'react';
    2
    3const Navbar = () => {
    4  return (
    5    <nav className="w-full p-4 font-semibold">
    6      Navbar
    7    </nav>
    8  );
    9};
    10
    11export default Navbar;

    src/components/MainContent/index.tsx

    1import React from 'react';
    2
    3const MainContent = () => {
    4  return (
    5    <div className="flex-1 overflow-y-auto bg-zinc-100 p-4 font-semibold">
    6      Main Content
    7    </div>
    8  );
    9};
    10
    11export default MainContent;

    これらの基本的なコンポーネント実装により以下のようなUIになるかと思います。

    ステップ1基本的なUI

    ステップ2 サイドバーをリサイズ可能にするためのロジックを追加

    このステップでは実際にリサイズ可能なサイドバーを実装します。
    classNameを動的に変更するためにclsxtailwind-mergeを追加します。

    1npm i clsx tailwind-merge

    src/lib/utils.ts

    1import { type ClassValue, clsx } from 'clsx';
    2import { twMerge } from 'tailwind-merge';
    3
    4export function cn(...inputs: ClassValue[]) {
    5  return twMerge(clsx(inputs));
    6}

    ロジックをカスタムフックとして実装し、コードの複雑さを解消します。
    src/hooks/useSidebarResize.ts

    1'use client';
    2
    3import { useCallback, useRef, useState } from 'react';
    4
    5export const useSidebarResize = () => {
    6  const MIN_SIDEBAR_WIDTH = 200;
    7  const MAX_SIDEBAR_WIDTH = 480;
    8  const isResizingRef = useRef(false);
    9
    10  const [sidebarWidth, setSidebarWidth] = useState(MIN_SIDEBAR_WIDTH);
    11
    12  const [isDragging, setIsDragging] = useState(false);
    13
    14  const handleMouseMove = useCallback((event: MouseEvent) => {
    15    if (!isResizingRef.current) return;
    16    //マウスの現在位置を取得
    17    let newWidth = event.clientX;
    18
    19    if (newWidth < MIN_SIDEBAR_WIDTH) newWidth = MIN_SIDEBAR_WIDTH;
    20    if (newWidth > MAX_SIDEBAR_WIDTH) newWidth = MAX_SIDEBAR_WIDTH;
    21
    22    setSidebarWidth(newWidth);
    23  }, []);
    24
    25  // ドラッグ操作を開始したときに呼び出される
    26  const handleMouseDown = (
    27    event: React.MouseEvent,
    28  ) => {
    29    event.preventDefault();
    30    // リサイズを開始
    31    isResizingRef.current = true;
    32    // ドラッグ状態を開始
    33    setIsDragging(true);
    34    document.addEventListener('mousemove', handleMouseMove);
    35    document.addEventListener('mouseup', handleMouseUp);
    36  };
    37
    38  // ドラッグ操作を終了したときに呼び出される
    39  const handleMouseUp = () => {
    40    // リサイズを終了
    41    isResizingRef.current = false;
    42    // ドラッグ状態を終了
    43    setIsDragging(false);
    44    document.removeEventListener('mousemove', handleMouseMove);
    45    document.removeEventListener('mouseup', handleMouseUp);
    46  };
    47
    48  return { sidebarWidth, isDragging, handleMouseDown };
    49};

    src/components/Sidebar/index.tsx

    1import { useSidebarResize } from '@/hooks/useSidebarResize';
    2import React from 'react';
    3import { cn } from '@/lib/utils';
    4
    5const Sidebar = () => {
    6  const { sidebarWidth, isDragging, handleMouseDown } = useSidebarResize();
    7
    8  return (
    9    <aside
    10      style={{
    11        width: sidebarWidth,
    12        transition: isDragging ? 'none' : 'width 0.3s ease-in-out',
    13      }}
    14      className={cn(
    15        'h-full overflow-y-auto relative flex flex-col z-[100] transition-all duration-300 ease-in-out p-4 font-semibold'
    16      )}
    17    >
    18      Sidebar
    19      <div
    20        onMouseDown={handleMouseDown}
    21        className="cursor-ew-resize absolute h-full w-1 right-0 top-0"
    22      />
    23    </aside>
    24  );
    25};
    26
    27export default Sidebar;

    上記のコードにより、サイドバーをドラッグすることでwidthを制限した範囲内でリサイズできるようになります!

    ステップ3 サイドバーを開閉するためのロジックを追加する

    このステップではドラッグによるリサイズに加え、サイドバーにアイコンを追加し、トグルでサイドバーを開閉できるようにします。
    以下のコードでreact-iconsを追加してください。

    1npm i react-icons

    src/app/page.tsx

    1'use client'
    2
    3import MainContent from "@/components/MainContent";
    4import Navbar from "@/components/Navbar";
    5import Sidebar from "@/components/Sidebar";
    6import { useState } from "react";
    7
    8export default function Home() {
    9  // サイドバーが折りたたまれているかどうかの状態管理を追加
    10  const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
    11 // 状態管理をトグルする関数の追加
    12  const handleToggleSidebar = () => {
    13    setIsSidebarCollapsed(!isSidebarCollapsed);
    14  };
    15
    16  return (
    17    <div className="h-screen flex flex-col">
    18      <div className="flex-1 flex overflow-hidden">
    19        <Sidebar
    20          isCollapsed={isSidebarCollapsed}
    21          onToggle={handleToggleSidebar}
    22        />
    23        <div className="flex-1 flex flex-col">
    24          <Navbar
    25            isCollapsed={isSidebarCollapsed}
    26            onToggleSidebar={handleToggleSidebar}
    27          />
    28          <MainContent />
    29        </div>
    30      </div>
    31    </div>
    32  );
    33}

    src/components/Sidebar/index.tsx

    1import { useSidebarResize } from '@/hooks/useSidebarResize';
    2import React from 'react';
    3import { cn } from '@/lib/utils';
    4import { MdKeyboardDoubleArrowLeft } from "react-icons/md";
    5
    6// 受け取る引数の型宣言
    7type SidebarProps = {
    8  isCollapsed: boolean;
    9  onToggle: () => void;
    10};
    11
    12const Sidebar:React.FC<SidebarProps> = ({
    13  isCollapsed,
    14  onToggle,
    15}) => {
    16  const { sidebarWidth, isDragging, handleMouseDown } = useSidebarResize(isCollapsed);
    17
    18  return (
    19    <aside
    20      style={{
    21        // isCollapsedがtrueの場合はwidthを0、それ以外はsidebarWidthにする
    22        width: isCollapsed ? 0 : sidebarWidth,
    23        transition: isDragging ? 'none' : 'width 0.1s ease-in-out',
    24      }}
    25      className={cn(
    26        'h-full overflow-y-auto relative flex flex-col z-[100] font-semibold p-4',
    27        isCollapsed ? 'w-0 p-0' : 'transition-all duration-300 ease-in-out',
    28      )}
    29    >
    30      <div
    31        onClick={onToggle}
    32        role="button"
    33        className={cn(
    34          'h-6 w-6 rounded-sm hover:bg-neutral-200 absolute top-4 right-4',
    35          isCollapsed ? 'hidden' : 'block',
    36        )}
    37      >
    38        <MdKeyboardDoubleArrowLeft className="h-6 w-6" />
    39      </div>
    40      Sidebar
    41      <div
    42        onMouseDown={handleMouseDown}
    43        className="cursor-ew-resize absolute h-full w-1 right-0 top-0"
    44      />
    45    </aside>
    46  );
    47};
    48
    49export default Sidebar;

    src/hooks/useSidebarResize.tsに変更を加えます。

    • 引数にisCollapsedを持たせる
    1export const useSidebarResize = (isCollapsed: boolean) => {
    • サイドバーのwidthをisCollapsedの状態によって動的に変更
    1const [sidebarWidth, setSidebarWidth] = useState(
    2 isCollapsed ? 0 : MIN_SIDEBAR_WIDTH,
    3);

    src/components/Navbar/index.tsx

    1import React from 'react';
    2import { MdMenu } from "react-icons/md";
    3
    4//受け取る引数の型宣言
    5type NavbarProps = {
    6  isCollapsed: boolean;
    7  onToggleSidebar: () => void;
    8};
    9
    10const Navbar: React.FC<NavbarProps> = ({
    11  isCollapsed,
    12  onToggleSidebar,
    13}) => {
    14  return (
    15    <nav className="p-4 w-full font-semibold flex">
    16      {/* isCollapsedがtrueの場合に表示 (= サイドバーが折りたたまれている時) */}
    17      {isCollapsed && (
    18        <MdMenu
    19          onClick={onToggleSidebar}
    20          role="button"
    21          className="h-6 w-6 cursor-pointer mr-4"
    22        />
    23      )}
    24      Navbar
    25    </nav>
    26  );
    27};
    28
    29export default Navbar;

    これら上記の実装により、サイドバーのwidthをドラッグで変えられるだけでなく、アイコンをクリックすることでサイドバーを開閉できるようになり、Notionで実装されているようなリサイズ可能なサイドバーを実装できました。

    最後に

    いかがだったでしょうか。Notionのようなリサイズできるサイドバーを実装できたかと思います。他にも気になる実装方法があればまたブログを通して共有していこうと思います。最後まで読んでいただき、ありがとうございました。

    参考サイト:Fullstack Notion Clone: Next.js 13, React, Convex, Tailwind | Full Course 2023

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    ロン(エンジニア)
    ロン(エンジニア)
    Show more...

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background