🤹‍♂️

Reactでお手軽アニメーション実装

2023/06/07に公開1

今回は、Reactのアニメーションの方法について紹介します。
WebGLを使うようなダイナミックのもではなく、Webサイトを制作する上でよく使うアニメーションや、要素がビューポートに入ると発火するなど、あるあるだけど意外と実装めんどくさい...なものを簡単に実装できる方法を紹介します!
また、それらを組み合わせて自作ライブラリも作ってみたのでぜひチェックしてみてください🙌

Intersection Observer API

言わずと知れたJavaScript標準の交差監視APIですね。
Reactでは、ライブラリを使った方が楽に実装できるため、今回はサンプルコードと基本的な解説のみ行います。

ObserverComponent.tsx
import React, { ReactNode, useEffect, useRef, useState } from "react";

const ObserverComponent = ({ children }: { children: ReactNode }) => {
  const ref = useRef<HTMLDivElement | null>(null);
  const [isShown, setIsShown] = useState(false);

  useEffect(() => {
    const { current } = ref;
    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsShown(entry.isIntersecting);
        console.log(entry.isIntersecting ? "The div is in viewport!" : "The div is not in the viewport.");
      },
      {
        root: null,
        rootMargin: "0px",
        threshold: 0.1,
      }
    );

    if (current) observer.observe(current);

    // クリーンアップ関数
    return () => {
      if (current) observer.unobserve(current);
    };
  }, []);

  return (
    <div ref={ref} data-is-shown={isShown}>
      {children}
    </div>
  );
};

export default ObserverComponent;

data-is-shownの値をbooleanで切り替え、アニメーションはスタイルで定義します。
Reactを使わない場合(バニラJS)などでも使う方法かなと思います。

Intersection Observer APIをインスタンス化し、第一引数にはコールバック関数を定義します。
([entry])とすることで観察した要素の最初のもののみを取得できます。
isIntersectingというのが要素がビューポート内に存在するかどうかを示すため、ここでboolean判定を行い、data-is-shownの値が動的に変更されるようになっています。

第二引数はオプションで、ルートマージンやしきい値などを定義することができます。
要は発火のタイミングなどをpxや%などで指定することができます。

カスタマイズしようとすると分岐が多くなりがちですが、ライブラリを使わずに簡単に実装できるのでとても楽ですね!

詳細は以下のMDNをご覧ください。
https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

Framer Motion

Framer Motionは、デザインとプロトタイピングツール「Framer」を開発しているFramer社によって提供されています。
Framer Motionはアニメーションに特化したライブラリで、Chakra UIにも取り込まれています!
インストール方法は以下の通りです。

// npm
npm install framer-motion
// yarn
yarn add framer-motion

ベースの使い方は以下のようになります。

FramerComponent.tsx
import { motion } from "framer-motion";
const FramerComponent = () => {
  return (
    <motion.div 
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: 2 }}
    >
      Hello, Framer Motion!
    </motion.div>
  );
};

export default FramerComponent;

initialで初期値を定義、animateでアニメーション後の状態を定義、transitionでタイミングや秒数などを定義という感じですね。
アニメーションが美しいFramer Motionですがこのままだと、スクロールして出現したタイミングでの発火もしないですし、このままだといちいち無駄なdivが生まれてしまいます。
少しカスタマイズしてみましょう!

  • スクロールしたら出現
  • divではなく任意のHTML(JSX)要素を受け取り、型補完もできる(例:aタグならhreftarget

これらを条件にして以下のようにしてみました!

ScrollReveal
import { MotionProps, motion } from "framer-motion";
import { ElementType, ReactNode, JSX } from "react";

export type ScrollRevealProps<T extends keyof JSX.IntrinsicElements> = {
  children: ReactNode;
  as?: keyof typeof motion | ElementType;
  initial?: MotionProps["initial"];
  whileInView?: MotionProps["whileInView"];
} & JSX.IntrinsicElements[T];

const ScrollReveal = <T extends keyof JSX.IntrinsicElements>({ children, as = "div" as T, ...props }: ScrollRevealProps<T>) => {
  const MotionComponent = motion[as as keyof typeof motion] as ElementType;
  return (
    <MotionComponent
      // 初期値
      initial={{ opacity: 0, y: 50 }}
      // 表示された時の値
      whileInView={{
        opacity: 1,
        y: 0,
        transition: { delay: 0.2, duration: 0.6, ease: "easeIn" },
      }}
      // 一度だけ実行
      viewport={{ once: true }}
      {...props}
    >
      {children}
    </MotionComponent>
  );
};

export default ScrollReveal;

スクロールしたタイミングで出現したい場合は、その処理を書かなくてはいけませんが、Framer Motionには、whileInView={}という便利なプロパティがあります。
animateではなく、whileInViewで指定することで、要素が出現したタイミングでアニメーションを開始します。
さらに、viewport={{ once: true }}でアニメーションを1回のみ実行することが容易にできます。
先ほどの、Intersection Observer APIでは、once: trueのような機能はデフォルトでないため自分でコードを書いて分岐することがありましたが、Framer Motionはその辺もpropsを渡すだけで楽ちんです!

TypeScriptの型のところは詳細な解説はしませんが、ジェネリクスとしてasで渡ってきた要素の型を定義してあげます。
MotionPropsには、Framer Motionの型があるので、そこから必要なものを定義しています。

簡単!!だけど....

  • Intersection Observer APIのようにビューポートから何%で発火させるなど決めたい
  • トリガーとして使いたい(疑似要素でのアニメーションや、他のCSSと組み合わせたい)
    こういったことはできなくなります。
    要件次第では、これだけでも十分使えますが、もう少し拡張させるとなると、Intersection Observer APIやその他のライブラリと組み合わせる必要があります。

Framer Motion + react-intersection-observer

先ほど解説したように、Intersection Observer APIを使用して組み合わせても問題ないですが、コードが少し複雑化してしまうため、react-intersection-observerというライブラリを使いましょう!
ReactでIntersection Observerの機能を使う際は大体このライブラリを使うことのほうが多いかと思います。
インストール方法は以下の通りです。 framer-motionは追加済みのものとします。

// npm
npm install react-intersection-observer
// yarn
yarn add react-intersection-observer

あとは、useInviewというカスタムフックが使えるようなるためインポートします。
useInviewを使うことで、ビューポートにに入っているか検知するinViewがbooleanで取得できます。

const [ref, inView] = useInView({ ...options });

上記のように、optionsを渡すことができます。
型の指定もしてあげましょう!

export type ScrollRevealProps<T extends keyof JSX.IntrinsicElements> = {
   children: ReactNode;
   as?: keyof typeof motion | ElementType;
   initial?: MotionProps["initial"]; // 初期値
   whileInView?: MotionProps["whileInView"]; // 表示された時の値
+  options?: IntersectionOptions; // オプション
} & JSX.IntrinsicElements[T];

optionsでは、デフォルトのrootMarginや、thresholdに加えて、react-intersection-observerが用意しているプロパティも渡すことができます。気になる方はIntersectionOptionsの内部ファイルを除いてみてください。triggerOnceという1回だけ実行するかなどの分岐がここでもできます。

オプションの型(IntersectionOptions)
IntersectionOptions
export interface IntersectionOptions extends IntersectionObserverInit {
    /** The IntersectionObserver interface's read-only `root` property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the `root` is null, then the bounds of the actual document viewport are used.*/
    root?: Element | null;
    /** Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left). */
    rootMargin?: string;
    /** Number between `0` and `1` indicating the percentage that should be visible before triggering. Can also be an `array` of numbers, to create multiple trigger points. */
    threshold?: number | number[];
    /** Only trigger the inView callback once */
    triggerOnce?: boolean;
    /** Skip assigning the observer to the `ref` */
    skip?: boolean;
    /** Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. */
    initialInView?: boolean;
    /** Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */
    fallbackInView?: boolean;
    /** IntersectionObserver v2 - Track the actual visibility of the element */
    trackVisibility?: boolean;
    /** IntersectionObserver v2 - Set a minimum delay between notifications */
    delay?: number;
    /** Call this function whenever the in view state changes */
    onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void;
}

これで、ルートマージンやしきい値なども個別に対応できました!
あとは、ビューポートに入ったタイミングを動的検知できるようにしましょう!

    <MotionComponent
      ref={ref}
      initial={{ opacity: 0, y: 50 }}
      whileInView={{
        opacity: 1,
        y: 0,
        transition: { delay: 0.2, duration: 0.6, ease: "easeIn" },
      }}
      viewport={{ once: true }}
+     data-is-active={inView}
      {...props}
    >
      {children}
    </MotionComponent>

これでトリガーとしても使えますし、アニメーションとしてもとても簡単に使えるようになりました🚀

合体したコードは↓のアコーディオン内にあります。

コード全文(ScrollMotionObserver.tsx)
ScrollMotionObserver.tsx
import { MotionProps, motion } from "framer-motion";
import { ElementType, ReactNode, JSX } from "react";
import { IntersectionOptions, useInView } from "react-intersection-observer";

export type ScrollRevealProps<T extends keyof JSX.IntrinsicElements> = {
  children: ReactNode;
  as?: keyof typeof motion | ElementType;
  initial?: MotionProps["initial"]; // 初期値
  whileInView?: MotionProps["whileInView"]; // 表示された時の値
  options?: IntersectionOptions; // オプション
} & JSX.IntrinsicElements[T];

const ScrollReveal = <T extends keyof JSX.IntrinsicElements>({ children, as = "div" as T, options = { rootMargin: "10%" }, ...props }: ScrollRevealProps<T>) => {
  const [ref, inView] = useInView({ ...options });

  const MotionComponent = motion[as as keyof typeof motion] as ElementType;

  return (
    <MotionComponent
      ref={ref}
      initial={{ opacity: 0, y: 50 }}
      whileInView={{
        opacity: 1,
        y: 0,
        transition: { delay: 0.2, duration: 0.6, ease: "easeIn" },
      }}
      viewport={{ once: true }}
      data-is-active={inView}
      {...props}
    >
      {children}
    </MotionComponent>
  );
};

export default ScrollReveal;

また、この2つのライブラリの組み合わせだと、whileInViewを使わなくても、以下のようにすることでも実現可能ですね。

<MotionComponent
  ref={ref} // react-intersection-observerのref
  initial={start} // start = initialの値
  animate={inView ? end : start} // end = 終了値, react-intersection-observerのinView
  viewport={{ once: true }}
  transition={transition}
  data-is-active={inView}
  {...props}
>
  {children}
</MotionComponent>

ここでは、初期値をstart, 終了値をendとして、変数名を変えて定義しています。
inViewでビューポート内にあるかの判定ができるので、そこで分岐しています。

自作ライブラリ公開してみた

要件に応じて、framer-motionか、react-intersection-observerか...またはどちらとも組み合わせるのかが分かれてくると思います。
それぞれ、トリガーとして使いたい場合とアニメーションのものを組み合わせているのでこれってもっと簡単に要件を絞って作成できないかな?と思い、ライブラリを自作してみました。

  • アニメーションとしても使えて、トリガーとしても使えるオールインワン
  • トリガーは内部でIntersection Observer APIを使い、アニメーションはHTML(JSX)要素のインラインスタイルなので軽量
  • translateYなどはCSSプロパティにないので対応させて簡単に使えるようにする
  • data-*属性の自動付与して、拡張性を持たせる
  • 個別にルートマージンなどを設定できる

こういったことをテーマに作成しました。その名も、「React Animate Observer」
https://www.npmjs.com/package/react-animate-observer

簡単に使い方を紹介すると、

import ScrollAnimator from 'react-animate-observer';

const YourComponent = () => {
  return (
    <ScrollAnimator
      start={{ opacity: 0, translateY: 40 }}
      end={{ opacity: 1, translateY: 0 }}
      transition={{
        transitionDuration: 0.8,
        transitionTimingFunction: 'ease-in-out',
      }}
    >
      <div>Your content goes here</div>
    </ScrollAnimator>
  );
};

これだけで、今までと同じようにスクロールされたらアニメーションがされます。また、data-is-active={<boolean>}も全て適用されます。
start, end, transitionはデフォルトで値を持っているため渡さなくてもアニメーションできます!さらに、HTML(JSX)要素を指定して型保管ももちろんできます。

<ScrollAnimator as="section">
  <p>Your content goes here</p>
</ScrollAnimator>

どのページでも使うデフォルトのアニメーションは、↑だけでいけます。
他にも、ルートマージンなどのデフォルト値を設定して渡したり、customStyle={true}を渡して初期値のアニメーションの値を消すこともできます。

詳細はドキュメントをご覧ください!
https://wadeen.github.io/react-animate-observer/docs/intro

作成していてとても学びがありました!メンテナンスしていくのでよければお使いください🕹️

おわり

いかがだったでしょうか。
アニメーションってコンポーネント化するのに意外とめんどくさいので今回はより簡単にする方法をご紹介しました。
WebGLなどを使うグラフィック系なサイトではもっとカスタマイズが必要だと思いますが、通常のWebサイト制作やWebアプリ開発では実際に使える内容だったのではないでしょうか?

また、Framer Motionも普通に実装しようとしたらめんどくさいアニメーションもテンプレとして存在してたりするのでサイトをチェックしてみてください!

自作したreact-animate-observerも開発続いているので何か気になることや要望があればイシューやPRなどでご連絡いただけると嬉しいです👍

参考

https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API
https://www.framer.com/motion/
https://react-intersection-observer.vercel.app/?path=/docs/intro--docs

Discussion

masaha03masaha03

Reactでのアニメーションの情報は意外と少ないので、とても参考になりました!

記事の内容を元に色々調査したのですが、motionコンポーネントの whileInView もintersection observerベースで実装されていて、 onViewportEnteronViewportLeave というpropでintersection observerをトリガーにしたコールバックを設定できるようです。

また、 viewport prop内の marginamount が、それぞれintersection observerの rootMarginthreshold に渡されるようです。

なので、

Intersection Observer APIのようにビューポートから何%で発火させるなど決めたい
トリガーとして使いたい(疑似要素でのアニメーションや、他のCSSと組み合わせたい)
こういったことはできなくなります。

というわけでもなさそうです。

あと、react-intersection-observerとほぼ同機能のuseInViewや、intersection observerではなくscrollイベントベースのuseScrollというフックもありました。Framer Motion、多機能過ぎですね。

いずれにせよ、Scroll-driven Animationsのブラウザ実装が進めば、こういった実装も楽になりそうです。
https://ics.media/entry/230718/
Framer Motionでもscroll関数としてすでに取り込まれていました。