この記事について
僕自身は、未経験で入社してようやく業界歴半年とまだまだヒヨっ子ですが、
毎朝日課として 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にはライフサイクルメソッドがないため、終了アニメーションを有効にする必要があるのです。
- コンポーネントがアンマウントされるときに通知し
- アンマウントを操作完了後まで延期できるようにする(例えばアニメーション)。
(公式Docs引用)
AnimatePresence
コンポーネントで囲むことで、
コンポーネント要素が アンマウントされる前に、
モーションコンポーネント
要素(以下の例だと 要素)に定義した終了
アニメーションを実行することができます。
(アンマウント時のアニメーションが必要なケースじゃない場合は、AnimatePresence
コンポーネントを使用する必要はないです)
また、モーションコンポーネント要素は、AnimatePresence
コンポーネントの直接の子孫である必要があります。
// NG(モーションコンポーネントが AnimatePresence コンポーネントの直接の子孫関係にない)
<motion.div
key={router.pathname}
// 省略
>
<AnimatePresence
initial={false}
mode='wait'
>
// 省略
</AnimatePresence>
</motion.div>
では、framer-motionからmotion
とAnimatePresence
をインポートし、
画面遷移のアニメーションを行うコンポーネントを作ります。
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>
)
}
AnimatePresence
にinitial={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>
これで、ページ遷移時のアニメーションを行うコンポーネントが作成できました。
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 />
を先ほど作ったコンポーネントで囲みます。
export default function App({ Component, pageProps }: AppProps) {
return (
<>
// 一部省略
<PageTransition>
<Component {...pageProps} />
</PageTransition>
</>
)
}
完成
↓ このような感じで実装できました。
サクッと慣性スクロールを実装する
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
を用いて状態として持っていますが、
実際はグローバルに状態管理持たせて使う感じになると思います!
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 を読み込みます。
import useSmoothScroll from '../hooks/useSmoothScroll'
export default function App({ Component, pageProps }: AppProps) {
+ useSmoothScroll()
return (
<>
// 省略
</>
)
}
完成
↓ こんな感じでぬるっ〜と慣性をつけたスクロールできるようになりました。
めちゃめちゃ簡単に実装できて良いですね。
最後に
「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/router
のuseRouter
使って魔改造する - ランタイムCSS in JS(styled-components, emotion, etc) を使う
- 勿論、
linaria
のようなゼロランタイム CSS in JS も使えない。 -
Tailwind CSS
では使えるっぽい。(他のCSSフレームワークは分からない...)
0から Next.js + FramerMotion 使って画面遷移のアニメーションを使う場合は、
ランタイム CSS in JS または Tailwind CSS 辺りを採用すると良さげだな〜という話でした。
(自戒の念も込めて)
少しでもこの記事が為になった!と思ったら
いいねボタンを押してもらえると、とっても嬉しいです。
最後まで読んでいただき、ありがとうございましたmm
(誤字や脱字、また技術的な説明とか間違っている所ありましたら、
是非コメントで突っ込んで頂ければ幸いです。)