わかります。「フワッ」や「シュッ」のニュアンスをコードに落とし込むのが我々エンジニアの仕事ですが、これを素のCSS(transitionや@keyframes)だけで再現しようとすると、cubic-bezierの数値調整地獄に陥ったり、コードの可読性が著しく下がったりしがちです。かといって、GSAP(GreenSock)を導入するのはライセンスやファイルサイズの観点で「ちょっと大掛かりすぎるな…」と躊躇するケースも多いはず。
そこで今回、私たちが最近のプロジェクトで「これが最適解だ!」と確信した技術スタック、Next.js (App Router) × Tailwind CSS × Framer Motion の組み合わせについて、現場の知見を余すところなく共有します。
単なる使い方の解説だけでなく、私たちが実際に本番環境で踏み抜いた**「Hydrationエラー(画面が真っ白になる現象)」の回避策**まで深掘りしますので、ぜひ最後までお付き合いください。
なぜ今、Framer Motion × Tailwind CSS なのか?(技術選定の理由)
技術選定において「流行っているから」は理由になりません。私たちがこのスタックを採用した理由は、明確に**「保守性」と「表現力」**のバランスがズバ抜けて良かったからです。
1. Tailwind CSSの「クラス地獄」をアニメーションに持ち込まない
Tailwind CSSは素晴らしいスタイリングツールですが、複雑なアニメーションをclass属性の中に記述しようとすると、可読性が崩壊します。
HTML
<div class="transition-all duration-500 ease-in-out hover:scale-110 hover:rotate-3 opacity-0 animate-fade-in ...">
</div>
Framer Motionを使えば、「見た目のスタイル(Tailwind)」と「動きのロジック(Framer)」を完全に分離できます。これはコードレビューをする際にも非常に有効です。「デザインの変更」なのか「動きの変更」なのかが、一目でわかるからです。
2. 宣言的UIとの相性の良さ
React/Next.jsのような「状態(State)」駆動のフレームワークにおいて、jQuery時代のような「命令的」なアニメーション(elem.style.height = ...と書くようなやり方)はバグの温床になります。
Framer MotionはReactのために作られたライブラリであり、**「コンポーネントがある状態になったら、どうあるべきか」**を宣言するだけで、その間の補間を自動で行ってくれます。これが、開発体験(DX)を劇的に向上させました。
実践:スクロール連動で「ふわっ」と出す基本のキ
では、実際のコードを見ていきましょう。 LP(ランディングページ)で最もよく使われる**「スクロールしたら要素が下から浮き上がる」**実装です。
基本のコンポーネント構成
まず、motion.div コンポーネントを使います。これは通常の div タグにアニメーション機能が生えたものと考えてください。
TypeScript
// components/ScrollRevealCard.tsx
'use client'; // App Routerでは必須
import { motion } from 'framer-motion';
type Props = {
children: React.ReactNode;
className?: string;
};
export const ScrollRevealCard = ({ children, className }: Props) => {
return (
<motion.div
// 1. 初期状態(隠れている状態)
initial={{ opacity: 0, y: 50 }}
// 2. 画面内に入った時の状態(表示される状態)
whileInView={{ opacity: 1, y: 0 }}
// 3. アニメーションの詳細設定
transition={{
duration: 0.6, // 0.6秒かけて
ease: "easeOut", // 減速しながら
delay: 0.2 // 0.2秒待ってから開始
}}
// 4. ビューポートの設定
viewport={{
once: true, // 一度表示されたらアニメーションをリセットしない
amount: 0.5 // 要素の50%が見えたら発火
}}
// Tailwindのクラスもそのまま渡せます
className={className}
>
{children}
</motion.div>
);
};
こだわりのポイント:viewport 設定
ここで特に重要なのが viewport={{ once: true }} です。 デフォルトでは、スクロールアウトして再度スクロールインすると、アニメーションが再実行されます。LPのようなストーリー性のあるページでは、何度も要素が出たり消えたりすると**「動きがうるさい」**と感じられ、離脱率につながる可能性があります。 「一度だけ上品に見せる」。これがStudio Puff流のこだわりです。
現場のリアル:複雑な「Stagger(時間差)」演出の実装
次に、デザイナーが大好きな**「リスト項目がポン、ポン、ポンと順番に出てくる」**演出(Stagger Animation)の実装です。 これをCSSだけでやろうとすると、nth-child ごとに animation-delay を計算して設定する必要があり、項目数が増減するたびに修正が発生してしまいます。
Framer Motionなら、親要素に設定を書くだけで自動制御できます。
【ここにリストアイテムが左から右へ順番に流れるように表示されるGIF画像を貼る】
TypeScript
// components/StaggerList.tsx
'use client';
import { motion } from 'framer-motion';
// アニメーション定義(Variants)を外に出して可読性アップ
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2, // 子要素を0.2秒ずらして実行!
delayChildren: 0.3, // 最初の開始を0.3秒遅らせる
},
},
};
const itemVariants = {
hidden: { opacity: 0, x: -20 },
visible: {
opacity: 1,
x: 0,
transition: {
type: "spring", // バネのような物理挙動
stiffness: 120
}
},
};
export const StaggerList = ({ items }: { items: string[] }) => {
return (
<motion.ul
className="space-y-4"
variants={containerVariants} // 親の設定
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
{items.map((item, index) => (
<motion.li
key={index}
variants={itemVariants} // 子は親の状態(hidden/visible)を受け継ぐ
className="p-4 bg-white shadow-md rounded-lg"
>
{item}
</motion.li>
))}
</motion.ul>
);
};
こだわりのポイント:type: "spring"
ここで ease ではなく type: "spring" を指定している点に注目してください。 線形のアニメーション(Linear)や単なるイージング(Ease)は「デジタルの動き」になりがちですが、Spring(バネ)を指定することで、**物理法則に基づいた「重み」や「反動」を表現できます。 「シュッ」と止まるのではなく、極小のオーバーシュート(行き過ぎて戻る動き)が含まれることで、ユーザー体験(UX)に「触っている感」**を生み出せるのです。
【重要】私たちが踏み抜いた「ハマりポイント」と解決策
さて、ここからが本題かもしれません。 公式ドキュメント通りに実装しても、実際の開発現場(特にNext.js環境)では予期せぬエラーに遭遇します。私たちが実際に直面し、解決に時間を費やした2つの事例を共有します。
1. 悪夢の「Hydration failed」エラー
App Router環境下で開発を進めていたある日、コンソールに不吉な赤い文字が大量に出力されました。
- 現象: 画面が一瞬チラついた後、レイアウトが崩れたり、インタラクションが効かなくなったりする。
- 原因: Framer Motionがクライアントサイド(ブラウザ)で初期化される際の状態と、サーバーサイドレンダリング(SSR)で生成されたHTMLの状態が一致していないこと。特に、ランダムな値をアニメーションに使ったり、ブラウザ幅(
window.innerWidth)を初期値に使ったりした場合に発生します。
解決策:useEffect でのマウント判定
この問題の確実な回避策は、「コンポーネントがマウントされた(クライアントサイドであることが確定した)後」にアニメーションを有効にすることです。
TypeScript
// hooks/useIsMounted.ts
import { useEffect, useState } from 'react';
export const useIsMounted = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted;
};
TypeScript
// コンポーネント側での使用例
const isMounted = useIsMounted();
if (!isMounted) {
// SSR時はアニメーションなしの静的な状態、またはスケルトンを返す
return <div className="opacity-0">Loading...</div>;
}
return (
<motion.div initial={{ opacity: 0 }} ... >
{/* コンテンツ */}
</motion.div>
);
この「マウント判定」を入れる一手間で、不可解なSSRエラーの9割は解消されました。地味ですが、安定したサイト運用には欠かせない処理です。
2. layout プロパティによる画像の歪み
Framer Motionには layout という魔法のプロパティがあります。これを付けるだけで、レイアウト変更(リストの並び替えなど)時にスムーズにアニメーションしてくれる素晴らしい機能です。
しかし、img タグや border-radius を持つ要素に layout を安易に付与すると、アニメーション中に画像が**「グニャッ」と歪んで引き伸ばされる現象**が発生しました。これは、ブラウザのコンポジタレイヤーの処理上、変形(Transform)として処理されるために起こります。
- 解決策: 子要素(画像など)にも
layoutプロパティを付与するか、borderRadiusをスタイルではなくmotionのプロパティとして管理する。
TypeScript
// 歪みを防ぐ書き方
<motion.div layout style={{ borderRadius: 20 }}>
<motion.img layout src="..." />
</motion.div>
「便利だからとりあえず付けておこう」は禁物です。必ず実機で挙動を確認しましょう。
まとめ:技術でデザインを加速させる
今回は、Next.js × Tailwind CSS × Framer Motion を用いた、モダンで保守性の高いアニメーション実装についてご紹介しました。
- Tailwind CSS でスタイルを管理し、
- Framer Motion で動きのロジックを分離・宣言し、
- Next.js (SSR) 特有のハイドレーションエラーを適切に回避する。
このフローを確立して以来、Studio Puffではデザイナーからの「ここ、いい感じに動かせます?」というオーダーに対して、**「もちろんです(しかも工数はそんなにかかりませんよ)」**と自信を持って答えられるようになりました。
エンジニアが「動き」の引き出しを多く持っていることは、デザイナーの創造性を刺激し、結果としてWebサイトのクオリティを底上げします。
ぜひ、皆さんの次のプロジェクトでも、まずはファーストビュー(FV)のキャッチコピー1行から、Framer Motionを導入してみてください。サイトが「生きている」ように感じられる瞬間が、きっとあるはずです。