🥰

クリック時に🧡がフワフワと漂うアニメーションの実装

2023/09/25に公開

こちらの内容についての補足記事です。背景や苦労した点などをメインに書きます。
CSS強い系の方々にアドバイス頂けないかなと下心もありますが、誰かしらの参考になれば幸いです。

(codesandboxの新バージョンだとzennの記事に埋め込み出来ない様子。なので動作確認やコードは直接codesandboxを開いてみてください。)
https://codesandbox.io/p/sandbox/heart-animation-wm3w55

(追記)
YouTubeにてこの記事を紹介する動画を公開しました

https://youtu.be/JxNyakBwjNY

背景と課題

現在個人開発中のアプリ内でいわゆる「いいね」のようなリアクションボタンを実装中なのですが、仕様上、”連打”できるようにしているので押下時の体験を気にしてあげる必要が出ました。
一般的に、例えばTwitterのハートボタンは1件に付き1いいねのみなので、on/offのスタイルさえ適用すれば最低限事足ります。
しかし、何度も押せるボタンの場合、ボタンを押した際にちゃんと押せたのか(=リクエストが飛んだのか)の表現(例えばアニメーション)が無いとユーザー的には不安が生じてしまいます。
また、冒頭で述べたように”連打”できる必要があるので、押下時にスピナータイプのsvgで置き換えるような、ボタンが消失するタイプの表現は使えません。
以上の課題をクリアするために、思いついたアニメーションが「クリック時に🧡飛ばせばいいんじゃね」でした。
さて、普段の仕事上でもアニメーションは出来合いのものを流用する程度な自分が不得手なCSSにチャレンジすることになりました。

前提技術と自分に課した制約

開発中のアプリでは、React + TailwindCSSな構成のため、react-springやEmotionなど別途余計なライブラリは導入したくありません。
なので、この枠内で実現できる方法を模索します。

TailwindCSSでのアニメーション

Tailwindからデフォルトで提供されるアニメーションは、spin/ping/pulse/bounce、になります。
https://tailwindcss.com/docs/animation

そのため、今回のような独自アニメーションは拡張から自前で追加して上げる必要があります。
https://tailwindcss.com/docs/animation#customizing-your-theme

keyframesとanimationの項目に以下のように設定します。(playgroundのリンクあり)

Reactコンポーネントと組み合わせる

さて、(”CSS力”が決して高くないため)JSと組み合わせる必要があるという結論ですので、定義したanimate-flyを使って後はコンポーネントでどうにかします。
課題点としては、1クリックで1🧡コンポーネントをアニメーションさせ、終了時に消す、必要があります。

アニメーション領域の設定

ボタン押下でそこからハートを一定領域内で飛ばす必要があります。

ここはanimationする秒数を考慮していい感じに決め打ちです。
ちょっと格好悪いですが、親となるここと

<div className="relative">
  <div className="absolute w-[100px] h-[100px] left-[-50px] top-[-100px]">
    {timestamps.map((t) => (
      <Heart key={t} />
    ))}
  </div>
</div>

<Heart />をこんな感じにしました。

<div
  className={`absolute bottom-0 left-[50%] translate-x-[-50%] animate-fly ${
    show ? "block" : "hidden"
  }`}
>
  🧡
</div>

ボタンに覆いかぶさるようにabsoluteでboxを下部中央に配置します。このbox内でハートをフワフワさせます。

難所:🧡を出現&アニメーションさせて消す

ボタンは連打可能なので、大量に🧡がフワフワしても問題なく描画して上げる必要があります。
そのため<Heart />は1つとは限りません。クリックした数だけ<Heart />を生成する必要があります。
ぱっと思いついたのは以下の方法です。単純にcounterでcountを配列に追加してもいいです。Math.random()は万が一被るとイケないので怠けてDate.now()。

const [timestamps, setTimestamps] = useState<number[]>([]);
...
<button
  type="button"
  onClick={() => {
    setTimestamps((s) => [...s, Date.now()]);
  }}
>
...
{timestamps.map((t) => (
  <Heart key={t} />
))}

<Heart />が生成された瞬間にanimationをスタートさせます。アニメーションに設定した秒数経過した後、消すためにuseEffectでshow = falseにします。

const Heart = () => {
  const [show, setShow] = useState(true);
  useEffect(() => {
    const timer = setTimeout(() => {
      setShow(false);
    }, 2000);
    return () => {
      clearTimeout(timer);
    };
  }, []);
  return (
    <div
      className={`absolute bottom-0 left-[50%] translate-x-[-50%] animate-fly ${
        show ? "block" : "hidden"
      }`}
    >
      🧡
    </div>
  );
};

※ display:none にしているのでメモリ的には if(!show) return null;が優しいかもです。連打後はこうなるので。

おわりに

それなりに自己満足なものが出来てよかったです。
ハートの漂う起動や秒数を変えたい場合、keyframesとanimationで複数パターン用意し(animate-fly1,animate-fly2,animate-fly3など)、<Heart />内でランダムにそれらを呼び出せば良いかなと思います。ハートの方向はtransform: rotate()で領域とハートをうまいこと回転させるといけるかも?

(インスタのストーリーズでハートが飛ぶアニメーションはこれらの応用で出来そう。というか最初これをイメージして作ったんだけど、出来上がった後にハートは直線で飛んでることを知って😇)

久しぶりに楽しかったのでヨシです。
何か気付きやアドバイス等あればコメント頂けると嬉しいです。ありがとうございました。

Discussion