React + GSAP を使用したアニメーションを作成する
制作物
https://react-gsap-animation.netlify.app/
対応したこと
- 左上の「Add Circle」を押したら、アニメーションのコンポーネントが表示される
- 要素のアニメーションはクリックされた分実行される
- アニメーションのコンポーネントについて
- 中央のアニメーションの塗りつぶし
- 要素の色・アニメーションを行う箇所はランダム
- 小さい円のアニメーションが以下のようにアニメーション
- 楕円が一つずつ下から移動
- 1と同じタイミングで楕円の拡大
- 楕円の丸みを正円に変更
- 楕円の縮小からのサイズ最小値
今回実装したコード
App.tsx
export const App = () => { const [count, setCount] = useState<number>(0) const addCircle = () => { setCount(count + 1) } return ( <div className="App"> <button type="button" onClick={addCircle}> Add Circle </button> {Array(count) .fill(0) .map((_, index) => ( // 今回アニメーションを行うコンポーネント <SvgCircle key={index} /> ))} </div> ) }
コンポーネント
import React, { createElement, VFC, useState, useEffect, useRef, Fragment, forwardRef, } from "react" import { gsap, Power2 } from "gsap" import { setup, styled, keyframes } from "goober" setup(createElement) // 円周が100pxになるように逆算した時の半径のサイズ const CIRCLE_RADIUS = 15.9154943092 // 円の中心の値(半径の倍の値) const CIRCLE_POSITION_CENTER = 31.8309886184 // svgのサイズ const SVG_ELEM_SIZE = 320 /** * @see https://greensock.com/forums/topic/14787-transform-in-vh-units/?tab=comments#comment-79432 */ const vw = (coef: number) => (window.innerWidth - SVG_ELEM_SIZE) * (coef / 100) const vh = (coef: number) => (window.innerHeight - SVG_ELEM_SIZE) * (coef / 100) - SVG_ELEM_SIZE export const SvgCircle: VFC = () => { const refWrapper = useRef(null) const q = gsap.utils.selector(refWrapper) const [color, setColor] = useState<string>() const [xPosition, setXPosition] = useState<number>(0) const [yPosition, setYPosition] = useState<number>(0) const [isAnimeRunning, setIsAnimeRunning] = useState<boolean>(true) useEffect(() => { const colorCode = Math.floor(Math.random() * 16777215).toString(16) setColor(`#${`000000${colorCode}`.slice(-6)}`) setXPosition(Math.floor(Math.random() * 100)) setYPosition(Math.floor(Math.random() * -100)) }, []) useEffect(() => { const refWrapperCurrent = refWrapper.current gsap.set(refWrapperCurrent, { y: 0, x() { return vw(xPosition) }, }) gsap.to(refWrapperCurrent, { keyframes: [ { duration: 0.7, ease: Power2.easeOut, y() { return vh(yPosition) }, }, { duration: 0.5, delay: 1.4, y: 0, ease: Power2.easeIn, }, ], onComplete() { setIsAnimeRunning(false) }, }) }, [xPosition, yPosition]) useEffect(() => { gsap.set(q(".smallCircle"), { fill: color, scale: 0, y: 30, }) gsap.to(q(".smallCircle"), { keyframes: [ { duration: 0.3, scaleX: 1, scaleY: 1.5, transformOrigin: "center", y: 0, delay: 0.5, stagger: 0.1, }, { duration: 0.4, scale: 1 }, { duration: 0.4, scale: 0, delay: 0.3 }, ], }) }, [color]) return ( <Fragment> {isAnimeRunning && ( <SvgWrapper1 ref={refWrapper}> <SvgWrapper2> <svg xmlns="http://www.w3.org/2000/svg" width={SVG_ELEM_SIZE} height={SVG_ELEM_SIZE} viewBox={`0 0 ${SVG_ELEM_SIZE} ${SVG_ELEM_SIZE}`} color={color} > <SvgCircleLarge color={color} cx="170" cy="150" r={CIRCLE_RADIUS} /> <SvgCircleLargeLine color={color} cx="140" cy="140" r="65" /> <circle className="smallCircle" cx="220" cy="50" r="10" /> <circle className="smallCircle" cx="150" cy="25" r="10" /> <circle className="smallCircle" cx="80" cy="40" r="10" /> <circle className="smallCircle" cx="30" cy="85" r="10" /> </svg> </SvgWrapper2> </SvgWrapper1> )} </Fragment> ) } /** * 扇のアニメーションについて * @see https://ksk-soft.com/2014/08/06/svg-pie-graph/ */ const sdaAnime = keyframes` 0% { stroke-dasharray: 0, 100; } 100% { stroke-dasharray: 100, 0; } ` const animeRotate = keyframes` 0% { transform: rotate(45deg); } 100% { transform: rotate(-45deg); } ` /** * easingの種類 * @see https://easings.net/ja# */ const easeInQuart = "cubic-bezier(0.5, 0, 0.75, 0)" const easeInOutCubic = "cubic-bezier(0.65, 0, 0.35, 1)" const SvgWrapper1 = styled("div", forwardRef)` bottom: -${SVG_ELEM_SIZE}px; position: absolute; ` const SvgWrapper2 = styled("div")` animation: ${animeRotate} 2s 0.2s ${easeInQuart} both; ` const SvgCircleLarge = styled("circle")` animation: 1.5s 0.3s ${sdaAnime} ${easeInOutCubic} both; fill: transparent; stroke: ${({ color }) => color}; stroke-dashoffset: 50; stroke-width: ${CIRCLE_POSITION_CENTER}; transform: scale(-2, 2); transform-origin: center; ` const SvgCircleLargeLine = styled("circle")` fill: transparent; stroke: ${({ color }) => color}; stroke-width: 5; `
使用したパッケージ
- "goober": "^2.0.41"
- "gsap": "^3.7.1"
- "react": "^17.0.0"
- "typescript": "^4.3.2"
- "vite": "^2.5.1"
実装手順
1. UIの実装
今回作成したマークアップについては以下になります。
<SvgWrapper1 ref={refWrapper}> <SvgWrapper2> <svg xmlns="http://www.w3.org/2000/svg" width={SVG_ELEM_SIZE} height={SVG_ELEM_SIZE} viewBox={`0 0 ${SVG_ELEM_SIZE} ${SVG_ELEM_SIZE}`} color={color} > <SvgCircleLarge color={color} cx="170" cy="150" r={CIRCLE_RADIUS} /> <SvgCircleLargeLine color={color} cx="140" cy="140" r="65" /> <circle className="smallCircle" cx="220" cy="50" r="10" /> <circle className="smallCircle" cx="150" cy="25" r="10" /> <circle className="smallCircle" cx="80" cy="40" r="10" /> <circle className="smallCircle" cx="30" cy="85" r="10" /> </svg> </SvgWrapper2> </SvgWrapper1>
SvgWrapper1
要素の出現アニメーションを行うための要素。
SvgWrapper2
要素を右から左に回転させるための要素。
svg
今回アニメーションを行うためのSVG全体の要素
SvgCircleLarge
SVGの丸部分のアニメーションを行うための要素
SvgCircleLargeLine
SVGの輪郭部分。中のものをアニメーションさせるため、輪郭用に別のcircleの要素を指定。
circle
小さい4つの円の要素。
2. css・gsapで行う箇所の切り分け・実装
cssでアニメーションできそうな箇所とgsapでアニメーションで切り分けた箇所について、
- cssでのアニメーション
- 単一のアニメーションの場合(opacityが0から1に変更するなど)
- gsapでのアニメーション
- アニメーションが複数ある場合(opacityが0から1に変更した後に、要素を移動したいなど)
- cssだけでの調整が難しいとき(初めの0.3sで出現・1.7s止まった後に0.3sかけてなど)
実際に今回行った箇所については以下になります。 - cssでのアニメーション - 中の円の塗りつぶしアニメーション - 要素全体を右側から左側に回転 - gsapでのアニメーション - 要素全体の上昇アニメーション - 小さい丸の方の出現タイミング・要素のアニメーション
3. ボタンをクリックした時に要素が出現するようにする
ボタンを押した時にアニメーションさせようと思っているため、以下のことを行いました。
- ボタンをクリック
- クリックした時に要素を追加
詰まった部分について
1. storybook接続時にstorybookでエラーになる
こちらについて、以下のようなコードの場合、
import React, { VFC } from "react" import { styled } from "goober" export const SvgCircle: VFC = () => <SvgCircleWrapper>text</SvgCircleWrapper> const SvgCircleWrapper = styled("svg")` background: #f0f; display: block; height: 10px; width: 10px; `
のエラーが出現する。対応策については以下のコードを追加
import React, { createElement, VFC } from "react" import { setup, styled } from "goober" setup(createElement)
全体のコードについては以下のように変更すると問題なく表示されます。
import React, { createElement, VFC } from "react" import { setup, styled } from "goober" setup(createElement) export const SvgCircle: VFC = () => <SvgCircleWrapper>text</SvgCircleWrapper> const SvgCircleWrapper = styled("svg")` background: #f0f; display: block; height: 10px; width: 10px; `
2. カラーコードがうまく取得できない
https://q-az.net/random-color-code/
を参考に その1 有名な方法
のような書き方を行っていたところ、 012345
の時に12345
と変換してしまいエラーになってしまったため、 その6 for なし
のものに修正しました。
// その1 有名な方法 var randomColor = "#" + Math.floor(Math.random() * 16777215).toString(16); // その6 for なし var color = (Math.random() * 0xFFFFFF | 0).toString(16); var randomColor = "#" + ("000000" + color).slice(-6);
3. アニメーション終了しても要素が残ってしまっている
コンポーネント部分にて、
const Hoge = () => { return ( <div>Hoge</div> ) }
のように記載していると、アニメーションが終わった後にコンポーネントがDOM上に残ってしまい、後々のアニメーションの処理が重くなってしまうため、以下のように修正してアニメーションが終わったタイミングでDOMの削除を行う。
const Hoge = () => { return ( <> { isHoge && ( // アニメーションが終わったら、isHogeをfalseにして要素を非表示にする <div>Hoge</div> )} </> <div>Hoge</div> ) }