Next.jsにdaisyUIを導入する(サンプルページ付き) ― ReactでWebアプリ開発〈2〉

はじめに

前回の記事↓

Reactを使ってWebアプリを作りたい ― ReactでWebアプリ開発〈1〉

前回の記事で、アプリの方向性や使用するライブラリを大まかに決めたので、今回はそれらのライブラリを使ったサンプルページを作っていきます。プロジェクトの作成やライブラリの導入といった基本的な準備を行った後、試しに簡単な天気情報サイトを作成してみました。

作成した天気情報サイトの外観

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

まず、Next.jsのプロジェクトの作成を行います。

プロジェクトは公式ドキュメントの推奨通り、以下のコマンドで作成します。

npx create-next-app@latest

コマンド実行後、プロジェクト名やオプションの設定のための質問が出てくるので、一つずつ答えていきます(基本的にYesです)。特に、今回のプロジェクトではdaisyUIを使用するので、Tailwind CSSの使用に関する項目はYesにします。

Would you like to use Tailwind CSS? » No / [Yes]

VSCodeの拡張機能のインストール

上記の通りNext.jsのプロジェクトを作成すると、プロジェクト全体に適用されるスタイルシートとしてglobal.cssが作成されます。その中にTailwind CSSを使用するためのディレクティブが記述されているのですが、VSCodeの場合、初期状態だとそこにUnknown at rule @tailwindという警告が現れてしまいます。

global.css
@tailwind base;
@tailwind components;
@tailwind utilities;

// 以下、デフォルトのスタイルが記述されていますが、今回は使わないので削除しました

警告が出ていても問題なく実行はできるのですが、何となく気持ち悪いので消すことにします。

以下の記事を参考にして、VSCodeの拡張機能であるPostCSS Language Supportをインストールすることで警告を出ないようにしました。(ついでに、同じ記事の中で紹介されているTailwind CSS IntelliSenseという、Tailwind CSS用の補完機能などが付いている拡張機能もインストールしました)

"Unknown at rule @tailwind"の警告を消す | Zenn

daisyUIの追加

daisyUIはTailwind CSS用のコンポーネントライブラリです。あらかじめ用意されたコンポーネントをクラス名を使って利用できるので、利便性が高く、CSSの記述をより簡潔にすることができます。

インストールは公式ブログ(How to install daisyUI and Tailwind CSS in Next.js 14 | daisyUI)の通りに行います。

まず、daisyUIをインストールします。

npm i -D daisyui@latest

その後、tailwind.config.tsを開き、daisyUIをプラグインに追加します。

tailwind.config.ts
import type { Config } from "tailwindcss";
import daisyui from 'daisyui'   // daisyUIのインポート

const config: Config = {
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      backgroundImage: {
        "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
        "gradient-conic":
          "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
      },
    },
  },
  plugins: [daisyui],   // プラグインに追加
};
export default config;

導入はたったこれだけです。用意されたコンポーネントのコードは公式サイトにまとめられています。クラス名を指定することで、使いまわすことが可能です。↓

All daisyUI components | daisyUI

天気情報サイトの作成

外部APIの使用やdaisyUIのコンポーネントの使用などを試してみるために、天気情報を表示する簡単なWebサイトをサンプルページとして作成しました。以下の図のようなページの構成になっています。

天気情報サイトのコンポーネントの説明図

天気情報の取得にはOpenWeatherMapが提供するWeather APIを使用しました。利用するためにはAPIキーが必要ですが、こちらはアカウントを作成するだけで取得できます。↓

Create New Account | OpenWeather

ナビゲーションバーの追加

まず、ページ上部のナビゲーションバーを追加します。daisyUIで用意されているNavbarを使って簡単に作成することができます。

Next.jsのApp Routerを使用している場合、appディレクトリ直下のlayout.tsxにナビゲーションバーを追加することで、サイト内の全ページにナビゲーションバーを表示させることができます。

layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import ThemeController from "./components/theme-controller";

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

export const metadata: Metadata = {
  title: "天気予報サンプル",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body className={`${inter.className}`}>
        {/* Navbar(daisyUI)の追加 */}
        <div className="navbar border-b px-10">
        <div className="navbar-start">
            <h1 className="text-xl font-bold">天気予報サンプル</h1>
        </div>
        <div className="navbar-end gap-2">
            <ThemeController />
            <a className="btn btn-ghost" href="https://mitaka.boo.jp" target="_blank">Blog</a>
        </div>
        </div>
        {children}
      </body>
    </html>
  );
}

navbarというクラスを指定することでナビゲーションバーを作成します。navbar-startnavbar-endといったクラス名を使うことで子要素の位置を指定できます。(Navbarの内部はFlexレイアウトになっているので、flexを指定することで位置を調整することも可能です)

子要素にはタイトルの他に、テーマを切り替えるためのトグルボタンや自分のブログへのリンクを貼ったボタンを追加しています。テーマ切り替えのトグルボタンはThemeControllerコンポーネントとして、別ファイルに用意しています。といっても、中身はdaisyUIに用意されているTheme Controllerをそのまま使用しているだけです。

components/theme-controller.tsx
export default function ThemeController() {
  return (
    <label className="flex cursor-pointer gap-2">
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="20"
        height="20"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round">
        <circle cx="12" cy="12" r="5" />
        <path
          d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
      </svg>
      <input type="checkbox" value="synthwave" className="toggle theme-controller" />
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="20"
        height="20"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round">
        <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
      </svg>
    </label>
  );
}

この時点ではただの飾りなので、ボタンを押してもテーマが切り替わることはありません。後ほど、実際にテーマを切り替えるための処理を追加で実装していきます。

天気情報表示コンポーネントの作成

天気情報を表示するコンポーネントは、daisyUIに用意されているStatコンポーネントをもとに、WeatherStatコンポーネントとして新たに作成しました。

components/weather-stat.tsx
import Image from "next/image";

const fetchWeather = async (lat: number, lon: number) => {
  {/* 緯度、経度、取得したAPIキーをパラメータにしてリクエスト送信 */}
  const response = await fetch(`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=YOUR_API_KEY`);
  if (!response.ok) throw new Error(response.statusText);
  return response.json();
}

export default async function WeatherStat({
  city, lat, lon
}: {
  city: string, lat: number, lon: number
}) {
  {/* 緯度・経度で地点を指定して、現在の天気情報を取得 */}
  const data = await fetchWeather(lat, lon);

  return (
    <div className="stats border h-40">
      <div className="stat">
        <div className="stat-title">{new Date((data?.dt + data?.timezone) * 1000).toLocaleString('ja-JP', {timeZone: 'UTC'})}</div>
        <div className="stat-value">{city}</div>
        <div className="stat-figure">
          <Image
            width={100}
            height={100}
            alt={data?.weather?.[0]?.description}
            src={`https://openweathermap.org/img/wn/${data?.weather?.[0]?.icon}@2x.png`}
          />
        </div>
        <div className="stat-desc flex gap-4">
          <div>気温: {Math.round(data?.main?.temp - 273.15)}℃</div>
          <div>湿度: {data?.main?.humidity}%</div>
        </div>
      </div>
    </div>
  );
}

WeatherStatコンポーネントにはPropsとして、表示する都市名cityとAPI呼び出しの際に利用する緯度lat、経度lonを渡します。

コンポーネント内では、天気情報の取得のために用意したfetchWeather()関数という非同期関数を呼び出します。緯度と経度、あらかじめ取得したAPIキーを利用してWeather APIにリクエストを送り、そのレスポンスをオブジェクトとして返すという単純な関数です。(APIの詳細については下記の公式ドキュメントを参照)

Current weather data

また、非同期関数をawaitを使って内部で呼び出すために、asyncを付けてWeatherStatコンポーネントを非同期コンポーネントにしています。

取得したJSONデータは以下のような構造になっています。(東京の天気を取得した場合)

{
    "coord": {
        "lon": 139.6941,
        "lat": 35.6868
    },
    "weather": [
        {
            "id": 501,
            "main": "Rain",
            "description": "moderate rain",
            "icon": "10n"
        }
    ],
    "base": "stations",
    "main": {
        "temp": 301.81,
        "feels_like": 306.39,
        "temp_min": 299.67,
        "temp_max": 302.38,
        "pressure": 1011,
        "humidity": 77,
        "sea_level": 1011,
        "grnd_level": 1006
    },
    "visibility": 10000,
    "wind": {
        "speed": 7.2,
        "deg": 180
    },
    "rain": {
        "1h": 2.37
    },
    "clouds": {
        "all": 75
    },
    "dt": 1724753704,
    "sys": {
        "type": 2,
        "id": 2001249,
        "country": "JP",
        "sunrise": 1724702974,
        "sunset": 1724750186
    },
    "timezone": 32400,
    "id": 1850144,
    "name": "Tokyo",
    "cod": 200
}

これらが変数dataにオブジェクトとして格納されているので、そこから必要なプロパティを取り出し、stat-figurestat-descを使ってStatコンポーネント内に表示します(ちなみに気温は絶対温度なので、セ氏温度に直しています)。

注意点として、Next.jsのImageコンポーネントを利用する場合、srcに外部URLの画像を指定するとエラーになるということが挙げられます。セキュリティ上の観点からこのような仕様になっているそうです。

この場合、next.config.mjs(あるいはnext.config.js)に以下のようにremotePatternsとしてURLのホスト名を追加することで解決します。

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        hostname: 'openweathermap.org'
      }
    ]
  }
};

export default nextConfig;

Suspenseによるローディング表示

実装したWeatherStatコンポーネントを使って、様々な都市の現在の天気を表示するページを作成します。appディレクトリ直下のpage.tsxを以下のように変更します。

page.tsx
import { Suspense } from "react";
import WeatherStat from "./components/weather-stat";

export default function Home() {
  return (
    <main className="grid gap-5 py-8 grid-cols-1 px-40 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
      <Suspense fallback={<div className="skeleton h-40"></div>}>
        <WeatherStat city="東京" lat={35.6828387} lon={139.7594549} />
      </Suspense>
      <Suspense fallback={<div className="skeleton h-40"></div>}>
        <WeatherStat city="ロンドン" lat={51.5073219} lon={-0.1276474} />
      </Suspense>
      <Suspense fallback={<div className="skeleton h-40"></div>}>
        <WeatherStat city="ニューヨーク" lat={40.7127281} lon={-74.0060152} />
      </Suspense>
      <Suspense fallback={<div className="skeleton h-40"></div>}>
        <WeatherStat city="デリー" lat={28.6517178} lon={77.2219388} />
      </Suspense>
      <Suspense fallback={<div className="skeleton h-40"></div>}>
        <WeatherStat city="ソウル" lat={37.5666791} lon={126.9782914} />
      </Suspense>
    </main>
  );
}

別々の都市のPropsを設定したWeatherStatコンポーネントを複数用意して、それらを<main>要素の中に配置しています。<main>要素のクラスにはTailwind CSSのグリッドレイアウトを指定しており、画面サイズによって列数が変わるようなレスポンシブ対応も行っています。

WeatherStatコンポーネントが非同期コンポーネントなので、ReactのSuspenseを使って表示待ちの際にフォールバックが表示されるように設定します。こちらも、daisyUIに便利なスケルトンコンポーネントが用意されているので、そちらを使用しています。

ローディング中は、以下の図のようにスケルトンのアニメーションが行われます。

天気情報サイトのスケルトンスクリーンの図

ちなみに、都市名の長さによってはレイアウトが崩れてしまうのですが、サンプルページなのでとりあえずこれで良しとします…。

テーマ切り替えの実装

天気情報サイトのダークモードの図

最後に、既に配置済みのThemeControllerコンポーネントに実際にテーマ切り替え機能を実装していきます。調べたところ、next-themesというライブラリを使えば簡単にテーマ切り替えを実装できることがわかったので、そちらを使っていきます。

daisyUIのテーマを使うので、先にその設定を行っておきます。tailwind.config.tsに以下のように使用するテーマを指定します。(詳しい設定は公式サイトを参照→daisyUI themes

tailwind.config.ts
...

const config: Config = {
  ...
  plugins: [daisyui],
  // 使用テーマを指定([ライトモード、ダークモード、その他]の順)
  daisyui: {
    themes: ["light", "dark"]
  },
};
export default config;

next-themesは以下のコマンドでインストールします。

npm install next-themes

使い方は至ってシンプルです。まず、bodyタグ以下にインポートしたThemeProviderコンポーネントを配置します。この時、next-themesはHTML要素を操作するため、suppressHydrationWarningを指定して警告を抑制します(自分の環境では特になくても動作したのですが、一応、公式ドキュメント通りにします)。

layout.tsx
...

import ThemeController from "./components/theme-controller";
import { ThemeProvider } from "next-themes";

...

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja" suppressHydrationWarning>
      <body className={`${inter.className}`}>
        <ThemeProvider>
          <div className="navbar border-b px-10">
            {/* 中略 */}
          </div>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

次に、トグルボタンであるThemeControllerコンポーネントに、useThemeフックを使ってテーマ切り替え機能を付与します。フックを使用するので、use clientディレクティブを記述してクライアントコンポーネントにしています。

components/theme-controller.tsx
"use client";

import { useTheme } from "next-themes";

export default function ThemeController() {
  const { setTheme } = useTheme();

  return (
    <label className="flex cursor-pointer gap-2">
      <svg
        ...
      </svg>
      <input
        type="checkbox"
        value="synthwave"
        className="toggle theme-controller"
        onChange={
          (e) => {
            if (e.target.checked) setTheme("dark");
            else setTheme("light");
          }
        } 
      />
      <svg
        ...
      </svg>
    </label>
  );
}

onChange属性にイベントハンドラとして、setTheme()を使ってテーマを切り替える関数を渡しています。これにより、トグルボタンが押されるとhtmlタグのdata-theme属性が書き換えられ、テーマを切り替えることができます(ブラウザの検証ツールで確認可能です)。

検証ツール内のdata-theme属性の表示

おわりに

ダークモードのままページをリロードした場合、画面のテーマとトグルボタンが一致しないといったバグもあるのですが、サンプルページなのでこちらはひとまず放置しておきます。

とは言え、daisyUIのようにコンポーネントがあらかじめ用意されていると、実装がかなり楽になると実感できました。

次回の記事↓

Spotify Web APIのApp作成とアクセストークンの取得 ― ReactでWebアプリ開発〈3〉