🍬

Web Animations APIベースのReact用アニメーションライブラリを作成しました

2022/07/11に公開

Reactで Web Animations API を扱うためのライブラリを作成しました。
軽量(現在1.9kB gzipped)、シンプルかつReactと相性の良いAPIを提供することを目指しています。
よろしければスター、試してみてフィードバックなどいただけると非常にありがたいです。
こういうアニメーションがやりたいけど出来ないんだけど…というような話もお待ちしております。できる限り多くのユースケースに対応していきたいので…

https://github.com/inokawa/react-animatable

Live demo

https://inokawa.github.io/react-animatable/

使い方

Quick Start

npm install react-animatable

以下のような手順で使えます。

  1. useAnimation hookでアニメーションを定義する
  2. animate.refをアニメーションさせたいelementにassignする
  3. animate.play()をevent handlerもしくはuseEffect中で実行する
import { useEffect } from "react";
import { useAnimation } from "react-animatable";

export const App = () => {
  const animate = useAnimation(
    [
      { transform: "rotate(0deg)", borderRadius: "1rem" },
      { transform: "rotate(360deg)", borderRadius: "50%" },
      { transform: "rotate(720deg)", borderRadius: "1rem" },
    ],
    {
      duration: 1000,
      iterations: Infinity,
      easing: "ease-in-out",
    }
  );

  useEffect(() => {
    animate.play();
  }, []);

  return (
    <div
      ref={animate.ref}
      style={{
        border: "solid 0.1rem #135569",
        height: "6rem",
        width: "6rem",
        margin: "2rem 0 2rem 2rem",
      }}
    />
  );
};

例えばこれが以下のようにアニメーションされます。

useAnimation hookの引数については、Web Animations APIの引数のフォーマットをできる限り引き継いでいます。なので、詳しい方は多少の慣れで使えるのではないかと思います。
詳しくない方は、第一引数にアニメーションさせたいCSS propertyを配列で渡し、第二引数にdurationでmsでアニメーション時間を指定すればとりあえず動きます。後は必要に応じて覚えていけば良いのではと思います。Demoにplaygroundも用意しているので、適当に試してみてください。
React.CSSProperties相当のTypeScriptの型定義を付けているため、フィールド名などの入力補完が効きます。

https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Keyframe_Formats#syntax
https://developer.mozilla.org/en-US/docs/Web/API/KeyframeEffect/KeyframeEffect#parameters

また、componentのunmount時にアニメーションは自動でcleanupされるので、メモリリークする恐れもないです。例えば、通常のWeb Animations APIだとfill: forwardsを指定したり、特定の設定でアニメーション終了後もずっとインスタンスが残留する可能性がありますが、そういった心配もありません。
https://www.webdesignleaves.com/pr/jquery/web-animation-api-basic.html#h5_index_13

hookの返り値には、play以外にもpausereversesetTimesetPlaybackRateなどのメソッドが生えています。この辺りもWeb Animations APIで出来ることは概ね出来ると思います。
hookの返り値や関数の参照は不変になっているので、直接componentなどに渡してもパフォーマンスへの影響はありません。

  <button onClick={animate.pause}>pause</button>
  <button onClick={animate.reverse}>reverse</button>
  <button onClick={animate.finish}>finish</button>

コンポーネントでアニメーションを管理する

このライブラリを作成した1個の目標としてあるのが、D3.jsのようなdata drivenなアニメーションをReactで簡単に再現できるようにすることです。

https://d3js.org/

例えば、以下のようにArray.prototype.mapを使って、アニメーション定義をcomponent単位で共通化し、それぞれpropsの値を変えてアニメーションに変化をつけることができます。
(SVGは左上がxy座標の原点なので、若干特殊な書き方になっています)。

const Bar = ({
  value,
  i,
  height,
}: {
  value: number;
  i: number;
  height: number;
}) => {
  const opacity = String(1 - i * 0.025);
  const animate = useAnimation(
    [
      {
        height: `${value}px`,
        transform: `translateY(-${value}px)`,
        opacity,
      },
    ],
    { duration: 150, easing: "ease-out", delay: i * 100 }
  );

  useEffect(() => {
    animate.play();
  }, [value]);

  return (
    <rect
      ref={animate.ref}
      x={i * 20}
      y={height}
      width={18}
      height={0}
      opacity={opacity}
      fill="steelblue"
    />
  );
};

export const Bars = () => {
  const init = () =>
    Array.from({ length: 30 }).map(() => 300 * Math.random() ** 2);
  const [rects, setRects] = useState(init);

  const width = 800;
  const height = 400;
  const margin = 10;
  const maxBarHeight = height - margin * 2;

  return (
    <svg width={width} height={height} onClick={() => setRects(init())}>
      <g transform={`translate(${margin},${margin})`}>
        {rects.map((v, i) => (
          <Bar key={i} i={i} value={v} height={maxBarHeight} />
        ))}
      </g>
    </svg>
  );
};

これだとvalueが変わっても、heightが毎回0から始まるアニメーションになってしまいますが、hookに関数を渡しprevとしてアニメーション実行直前のstyleの値を参照することができるので、CSS transitionのように値を引き継いでアニメーションさせることもできます。

const animate = useAnimation(
  (prev) => [
    {
      height: prev.height,
      transform: prev.transform,
      opacity: prev.opacity,
    },
    {
      height: `${value}px`,
      transform: `translateY(-${value}px)`,
      opacity: String(1 - i * 0.025),
    },
  ],
  { duration: 150, easing: "ease-out", delay: i * 100 }
);

その他のAPI

useAnimationFunction

実は、Web Animations APIではHTML, SVGだけでなく、テキストやCanvasなどもアニメーションさせることができます。
この機能を単体のhookにしたものです。

useAnimationController

1つのelementに対し、複数種類のアニメーションをかけたい場合に使用します。
同時にかけたり、連続してかけたりできます。
…なのですが、この、同時/連続にかける、にはかなり色々なバリエーションがあると思われ、それらに出来る限り対応できるよう改善中です。
具体的には、Web Animations API Level 2 Draftにある GroupEffect / SequenceEffect 相当のことを出来るようにしたり、componentを跨いでアニメーションを複雑にオーケストレーション出来るようにしていきたいです。

useTransitionAnimation

componentのmount/update/unmountを検知してアニメーションを実行したい場合に使用するhookです。
react-transition-group の TranstionGroup、古くは(?)D3.jsのenter/update/exit patternに相当するものです。
AnimationGroup componentと一緒に使う必要があります。
こちらも改善中です。

モチベーション

自身のバックグラウンドを説明すると、普段の業務ではReactを用いたSPAを開発しているフロントエンドエンジニアになります。
そんな私がReactでアニメーションを実装したい時って、要素やグラフに少しだけ動きをつけたいケースがほとんどなのですが、

  • ちょっとしたアニメーションをつけたい、という目的に対してライブラリの使い方が難しすぎないか?
  • そのために数十kBのライブラリを載せるのは過剰ではないか?
  • かといって、生のJSやCSSなどで実装すると、Reactと噛み合わせが悪い気がする。
  • アニメーションをdata drivenにコントロールしたいし、Reactのcomponentやhookで上手く管理したい。

というような気持ちを抱くことがあり、これらの問題をなんとかしたい、というのがモチベーションとしてありました。

加えて、最近Web Animations APIについて深く知る機会があり、これは便利だしもっと使われるべきものなのでは…と感じ、ライブラリのコード量を減らせる、高パフォーマンスなど目指している方向とも違わないため採用しました。
IEもサポート終了した今、最新のブラウザであれば実用上問題なくWeb Animations APIは動くはず。そうでなくとも、feature detectionを使えば未対応のブラウザにだけpolyfillを入れたりもできますし、そんな感じでもっと広まっていくと良いのかなと思います。(polyfillの入れ方はreadmeに少し記載しています)

技術的な話など

Reactでアニメーションライブラリを作る、Web Animations APIを使うにあたって困った(未だ困っている)話など。

ReactとDOMのstyle同期

Reactでアニメーションライブラリを作る上で問題になると思われるのが、アニメーションしたstyleをDOMにどう反映し、またstateなどReactの世界とどう同期を取っていくか、ということだと思います。

react-motionreact-moveといった旧来のライブラリは、Reactのstateを連続して更新し、結果elementのprops経由でstyleが更新される、というような方法をとっているものが多いと思います。
これは余計なrenderを誘発したり、React側で状態が間引かれてしまうケースもあるためベストな方法ではないと思われ、react-springframer-motionなど新しめのライブラリはref経由で直接DOMのstyleを更新する方法が取られていると思います。

このライブラリのように、Web Animations APIをアニメーションに用いる場合はまた話が違ってきます。JSで連続的にstyleを書き換えてアニメーションするケースとは違って、Web Animations APIでアニメーションしても、通常styleに値が書き込まれる事はなく、またclassやinline styleよりも高い優先度でアニメーションのstyleが適用されるなど、いくつか特徴があります。これらはReactと組み合わせるにあたって良い面と悪い面があると思われ、特にpropsやclassで決まるstyleとアニメーションで決まるstyleとを如何にシームレスに扱えるようにしていくか、という点は考え所だなと感じました。

ref

DOMに直接更新をかけたい場合、Reactではrefをelementに渡し、これを経由して更新をかける以外の方法はないと思います(一応findDOMNodeがありますがdeprecatedかと思うので)。
refって基本的にはelementと一対一で対応させるしかないと思うので、これがある程度APIを決めてしまうな、という気がしています。一応、1つのrefを複数のelementに渡したりする方法もあると思いますが、この場合refに紐づくelementがunmountされたことを検出する良い方法が無い気がしていて、cleanupに難があるなと感じています。

https://github.com/facebook/react/issues/13029

exit animation

Reactでmount時、propsやstateのupdate時にアニメーションするのは簡単です。しかし、unmountの直前にアニメーションを挟み、そのままunmountする、という挙動を実現するのは通常の方法では難しいです。
本ライブラリでは、AnimationGroupというcomponentをアニメーション対象の親に配置させることで、childrenのunmountを遅延させられるようにしています。react-transition-groupやframer-motionなど、他のライブラリと似たような方法を使っていると思います。
RFCも出ていますがクローズされていますね。
https://github.com/reactjs/rfcs/issues/128

過去に作成したもの

実は、これ以前にも幾つかReact用のアニメーションライブラリを作成しています。
振り返ってみると正直どれも微妙なのですが、ただ他であまりやっていないことをやってはいると思うので書き残しておきます。

tweened

https://github.com/inokawa/tweened

react-flightを見て、自分がD3.jsで好きな部分であるアニメーション機能をJSXで再現できないだろか?と思って作成しました。
d3-transitionが普通のstyle/attribute更新とアニメーションを伴うstyle/attribute更新をよく似たような記法で扱っているように、JSXで通常のpropsとアニメーション定義を一元的に扱えないか?と思って作っては見たものの、やはり無理が出てきて開発終了しました。

例えばreact-routerのrouting定義であれば、routing構造がDOMのツリー構造を内包するのでJSXで書いても違和感が少ないと思います。ですが、DOMの構造とアニメーションの定義の構造だと、アニメーションは特定のプロパティだけ動かしたいケースがあったり、componentを跨いでシーケンスでアニメーションを実行したいケースもあったり、これらをJSXで綺麗に定義するのって難しいんじゃ無いかなと…もしかしたら、もう少しユースケースを絞れば良かったのかもしれませんが。
あとは、どうしてもDOMとstateを同期させる実装が煩雑になるのと、どう頑張ってもアニメーションエンジンだけで数kB食ってしまうので。これらの問題は、Web Animation APIを使えば解決しそうだったので、次作ではこれに乗り換えました。

アニメーションエンジンはd3-transitionのforkから出発しました。実はreact-moveで使われているアニメーションエンジンもd3-transitionの部分的なforkなので、結果似たような感じになったと思います。react-moveはAPIもD3.jsっぽいですよね。

また、<tweened.rect/>のような記法は、framer-motionを参考にProxyを使ってelementごとに初回の呼び出しのタイミングでcomponentの関数を生成しています。こうすることで、rectなどelementの名称をJSランタイム中に持たなくて済むようにしています。

react-use-d3

https://github.com/inokawa/react-use-d3

d3-renderを見て、D3をReactっぽく宣言的に書くのではなく、ReactでD3を宣言的に使えるようにできないか?、と思って作成しました。
なのですが、D3の記法そのまますぎて、Reactを使っている人には使い辛い、D3をref経由で使うのと比べ優れているという訳でもなく、D3を使える人が敢えて使うメリットもあまりない、と誰得な仕様になってしまったため、開発終了しました。

react-faux-domのforkから出発して、D3.jsから呼ばれるdocument.createElement()React.createElement()に、DOMに生えているメソッドも全てReactのpropsやrefに変換する実装を行っています。
https://github.com/Olical/react-faux-dom

Discussion