Zenn
💭

【個人サイト開発ログ】レスポンシブなグローバルナビゲーションバーを作った

2025/02/25に公開

今回からは、前回立ち上げた個人サイトの機能開発を行なっていきます。

今回の開発内容について

サイトの全画面共通のナビゲーションを追加したいと思います。

今のホーム画面は以下のようになっています。

記事の詳細画面に遷移するとヘッダーが付与されていますが、サイトロゴしかありません。

ヘッダーロゴに加えて、Home ページ、Works ページ、Blog ページ、Profile ページ、Contact ページにそれぞれ遷移できるようにナビゲーションを作成します。
イメージは以下のような感じです。

必要なものを準備する

  • リンク先のページ作成

    App Router を採用しているため、/src/app 配下にナビゲーションバーの項目名と同じ名前のディレクトリを作成し、作成したディレクトリの直下に page.tsx を作成する。Home ページ、Works ページ、Blog ページ、Profile ページ、Contact ページをそれぞれ表示するために必要なディレクトリを app の直下に作成する。このディレクトリ名がそのままルート(/)の後の URL となる。

    例)/src/app 配下に blog ディレクトリを作成した場合、https://y-blo.vercel.app/blog となる。

    /src
    |_app
        |_blog
          |_page.tsx
        |_contact
          |_page.tsx
        |_profile
          |_page.tsx
        |_works
          |_page.tsx
    
  • 各ページへのリンクを作成

    上記の App Router の仕組みにより、https://[ホスト名]/blog などのように URL ができるため、それらを各ナビゲーションバーの項目のリンク先として扱う。

開発

① まずは PC 用のヘッダーのために作成する

今回作成する個人サイトとしては PC,タブレット,スマホの大きく分けて 3 種類のデバイスでの UI とするため、サイトのアクセシビリティとして各画面でのレスポンシブ対応ができている状態を目指す。
スマホ対応は後述するため、まずは PC で表示した場合のヘッダー用にナビゲーションを作成する。

共通部品と画面コンテンツの表示について

まずは Next.js の App Router における画面表示をする際のファイルの関係について述べる。

/src/app 配下に layout.tsx と page.tsx のファイルが存在する。これらは/src/app 配下に関わらず、
page.tsx は特定のページのコンテンツを定義し、layout.tsx ファイルは、特定のディレクトリ内のすべてのページに共通のレイアウトを提供する。
なので、サイトの全ページ共通で表示したいレイアウト(ヘッダー、フッター、ナビゲーションなど)は layout.tsx にコンポーネントを定義する。

また、layout.tsx ファイルの RootLayout コンポーネントは、children プロパティを通じて各ページのコンテンツを受け取り、共通レイアウト内に挿入する。

/src/app/layout.tsx
import { CMS_NAME, HOME_OG_IMAGE_URL } from '@/lib/constants'
import cn from 'classnames'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'

import Footer from '@/app/_components/footer'
import Header from '@/app/_components/header'
import { NavbarProvider } from '@/app/_context/NavbarContext'
import 'zenn-content-css'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'y-blo',
  description: `Next.jsと${CMS_NAME}を使った個人技術ブログ.`,
  openGraph: {
    images: [HOME_OG_IMAGE_URL],
  },
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <head>
        {/* メタデータやリンクタグのため省略 */}
      </head>
      <body className={cn(inter.className, 'dark:bg-slate-900 dark:text-slate-400')}>
        <NavbarProvider>
          <Header />
          <div className="min-h-screen mb-20">{children}</div>
        </NavbarProvider>
        <Footer />
      </body>
    </html>
  )
}
/src/app/page.tsx
import Container from '@/app/_components/container'
import { HeroPost } from '@/app/_components/hero-post'
import { getPostsData, getSortedPostsData } from '@/lib/posts'
import type { Article } from '@/types/Article'

export default async function Index() {
  const allPostsData = await getPostsData()
  const sortedPostData: Article[] = await getSortedPostsData(allPostsData)
  const heroPost = sortedPostData[0]

  return (
    <main>
      <Container>
        {heroPost ? (
          <HeroPost
            key={heroPost.id}
            id={heroPost.id}
            title={heroPost.title}
            emoji={heroPost.emoji}
            topics={heroPost.topics}
            published_at={heroPost.published_at}
          />
        ) : (
          <p>No hero post available.</p>
        )}
      </Container>
    </main>
  )
}

今回作成するグローバルナビゲーションはヘッダー内に表示するため、
Header コンポーネントを layout.tsx の body コンポーネント内(children の外側)に配置する。

また、Header コンポーネントと children を囲う形で NavbarProvider コンポーネントを配置しているが、
これは state をコンテキストで共有するために必要なものなので、後述の説明で補足する。

ナビゲーションバーを作成する

Heade コンポーネントを定義したため、その中で表示するナビゲーションバーを作成するために Navbar コンポーネントを作成する。以下に Header コンポーネントと Navbar コンポーネントをそれぞれ説明する。

/src/app/_components
import Navbar from '@/app/_components/navbar'
import Link from 'next/link'

const Header = () => {
  return (
    <header className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between h-18 px-8 border-b border-neutral-200 bg-neutral-50 dark:bg-slate-800 md:h-22 lg:h-24">
      <h1>
        <Link
          href="/"
          className="text-2xl font-logo hover:underline md:text-4xl lg:text-6xl"
        >
          y-blo
        </Link>
      </h1>
      <div>
        <Navbar />
      </div>
    </header>
  )
}

export default Header

Header コンポーネント内に Navbar コンポーネントを定義します。
ちなみに Link タグは Next.js 13 以降では a タグとして認識されるようで、子コンポーネントに a タグを置くと無効になるそうです。そのためテキストを Link タグで直接かこっています。

/src/app/_components/navbar.tsx
'use client'

import NavLinks from '@/app/_components/nav-links'
import NavLinksSmp from '@/app/_components/nav-links-smp'
import { useNavbar } from '@/app/_context/NavbarContext'
import { Disclosure } from '@headlessui/react'
import { Bars3Icon, BellIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { usePathname } from 'next/navigation'
import React, { useEffect } from 'react'

const navigation = [
  { name: 'Home', href: '/', current: true },
  { name: 'Works', href: '/works', current: false },
  { name: 'Blog', href: '/blog', current: false },
  { name: 'Profile', href: '/profile', current: false },
  { name: 'Contact', href: '/contact', current: false },
]

const Navbar = React.memo(() => {
  const pathname = usePathname()
  const { isOpen, toggleNavbar, closeNavbar } = useNavbar()

  useEffect(() => {
    closeNavbar()
  }, [pathname])

  return (
    <Disclosure as="nav" className="relative z-50">
      <div className="mx-auto max-w-7xl px-0.25 xs:px-6 lg:px-8 pr-6">
        <div className="relative flex h-16 items-center justify-between">
          <div className="absolute inset-y-0 left-0 flex items-center lg:hidden">
            {/* Mobile menu button */}
            <div
              onClick={toggleNavbar}
              className="group relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:ring-2 focus:ring-white focus:outline-hidden focus:ring-inset"
            >
              <span className="absolute -inset-0.5" />
              <span className="sr-only">Open main menu</span>
              <Bars3Icon
                aria-hidden="true"
                className={`size-8 ${isOpen ? 'hidden' : 'block'}`} // isOpenがfalseのとき表示
              />
              <XMarkIcon
                aria-hidden="true"
                className={`size-8 text-gray-800 ${isOpen ? 'block' : 'hidden'}`} // isOpenがtrueのとき表示
              />
            </div>
          </div>

          {/* スマホのナビゲーション */}
          {isOpen && (
            <div className="lg:hidden text-left fixed bg-slate-100 right-0 top-16 w-full h-screen flex flex-col justify-start pt-8 px-3">
              <div className="px-7 pt-6 pb-10">
                <NavLinksSmp
                  navigation={navigation}
                  closeNavbar={closeNavbar}
                  isOpen={isOpen}
                />
              </div>
            </div>
          )}

          {/* PCのナビゲーション */}
          <div className="flex flex-1 items-center justify-center lg:items-stretch lg:justify-start">
            <div className="hidden lg:ml-6 lg:block">
              <div className="flex space-x-4">
                <NavLinks navigation={navigation} />
              </div>
            </div>
          </div>

          <div className="absolute inset-y-0 right-0 flex items-center lg:static lg:inset-auto mr-3 lg:ml-6 lg:pr-0">
            {/* notificationボタン */}
            <button
              type="button"
              className="relative rounded-full bg-gray-800 p-1 text-gray-400 hover:text-white focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800 focus:outline-hidden"
            >
              <span className="absolute -inset-1.5" />
              <span className="sr-only">View notifications</span>
              <BellIcon aria-hidden="true" className="size-6" />
            </button>
          </div>
        </div>
      </div>
    </Disclosure>
  )
})

Navbar.displayName = 'Navbar'

export default Navbar

Navbar コンポーネントではナビゲーションバーの表示を制御します。
レスポンシブ対応がなされるように、スマホの場合のコンポーネントも PC 画面の表示時の UI となるコンポーネントも両方記載しており、
CSS で画面幅が lg サイズ(横幅 1024px)を境に、スマホ&iPad の画面表示か PC の画面表示するかを tailwindCSS で className に hidden を与えることで表示の出しわけを行っています。

hook の利用

Navbar コンポーネントでは特にスマホのナビゲーションの状態変化のためにフックスを活用しています。
Next.js では React hook を使う場合はファイルの一番トップに'use client'と記載し、クライアントコンポーネントであることを明示しなければなりません。

以下に、このコンポーネントで使用する hook の説明をします。

  • React.memo
    React.memo は、コンポーネントが受け取るプロパティが変更されない限り、再レンダリングをスキップします。
    しかし、以下のような場合には再レンダリングが発生します:

    1. 内部状態の変更: コンポーネント内部で状態(state)が変更された場合、再レンダリングが発生します。
    2. フックの影響: コンポーネント内で使用されているフック(例:useEffect、useState、useContext など)の影響で再レンダリングが発生することがあります。

    Navbar コンポーネントは、次のフックを使用しているため、再レンダリング発生の可能性は十分にあります:usePathname、useNavbar、useEffect

  • usePathname
    usePathname:現在の URL パスを取得するために使用されます。
    URL パスが変更されると、usePathname が再評価され、再レンダリングが発生します。

  • useEffect
    useEffect:pathname が変更されたときにナビゲーションバーを閉じるために使用されます。
    pathname が変更されると、useEffect が再評価され、再レンダリングが発生します。

  • useNavbar
    useNavbar:ナビゲーションバーの状態を管理するカスタムフックです。ナビゲーションバーの開閉状態が変更されると、再レンダリングが発生します。

カスタムフック(useNavbar)の解説

/src/app/_context/NavbarContext.ts
'use client'

import type { ReactNode } from 'react'
import { createContext, useContext, useState } from 'react'

interface NavbarContextType {
  isOpen: boolean
  toggleNavbar: () => void
  closeNavbar: () => void
}

const NavbarContext = createContext<NavbarContextType | null>(null)

export const useNavbar = () => {
  const context = useContext(NavbarContext)
  if (!context) {
    throw new Error('useNavbar must be used within a NavbarProvider')
  }
  return context
}

interface NavbarProviderProps {
  children: ReactNode
}

export const NavbarProvider = ({ children }: NavbarProviderProps) => {
  const [isOpen, setIsOpen] = useState(false)

  const toggleNavbar = () => setIsOpen((prev) => !prev)
  const closeNavbar = () => setIsOpen(false)

  return (
    <NavbarContext.Provider value={{ isOpen, toggleNavbar, closeNavbar }}>
      {children}
    </NavbarContext.Provider>
  )
}

ナビゲーションバーの状態を管理するためのコンテキストとプロバイダーを定義しています。このコンテキストを使用することで、アプリケーション内のどこからでもナビゲーションバーの状態を操作することができます。

  • コンテキストの作成
    NavbarContextType:
    ナビゲーションバーの状態と操作を定義するインターフェース。
    Context で管理し子コンポーネントに共有したい状態変数の型は全項目分ここに記述。

    NavbarContext:
    ナビゲーションバーの状態を管理するためのコンテキストを作成します。型変数は NavbarContextType とし、初期値は null です。

  • useNavbar フック

    useNavbar:
    ナビゲーションバーの状態を取得するためのカスタムフックです。
    このフックは必ず NavbarProvider 内で使用される必要があります。

    useContext:
    NavbarContext を使用してコンテキストの値を取得し、null の場合、エラーをスローします

  • NavbarProvider コンポーネント

    NavbarProvider:
    ナビゲーションバーの状態を管理するプロバイダーコンポーネントです。

    useState:
    ナビゲーションバーの開閉状態を管理するための状態フックです。

    toggleNavbar:
    ナビゲーションバーの開閉を切り替える関数です。

    closeNavbar:
    ナビゲーションバーを閉じる関数です。

    NavbarContext.Provider:
    コンテキストプロバイダーを使用して、ナビゲーションバーの状態と操作を子コンポーネントに提供します。

  • NavbarProvider コンポーネントとの関係

    NavbarProvider:
    このコンポーネントは、ナビゲーションバーの状態を管理し、子コンポーネントにその状態と操作を提供します。

    useNavbar フック:
    このフックを使用することで、NavbarProvider 内の任意のコンポーネントからナビゲーションバーの状態と操作を簡単に取得できます。

    NavbarContext:
    ナビゲーションバーの状態を管理するためのコンテキストです。NavbarProvider がこのコンテキストを提供し、useNavbar フックがこのコンテキストを利用します。

ハンバーガーメニューの表示制御

/src/app/layout.tsx
(再掲)
<div className="absolute inset-y-0 left-0 flex items-center lg:hidden">
    {/* Mobile menu button */}
    <div
      onClick={toggleNavbar}
      className="group relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:ring-2 focus:ring-white focus:outline-hidden focus:ring-inset"
    >
      <span className="absolute -inset-0.5" />
      <span className="sr-only">Open main menu</span>
      <Bars3Icon
        aria-hidden="true"
        className={`size-8 ${isOpen ? 'hidden' : 'block'}`} // isOpenがfalseのとき表示
      />
      <XMarkIcon
        aria-hidden="true"
        className={`size-8 text-gray-800 ${isOpen ? 'block' : 'hidden'}`} // isOpenがtrueのとき表示
      />
    </div>
  </div>

  {/* スマホのナビゲーション */}
  {isOpen && (
    <div className="lg:hidden text-left fixed bg-slate-100 right-0 top-16 w-full h-screen flex flex-col justify-start pt-8 px-3">
      <div className="px-7 pt-6 pb-10">
        <NavLinksSmp
          navigation={navigation}
          closeNavbar={closeNavbar}
          isOpen={isOpen}
        />
      </div>
    </div>
  )}

useNavbar によりコンテキストから 3 つの状態変数と関数を取得しました(isOpen, toggleNavbar, closeNavbar)。

isOpen によってハンバーガーメニューの Bar3Icon(false の時表示)と XMarkIcon(true の時表示)の制御を行い、true の場合にナビゲーションを表示させます。

toggleNavbar は isOpen の値を反転させ、closeNavbar は isOpen を false にします。
ハンバーガーメニューのアイコンは toggleNavbar を onClick イベントハンドラによって実行されるため、メニューの開閉ができる。
スマホナビゲーションの NavLinksSmp コンポーネントには closeNavbar を props に渡しているため、
つまりナビゲーションメニューから別のページへのリンクを押下した際にナビゲーションメニューが閉じる処理が行われることを意味します。

/src/app/_components/nav-links-smp.tsx
'use client'

import { DisclosureButton } from '@headlessui/react'
import { usePathname } from 'next/navigation'
import React from 'react'

type NavLink = {
  name: string
  href: string
  current: boolean
}

function classNames(...classes: (string | undefined | null | false)[]): string {
  return classes.filter(Boolean).join(' ')
}

interface NavLinksSmpProps {
  navigation: NavLink[]
  closeNavbar: () => void
  isOpen: boolean
}

const NavLinksSmp = React.memo(
  ({ navigation, closeNavbar, isOpen }: NavLinksSmpProps) => {
    const pathname = usePathname()

    return (
      <>
        {isOpen &&
          navigation.map((link) => {
            return (
              <DisclosureButton
                key={link.name}
                as="a"
                href={link.href}
                className={classNames(
                  link.href === pathname
                    ? 'bg-gray-900 text-white'
                    : 'hover:bg-gray-700 hover:text-white',
                  'block rounded-md px-3 py-4 text-base font-medium',
                )}
                onClick={() => {
                  console.log(
                    `リンクがクリックされました: ${link.name}, URL: ${link.href}`,
                  )
                  closeNavbar()
                }}
              >
                {link.name}
              </DisclosureButton>
            )
          })}
      </>
    )
  },
)
NavLinksSmp.displayName = 'NavLinksSmp'

export default NavLinksSmp

classNames 関数は、複数のクラス名を動的に結合するための便利なユーティリティ関数です。
undefined、null、false などの「偽」と評価される値を除外し、有効なクラス名だけをスペースで結合して 1 つの文字列にします。
これにより、tailwindCSS などでのクラス名の動的な設定が簡単になります。

終わり

個人的に難しかった点としては、ハンバーガーメニューのボタン開閉制御のところだった。
ボタンを開いた状態のままで、ヘッダーのロゴに貼り付けたリンクを押下すると(つまり isOpen の状態を変更する関数をクリックイベントで持っていないリンク)、開いたメニューが閉じないまま画面遷移してしまうという状態になってしまった。

調べてみたところ useContext の仕組みを使うことで、親が離れているコンポーネントにも isOpen ステートを渡せるようにすることで解決できた。
最適解だったのかはわからないが、ひとまず作りたい機能を作ることができた。

また、スマホナビゲーションの開閉時どちらの場合も配置を整えるのに時間がかかってしまった。元々 CSS は少し苦手意識があるため時間がかかってしまったのは力不足ではあるが、tailwindCSS を初めて使ってみたところ自分的には使いやすいと感じた。style シートを別ファイルで作って書くのも良いが、できれば今後も React と一緒に tailwindCSS を使いたい。

今回ナビゲーションバーをレスポンシブに開発することができた。
今後しばらくは個人サイトの機能追加などに関する記事を書くと思います(開発したけど記事でアウトプットできていないことが溜まっている)。
ほんとは記事アウトプットしながら開発を進めるのが良さそうだが、自分の場合は熱中すると記事を書くよりも先にコードを書きたくなってしまう・・・。

GitHubで編集を提案

Discussion

ログインするとコメントできます