0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

framer-motion モバイルアニメーション対応ナレッジ

Posted at

概要

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転送が正しく機能しない場合がある
  • 子コンポーネント内での使用時に親との競合が発生

参考: GitHub Issue #2346

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のメリット

  1. パフォーマンス最適化: Reactのre-renderを発生させずにアニメーション
  2. ref不要: 明示的なref転送が不要
  3. シンプル: コードが簡潔になる
  4. 信頼性: モバイルでの動作がより安定

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との併用不可

whileInViewtransition: { 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

参考記事


更新履歴

日付 内容
2026-01-01 初版作成
2026-01-01 Web検索による情報検証、useInView vs whileInViewの比較追加、viewportオプション詳細追加、制限事項追加、参考リンク更新
0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?