Nest.js Navbar 実装

2023-05-15

Next.js

TailwindCSS

ゴール

https://minimal-nextjs-portfolio-website.vercel.app/ 上記URLのNavbar

  • 基本的な構造をコーディングする

components > Navbar > Navbar.tsx

import Link from "next/link"; import React from "react"; export const Navbar = () => { return ( <header> <div className="w-full flex justify-between items-center"> <nav className="flex items-cneter justify-center"> <Link href="/" className="mr-4">Home</Link> <Link href="/" className="mx-4">Home</Link> <Link href="/" className="mx-4">Home</Link> <Link href="/" className="ml-4">Home</Link> </nav> <nav className="flex items-cneter justify-center"> <Link href="/" className="mr-3">Twitter</Link> <Link href="/" className="mx-3">Twitter</Link> <Link href="/" className="mx-3">Twitter</Link> <Link href="/" className="mx-3">Twitter</Link> </nav> </div> </header> ); }

  • サイト内リンクをコンポーネント化

components > Navbar > Navbar.tsx

import Link from "next/link"; import { useRouter } from "next/router"; import React from "react"; const CustomLink = ({ href, title, className = ""}) => { const router = useRouter(); return ( <Link href={href} className={`${className} relative group`}> {title} <span className={` h-[1px] inline-block bg-gray-400 absolute left-0 -bottom-0.5 group-hover:w-full transition-[width] ease duration-300 ${router.asPath === href ? "w-full" : "w-0"} dark:bg-dark `}> &nbsp; </span> </Link> ); }; export const Navbar = () => { return ( <header className="w-full px-32 py-8 font-medium flex items-center justify-between relative z-10 lg:px-16 md:px-12 sm:px-8 "> <div className="w-full flex justify-between items-center"> <nav className="flex items-cneter justify-center"> <CustomLink href="/" title="Home" className="mr-4"></CustomLink> <CustomLink href="/About" title="About" className="mx-4"></CustomLink> <CustomLink href="/Projects" title="Projects" className="mx-4"></CustomLink> <CustomLink href="/Blog" title="Blog" className="ml-4"></CustomLink> </nav> ~~~ 省略

spanタグCSS解説 Linkタグrelativeに指定し、spanタグにabsolute指定で位置調整 Linkタグにgroup指定し、Linkタグホバーでspanタグをwidth: 100%;にする transition-[width]で始点からwidth最大値までのトランジションに設定 easeで滑らかに、durationで遅延させる

${router.asPath === href ? "w-full" : "w-0"}

現在のURLがPropsのhrefと一致する場合にw-fullを返す

  • サイト外リンクにアイコンを適用する
<details> <summary>svgについて</summary>

そもそもsvgは拡大縮小しても崩れない画像

svgサイズ調整難しい…

aタグに対してwidth: 24px;指定 svgの最大幅になる(w-full)

不明点

viewBox, width, height

CSSでもサイジング可能

ここではw-full, h-auto指定なのでaタグに依存(w-6)

よってwidth, height無視

viewBoxとは?svgの絶対値を決める

viewBox="0 0 100 100”とし

width, heightはsvgの表示領域を指定することでviewBoxをもとに計算される

width = “100”, height = “100” なら 1単位1px

width = “300”, height = “200” なら 1単位2px

svgはpathでviewBoxに対してのサイズ感が決まっている(pathいじらない場合)

中身いじれば表示領域最大限に表示できる

上記を踏まえて、サイズ調整方法は

要素が影響の受けるwidth指定を変更(height-auto)

viewBoxとwidth, heightをいじって比率を大きくする(最大要素が決まっている場合は??)

そもそものpathサイズを調整する

width, height 1rem指定はフォントサイズで変更させるため?

ややこしくなる 例えば1remだとすると最上位のhtmlタグに指定されたfont-sizeによって変わってくるので フォントサイズ(1rem)に対して最適なviewBoxの絶対値を探す必要があるってことか。

まあ、パスのサイズはいい感じに配置される前提として、CSSなどで指定したサイズに表示領域を合わせていくって感じなんだろうな。

CSSでサイズ指定してそれに最適なviewBox, width, heightに合わせてあとはCSSでサイズ調整ってことでなんとかなりそう。 例えばviewBox = “0, 0, 48, 48” width, height = 1rem 24pxとすると1単位0.5pxになるので48で24pxになる あとはCSSで拡大縮小って感じで調整する

</details>

npm install framer-motion 実行

svg用意して下記(SVGR)でJSXに変換。icons8で用意

https://react-svgr.com/playground/

components > Icons > Icons.tsx 作成

import React, { SVGProps } from "react" interface GithubIconProps extends SVGProps<SVGSVGElement> { className?: string; } export const GithubIcon: React.FC<GithubIconProps> = ({ className, ...rest }) => ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="1em" height="1em" {...rest} className={`w-full h-auto ${className}`} > ~~~ 省略 </svg> );

Githubだとこんな感じ

Navbar.tsxで読み込み

import { motion } from "framer-motion"; // アニメーションライブラリ import { // アイコンコンポーネント GithubIcon, } from "../Icons/Icons"; ~~~ 省略 export const Navbar = () => { return ( <header className="w-full px-32 py-8 font-medium flex items-center justify-between dark:text-light relative z-10 lg:px-16 md:px-12 sm:px-8 "> <div className="w-full flex justify-between items-center"> <nav className="flex items-cneter justify-center"> <CustomLink href="/" title="Home" className="mr-4"></CustomLink> <CustomLink href="/About" title="About" className="mx-4"></CustomLink> <CustomLink href="/Projects" title="Projects" className="mx-4"></CustomLink> <CustomLink href="/Blog" title="Blog" className="ml-4"></CustomLink> </nav> <nav className="flex items-cneter justify-center"> {/* 追加 */} <motion. href="/" target={"_blank"} whileHover={{ y: -2 }} whileTap={{ scale: 0.9 }} className="w-8 mr-3" // aタグに対してwidth: 24px;指定 svgの最大幅になる(w-full) > <GithubIcon /> </motion.a>

  • ダーク・ライト切り替えボタンの実装

Stateでモードを管理する

components > hooks > useThemeSwitcher.tsxの作成

import React, { useEffect, useState } from "react"; const useThemeSwitcher = () => { const preferDarkQuery = "(prefers-color-scheme: dark)"; // ユーザがダークモードを好むかどうか const [mode, setMode] = useState(""); useEffect(() => { // 初期マウント const mediaQuery = window.matchMedia(preferDarkQuery); const usePref = window.localStorage.getItem("theme"); const handleChange = () => { if(usePref){ // 既存のテーマ設定があれば let check = usePref === "dark" ? "dark" : "light"; setMode(check); if(check==="dark"){ document.documentElement.classList.add("dark") } else { document.documentElement.classList.remove("dark") } } else { // テーマ設定がない場合 let check = mediaQuery.matches ? "dark" : "light"; // mediaQuery参照 setMode(check); // modeに格納 window.localStorage.setItem( "theme", check ); if(check==="dark"){ document.documentElement.classList.add("dark") } else { document.documentElement.classList.remove("dark") } } } handleChange(); mediaQuery.addEventListener("change", handleChange) // preferDarkQueryに変更があった場合に実行 return () => mediaQuery.removeEventListener("change", handleChange) // アンマウント時のクリーンアップ }, []) useEffect(() => { // mode更新時の処理 if(mode === "dark"){ window.localStorage.setItem("theme", "dark"); document.documentElement.classList.add("dark") } if(mode === "light"){ window.localStorage.setItem("theme", "light"); document.documentElement.classList.remove("dark") } }, [mode]) return [mode, setMode] } export default useThemeSwitcher

darkモードの有効化

tailwind.config.js

参考 https://zenn.dev/azukiazusa/articles/bee71756d66679

/** @type {import('tailwindcss').Config} */ module.exports = { content: [ './pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}', './app/**/*.{js,ts,jsx,tsx}', ], 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: [], darkMode: 'class', // 追加 dark:css が利用可能に }

Navbar.tsx

~~~ 省略 import { // アイコンコンポーネント TwitterIcon, GithubIcon, SunIcon, // 追加 MoonIcon, // 追加 } from "../Icons/Icons"; import useThemeSwitcher from "../hooks/useThemeSwitcher"; // 追加 ~~~ 省略 export const Navbar = () => { const [mode, setMode] = useThemeSwitcher(); ~~~ 省略 <motion.a href="/" target={"_blank"} whileHover={{ y: -2 }} whileTap={{ scale: 0.9 }} className="w-8 mx-3" > <GithubIcon /> </motion.a> // 追加 <button onClick={() => setMode(mode === "light" ? "dark" : "light")} className={`w-8 h-8 ml-5 rounded-full p-1 ease ${mode === "light" ? "bg-black text-white" : "bg-white text-black"}`} > {mode === "light" ? ( <SunIcon className={"fill-dark"} /> ) : ( <MoonIcon className="fill-dark" /> )} </button> ~~~ 省略

dark時のcss処理

components > Layout.tsx

~~~ 省略 export const Layout = ({ children }: MyComponentProps) => { return ( // CSS追加 <div className="dark:bg-black dark:text-white transition-colors duration-500"> <Navbar /> {children} </div> ); }

  • ロゴの実装
  • レスポンシブ対応

既存メニューのスクリーン制御 Navbar.tsx

min指定(通常)でlg:flex, hidden追加

<header className="w-full px-32 py-8 font-medium flex items-center justify-between relative z-10 lg:px-16 md:px-12 sm:px-8 "> <div className="w-full justify-between items-center lg:flex hidden"> </div> </header>

レスポンシブリンクコンポーネントCustomMobileLink作成とメニュー作成

type CustomMobileLinkProps = { href: string; title: string; className: string; toggle: () => void; } const CustomMobileLink = ({ href, title, className = "", toggle }: CustomMobileLinkProps) => { const router = useRouter(); const handleClick = () => { toggle(); router.push(href); }; return ( <button className={`${className} relative group my-2`} onClick={handleClick} > {title} <span className={` h-[1px] inline-block bg-gray-400 absolute left-0 -bottom-0.5 group-hover:w-full transition-[width] ease duration-300 ${router.asPath === href ? "w-full" : "w-0"} dark:bg-gray-400 `}> &nbsp; </span> </button> ); }; // 以下をreturnに追記 <motion.div initial={{ scale: 0, opacity: 0, x: "-50%", y: "-50%" }} animate={{ scale: 1, opacity: 1 }} className="min-w-[90vw] sm:min-w-[70vw] flex flex-col justify-between z-30 items-center fixed top-1/2 left-1/2 translate-x-1/2 -translate-y-1/2 bg-black/80 dark:bg-white/70 rounded-lg backdrop-blur-md py-32" > <nav className="flex items-cneter justify-center"> <CustomMobileLink href="/" title="Home" className="mr-4" toggle={} /> <CustomMobileLink href="/About" title="About" className="mx-4" toggle={} /> <CustomMobileLink href="/Projects" title="Projects" className="mx-4" toggle={} /> <CustomMobileLink href="/Blog" title="Blog" className="ml-4" toggle={} /> </nav> <nav className="flex items-cneter justify-center flex-wrap mt-2"> <motion.a href="/" target={"_blank"} whileHover={{ y: -2 }} whileTap={{ scale: 0.9 }} className="w-8 mr-3" // aタグに対してwidth: 24px;指定 svgの最大幅になる(w-full) > <TwitterIcon /> </motion.a> <motion.a href="/" target={"_blank"} whileHover={{ y: -2 }} whileTap={{ scale: 0.9 }} className="w-8 mx-3" > <GithubIcon /> </motion.a> <button onClick={() => setMode(mode === "light" ? "dark" : "light")} className={`w-8 h-8 ml-5 rounded-full p-1 ease ${mode === "light" ? "bg-black text-white" : "bg-white text-black"}`} > {mode === "light" ? ( <SunIcon className={"fill-dark"} /> ) : ( <MoonIcon className="fill-dark" /> )} </button> </nav> </motion.div>

レスポンシブメニュー開閉ステートを作成

~~~省略 export const Navbar = () => { const [mode, setMode] = useThemeSwitcher(); const [isOpen, setIsOpen] = useState(false); const handleClick = () => { setIsOpen(!isOpen); } return ( ~~~ 省略 {/* // レスポンシブニュー */} {isOpen ? ( <motion.div initial={{ scale: 0, opacity: 0, x: "-50%", y: "-50%" }} animate={{ scale: 1, opacity: 1 }} className="min-w-[90vw] sm:min-w-[70vw] flex flex-col justify-between z-30 items-center fixed top-1/2 left-1/2 translate-x-1/2 -translate-y-1/2 bg-black/80 dark:bg-white/70 rounded-lg backdrop-blur-md py-32" > <nav className="flex items-cneter justify-center"> <CustomMobileLink href="/" title="Home" className="mr-4" toggle={handleClick} /> <CustomMobileLink href="/About" title="About" className="mx-4" toggle={handleClick} /> <CustomMobileLink href="/Projects" title="Projects" className="mx-4" toggle={handleClick} /> <CustomMobileLink href="/Blog" title="Blog" className="ml-4" toggle={handleClick} /> </nav> ~~~ 省略 </motion.div> ) : null} ~~~ 省略

ハンバーガーメニューボタンUIを作成

以下を追記

~~~ 省略 return ( <header className="w-full px-32 py-8 font-medium flex items-center justify-between relative z-10 lg:px-16 md:px-12 sm:px-8 "> <button className="flex-col justify-center items-center lg:hidden" onClick={handleClick}> <span className={`bg-black dark:bg-white block transition-all duration-300 ease-out h-0.5 w-6 rounded-sm ${ isOpen ? "rotate-45 translate-y-1" : "-translate-y-0.5" }`}></span> <span className={`bg-black dark:bg-white block transition-all duration-300 ease-out h-0.5 w-6 rounded-sm my-0.5 ${ isOpen ? "opacity-0" : "opacity-100" }`}></span> <span className={`bg-black dark:bg-white block transition-all duration-300 ease-out h-0.5 w-6 rounded-sm ${ isOpen ? "-rotate-45 -translate-y-1" : "translate-y-0.5" }`}></span> </button> ~~~ 省略
TOP PAGE