概要
framer-motion(現: Motion)を使用したスクロールトリガーアニメーションがモバイル(特にiOS Safari)で動作しない問題と、その解決策についてのドキュメント。
Note: framer-motionは現在 Motion としてリブランドされ、ドキュメントは motion.dev に移行しています。
発生した問題
-
症状:
useInViewフックを使用したアニメーションがモバイルデバイスで発火しない - 影響範囲: iOS Safari、一部のAndroidブラウザ
- 発生コンポーネント: Vision.tsx(カードの文字アニメーション)
useInView vs whileInView の違い
| 項目 |
useInView (Hook) |
whileInView (Prop) |
|---|---|---|
| タイプ | Reactフック | motion要素のプロパティ |
| サイズ | 0.6kb | motion coreに含まれる |
| 戻り値 | boolean | - |
| パフォーマンス | Reactのre-renderが発生 | re-renderなしで最適化 |
| 用途 | 状態管理、副作用 | 宣言的アニメーション |
使い分けのベストプラクティス
| ユースケース | 推奨アプローチ |
|---|---|
| motion要素のシンプルなアニメーション |
whileInView(パフォーマンス優位) |
| 副作用やカスタムロジックの実行 |
useInView + useEffect
|
| 非motion要素のアニメーション | useInView |
| 条件付きレンダリングにboolean状態が必要 | useInView |
原因分析
1. useInViewフックの信頼性問題
useInViewフックはIntersection Observer APIを使用していますが、以下の問題が報告されている:
- モバイルブラウザでの実装差異
- ref転送が正しく機能しない場合がある
- 子コンポーネント内での使用時に親との競合が発生
2. marginオプションの問題
// 問題のあるコード
const isInView = useInView(ref, { once: true, margin: '-100px' })
-
margin: '-100px'は要素がビューポートに100px以上入らないとトリガーされない - スマホの小さい画面では、この条件を満たさない場合がある
3. ラッパーコンポーネントとの競合
TiltCardのようなラッパーコンポーネントがタッチデバイスで異なるレンダリングをする場合、refの挙動が変わる可能性がある。
解決策
推奨: whileInViewプロパティを使用
useInViewフックの代わりに、motion要素のwhileInViewプロパティを使用する。
Before(問題のあるコード)
import { motion, useInView } from 'framer-motion'
import { useRef } from 'react'
function Component() {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-100px' })
return (
<motion.div
ref={ref}
initial={{ opacity: 0 }}
animate={isInView ? { opacity: 1 } : {}}
>
コンテンツ
</motion.div>
)
}
After(解決策)
import { motion } from 'framer-motion'
function Component() {
return (
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
>
コンテンツ
</motion.div>
)
}
whileInViewのメリット
- パフォーマンス最適化: Reactのre-renderを発生させずにアニメーション
- ref不要: 明示的なref転送が不要
- シンプル: コードが簡潔になる
- 信頼性: モバイルでの動作がより安定
viewportオプション詳細
| オプション | 型 | デフォルト | 説明 |
|---|---|---|---|
once |
boolean |
false |
trueの場合、一度だけアニメーション実行 |
amount |
"some" | "all" | number
|
"some" |
要素の何割が見えたらトリガーするか |
margin |
string |
"0px" |
ビューポートのマージン(CSS形式) |
root |
RefObject |
window |
スクロール検知の対象要素 |
amountオプションの詳細
// 要素の一部が見えたらトリガー(デフォルト)
viewport={{ amount: "some" }}
// 要素の全体が見えたらトリガー
viewport={{ amount: "all" }}
// 要素の50%が見えたらトリガー
viewport={{ amount: 0.5 }}
marginの使い方
// 単一の値(全方向)
viewport={{ margin: "100px" }}
// 複数の値(top right bottom left)
viewport={{ margin: "0px -20px 0px 100px" }}
// モバイル対応の推奨値
viewport={{ once: true, margin: "0px" }}
カスタムスクロールコンテナ
function Component() {
const scrollRef = useRef(null)
return (
<div ref={scrollRef} style={{ overflow: "scroll", height: "400px" }}>
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ root: scrollRef }}
/>
</div>
)
}
コールバック関数
ビューポートへの出入りを検知してカスタムロジックを実行できます。
<motion.div
onViewportEnter={(entry) => {
console.log("要素がビューポートに入りました", entry)
}}
onViewportLeave={(entry) => {
console.log("要素がビューポートから出ました", entry)
}}
/>
実装パターン
variants と組み合わせる場合
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
const itemVariants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6 },
},
}
function Component() {
return (
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
<motion.div variants={itemVariants}>子要素1</motion.div>
<motion.div variants={itemVariants}>子要素2</motion.div>
</motion.div>
)
}
文字単位のアニメーション
function AnimatedCharacters({
text,
baseDelay = 0,
}: {
text: string
baseDelay?: number
}) {
return (
<>
{text.split('').map((char, i) => (
<motion.span
key={i}
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: baseDelay + i * 0.02, duration: 0.1 }}
>
{char}
</motion.span>
))}
</>
)
}
制限事項
repeat: Infinityとの併用不可
whileInViewはtransition: { repeat: Infinity }と組み合わせて使用できません。
// これは動作しない
<motion.div
whileInView={{ rotate: 360 }}
transition={{ repeat: Infinity }} // ❌
/>
回避策: onViewportEnter/onViewportLeaveコールバックとuseStateを使用
function InfiniteAnimation() {
const [isInView, setIsInView] = useState(false)
return (
<motion.div
onViewportEnter={() => setIsInView(true)}
onViewportLeave={() => setIsInView(false)}
animate={isInView ? { rotate: 360 } : {}}
transition={isInView ? { repeat: Infinity, duration: 2 } : {}}
/>
)
}
トラブルシューティング チェックリスト
モバイルでスクロールアニメーションが動作しない場合:
-
useInViewフックを使用していないか確認 -
whileInViewプロパティに変更する -
marginが負の値になっていないか確認(margin: "0px"を推奨) -
amountの値が厳しすぎないか確認 - ラッパーコンポーネントがrefを正しく転送しているか確認
-
viewport={{ once: true }}を設定しているか確認 - iOS Safariでテストしているか確認
参考リンク
公式ドキュメント(motion.dev)
GitHub Issues
- Issue #2346 - useInView() not triggered
- Issue #2459 - Safari compatibility
- Issue #3079 - whileInView soft navigation
参考記事
- Create Beautiful Scroll Animations Using Framer Motion - DEV Community
- Implementing React scroll animations with Framer Motion - LogRocket Blog
- Framer-Motion: New And Underestimated Features - Shakuro
更新履歴
| 日付 | 内容 |
|---|---|
| 2026-01-01 | 初版作成 |
| 2026-01-01 | Web検索による情報検証、useInView vs whileInViewの比較追加、viewportオプション詳細追加、制限事項追加、参考リンク更新 |