LoginSignup
25
13

More than 1 year has passed since last update.

[Next.js + FramerMotion + Lenis]シームレスな画面遷移と慣性スクロールをサクッと導入する

Last updated at Posted at 2023-01-05

この記事について

僕自身は、未経験で入社してようやく業界歴半年とまだまだヒヨっ子ですが、
毎朝日課として Awwwards や Dribbble などの
世界中のクールなポートフォリオやサイトのデザインを見て、
感性のアップデートに勤しんでいます。(結果に出ているかは別として。)

また、このようなWebアワード(FWA や CSS Design Awardも含む) に
ノミネートされるポートフォリオやwebサイトではかなりの頻度で
慣性スクロールであったり、シームレスな画面遷移のような
動きのあるインタラクティブな演出がされている事が多いです。
(慣性スクロールは、ユーザビリティを大きく損いかねない実装 & 好みが大きく分かれる部分ではあるのですが、今回はよくある演出ということで実装します。)

今回は、
デザインツールを提供している Framer 社が開発元である
React用アニメーションライブラリ Framer-motion
Studio Freight社が開発している新進気鋭なライブラリ Lenis を用いて、
Next.js でサクッとインタラクティブな演出を導入します。

バージョン

Next.js v13.0.5
framer-motion v7.6.19
@studio-freight/lenis v0.2.26

自分は TypeScript と npm 使うので
以下の通りで、パパッと開発環境を作っちゃいます。(この辺りは好みでどうぞ)

npx create-next-app@latest --ts --use-npm
npm i framer-motion @studio-freight/lenis

今回特段スタイリング周りは気にしませんが、
Framer Motionを使ってNext.jsで画面遷移のアニメーションを作成する際は、
CSS Modules を使用するのは避けたほうが良いです。理由は最後に書きます

サクッとシームレスな画面遷移を実装する

画面遷移時のアニメーションを作成したいので、
Framer Motionを用いて、
作成するアニメーションがルート変更時にのみ実行するように実装していきます。

AnimatePresence

AnimatePresence | Framer for Developers

コンポーネントがReactのツリーから削除されたときにアニメートする。
AnimatePresenceは、コンポーネントがReactのツリーから削除されたときに、アニメーションで表示するようにします。
Reactにはライフサイクルメソッドがないため、終了アニメーションを有効にする必要があるのです。

  1. コンポーネントがアンマウントされるときに通知し
  2. アンマウントを操作完了後まで延期できるようにする(例えばアニメーション)。
    (公式Docs引用)

AnimatePresence コンポーネントで囲むことで、
コンポーネント要素が アンマウントされる前に、
モーションコンポーネント要素(以下の例だと 要素)に定義した終了アニメーションを実行することができます。
(アンマウント時のアニメーションが必要なケースじゃない場合は、AnimatePresence コンポーネントを使用する必要はないです)

また、モーションコンポーネント要素は、AnimatePresenceコンポーネントの直接の子孫である必要があります。

// NG(モーションコンポーネントが AnimatePresence コンポーネントの直接の子孫関係にない)
<motion.div
		key={router.pathname}
        // 省略
>
		<AnimatePresence
			initial={false}
			mode='wait'
		>
		// 省略
		</AnimatePresence>
</motion.div>

では、framer-motionからmotionAnimatePresenceをインポートし、
画面遷移のアニメーションを行うコンポーネントを作ります。

PageTransition.tsx
import { motion, AnimatePresence } from 'framer-motion'
import { useRouter } from 'next/router'

interface TransitionProps {
	children: React.ReactNode
}

const PageTransition = ({ children }: TransitionProps) => {
	const router = useRouter()

	return (
			<AnimatePresence
				initial={false}
				mode='wait'
			>
				<motion.div
					key={router.asPath}
				>
					{children}
				</motion.div>
			</AnimatePresence>
	)
}

AnimatePresenceinitial={false}を設定する事で、
AnimatePresence が最初にロードされたときに存在するコンポーネントは、アニメーションの
状態から開始されるようにします。

またmode="wait" とすることで、
コンポーネントのアンマウントを待ち、
アンマウント時のアニメーションと遷移先のマウント時のアニメーションが同時に発生しないようにしています。

直接の子供(モーションコンポーネント要素)は、
AnimatePresenceがツリー内の存在を追跡できるように、
それぞれ一意の key Propsを持つ必要がある
ため、
useRouter の asPath を用いて、ブラウザに表示されるパスの文字列を一意に割り当てています。

アニメーションの動作を定義

次に、Variantsを用いてアニメーションの動作を定義していきます。

参照 : Animation | Framer for Developers


const variants = {
	center: {
		clipPath: 'inset(50% round 50%)',
		transition: {
			duration: 0.6,
			ease: [0.83, 0.67, 0.67, 0.17],
		},
	},
	filled: {
		clipPath: 'inset(0% round 0%)',
		transition: {
			duration: 0.55,
			ease: 'circOut',
		},
	},
}

定義したバリアントのPropsをモーションコンポーネント要素に渡すことができるので、
アニメーションの設定を行います。

<motion.div
    key={router.pathname}
+   variants={variants}
+   initial='center' // 初期状態のアニメーション。 false を設定すると、マウントアニメーションを無効にする。
+   animate='filled' // マウント時のアニメーション。
+   exit='center' // アンマウント時のアニメーション(有効にするために AnimatePresence の最初のアニメート可能な子である必要がある)
>
	{children}
</motion.div>

これで、ページ遷移時のアニメーションを行うコンポーネントが作成できました。

PageTransition.tsx
const variants = {
	center: {
		clipPath: 'inset(50% round 50%)',
		transition: {
			duration: 0.6,
			ease: [0.83, 0.67, 0.67, 0.17],
		},
	},
	filled: {
		clipPath: 'inset(0% round 0%)',
		transition: {
			duration: 0.55,
			ease: 'circOut',
		},
	},
}

const PageTransition = ({ children }: TransitionProps) => {
	const router = useRouter()

	return (
		<div className={styles.transition_container}>
			<AnimatePresence
				initial={false}
				mode='wait'
			>
				<motion.div
					key={router.pathname}
					variants={variants}
					initial='center' 
					animate='filled'
					exit='center'
				>
					{children}
				</motion.div>
			</AnimatePresence>
		</div>
	)
}

export default PageTransition

_app.tsx<Component /> を先ほど作ったコンポーネントで囲みます。

_app.tsx
export default function App({ Component, pageProps }: AppProps) {
	return (
		<>
            // 一部省略
			<PageTransition>
				<Component {...pageProps} />
			</PageTransition>
		</>
	)
}

完成

↓ このような感じで実装できました。

pageTransition.gif

サクッと慣性スクロールを実装する

Lenisとは

慣性スクロールを実装するライブラリとして有名なのは、
ASScroll(スター数 873)や Locomotive-scroll (スター 5.9k) などが挙げられます。

Locomotive Scroll は慣性スクロールに加えて、
pallaraxの実装などもとても手軽に出来るライブラリなのですが、
WebGLの描画との同期がさせづらいという問題があります。

https://github.com/locomotivemtl/locomotive-scroll/issues/154

ライブラリ内部のrequestAnimationFrameに依存しており、
スクロール連動以外で発生する WebGLの描画は
別のrequestAnimationFrameループにおいて render するため、
座標更新やフレームの整合性がとりづらいのかな?と思います。
(この辺りの有識者いらっしゃいましたら、是非教えてください🙏)

そんな中、
今回採用するのは 現在開発中真っ只中のライブラリ Lenis となっております。

まだWIPで、APIは新しいリリースで変わるかもしれない🚧。

メジャーバージョンのリリースは未だされておらず、公式も上記のコメントをしているため、
プロダクトに入れるのはまだまだ時期尚早かと思いますが、
既存のSmooth Scrollライブラリの中でも格段に使い易いものになっていそうです。(以下図参照)

機能 locomotive-scroll scrollsmoother Lenis
Native scrollbar
Native scroll inputs
Normalize scroll experience
Accessibility
CSS Sticky
IntsersectionObserver
Open source
Built-in animation system
Size (gzip) 12.33KB 26.08KB 2.13KB

参照 : https://github.com/studio-freight/lenis#features

かなり嬉しい機能としては、
CSS Sticky に対応している点ですね。

Locomotive Scroll または ASScroll どちらも、
スムーススクロールを行う要素の中で、position: sticky をつけると意図しない挙動をしてしまいます。
正しい挙動にするためには、スムーススクロールを行う要素の外側に出したりと、面倒な工夫が必要でした。
インタラクティブな演出をつけるにあたり、position: stickyがデフォルトで扱えるのは非常に便利です。

また、Lenis は Built-in animation system は対応していないので、
あくまで慣性スクロールを実装する用途のみで使用することになりますが、
上記で紹介した ASScroll も同様ですし、parallax は簡単に実装できるので
そこまで大きなデメリットではなさそうです。

実装

スムーススクロールを実行する Custom Hook を公式のDocsを元に作成していきます。
(requestAnimationFrameに関する処理もまとめて書いてますが、こちらの処理もカスタムフックとして切り出した方が良きですね。)

インスタンスをuseStateを用いて状態として持っていますが、
実際はグローバルに状態管理持たせて使う感じになると思います!

use-smooth-scroll.ts
import Lenis from '@studio-freight/lenis'

const useSmoothScroll = () => {

	const [lenis, setLenis] = useState<Lenis | null>()
	const reqIdRef = useRef<ReturnType<typeof requestAnimationFrame>>()

	useEffect(() => {
		const animate = (time: DOMHighResTimeStamp) => {
			lenis?.raf(time)
			reqIdRef.current = requestAnimationFrame(animate)
		}
		reqIdRef.current = requestAnimationFrame(animate)
		return () => cancelAnimationFrame(reqIdRef.current as number)
	}, [lenis])

	useLayoutEffect(() => {
		// インスタンスを生成
		const lenis = new Lenis({
			duration: 1.2, // アニメーションの継続時間
			easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // 特定の値の変化率を指定
			direction: 'vertical', // 方向
			gestureDirection: 'vertical',
			smooth: true, // スムーススクロールの有効・無効を設定
			smoothTouch: false, // タッチスクロール時のスムーススクロールの有効・無効を設定。(タッチデバイス本来の滑らかさを模倣することは不可能であるため、デフォルト無効)
			touchMultiplier: 2,
		})
		setLenis(lenis)

		return () => {
			lenis.destroy()
			setLenis(null)
		}
	}, [])
}

export default useSmoothScroll

スムーススクロールを全体に反映させるために、_app.tsx にて作成した Custom Hook を読み込みます。

_app.tsx
import useSmoothScroll from '../hooks/useSmoothScroll'

export default function App({ Component, pageProps }: AppProps) {
+   useSmoothScroll()
	return (
		<>
        // 省略
		</>
	)
}

完成

↓ こんな感じでぬるっ〜と慣性をつけたスクロールできるようになりました。
めちゃめちゃ簡単に実装できて良いですね。
ezgif.com-gif-maker.gif

最後に

「Framer Motionを使ってNext.jsで画面遷移のアニメーションを作成する際は、
CSS Modules を使用するのは避けたほうが良いです。」

と冒頭で申しましたが、それは何故かと言うと
実運用環境では、
ルート変更時にDOMが削除される前に先にCSSモジュールのスタイルが削除されてしまうからです。

試しに作ったデモサイトは以下になります。

実際に動かしてみると分かるんですけど、
Homeから別のページに切り替わる際に、
DOMが削除される前にCSSが崩れていることが確認できます。
(自分もデプロイしてから初めて気づきました、、、)

CSS モジュールのスタイルが、実運用環境では DOM が削除された後ではなく、next/link をクリックすると直ちに削除される。このため、ページ遷移時にコンポーネントにスタイルが全く付与されない。この問題は、開発モードでは発生しません。

issueを見た感じだと、現状CSSモジュールを用いた場合の解決策はなさそうです、、

  • CSSモジュール、またCSSモジュール(Sass)を使った場合の解決策は特にない
    👉 _app.tsx で 必要なCSSモジュールを読み込めばいけるが、CSSモジュールの意義がなくなりそう
  • next/routeruseRouter 使って魔改造する
  • ランタイムCSS in JS(styled-components, emotion, etc) を使う
  • 勿論、linaria のようなゼロランタイム CSS in JS も使えない。
  • Tailwind CSS では使えるっぽい。(他のCSSフレームワークは分からない...)

0から Next.js + FramerMotion 使って画面遷移のアニメーションを使う場合は、
ランタイム CSS in JS または Tailwind CSS 辺りを採用すると良さげだな〜という話でした。
(自戒の念も込めて)

少しでもこの記事が為になった!と思ったら
いいねボタンを押してもらえると、とっても嬉しいです。 

最後まで読んでいただき、ありがとうございましたmm

(誤字や脱字、また技術的な説明とか間違っている所ありましたら、
是非コメントで突っ込んで頂ければ幸いです。)

25
13
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
13