紙吹雪をふらせる React のコンポーネントを作成する

CSS で紙吹雪を作るときには rotate3d でのアニメーションが一部のブラウザで意図していないアニメーションをすることがあることに気をつける必要があります。紙吹雪の紙部分ができてしまえば、あとはある程度ランダムな数値でアニメーションをするだけでそれっぽい感じになります。応用がききやすいアニメーションなので、形や色を変えることで色々と使うことができそうです。
2021.10.11

紙吹雪をふらせる React のコンポーネントを作成してみます。作成物は以下になります。

準備

上記をテンプレートとして使用します。今回 tailwindcss は使用せず、また Next.js ですが styled-jsx を使用せず CSS Modules のみで作成していきます。

紙吹雪の紙のコンポーネントを作成する

紙吹雪を回転させる

Piece.tsx という紙吹雪の紙部分のコンポーネントを作っていきます。

コンポーネントには紙の長さと高さ、色を指定できるようにしておきます。

// components/Piece/Piece.tsx
import { ComponentPropsWithoutRef, CSSProperties, forwardRef } from "react";

import style from "./index.module.css";

type Props = Omit<
  DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
  "className" | "color" | "width" | "height"
> & {
  color: Required<CSSProperties>["backgroundColor"];
  width: Required<CSSProperties>["width"];
  height: Required<CSSProperties>["height"];
};

export const Piece = forwardRef<HTMLDivElement, Props>((props, ref) => {
  const {
    color: frontColor,
    width,
    height,
    style: pieceStyle,
    ...rest
  } = props;

  return (
    <div
      ref={ref}
      className={style.piece}
      style={{ backgroundColor, width, height, ...pieceStyle }}
      {...rest}
    />
  );
});
Piece.displayName = "Piece";

この Piece を CSS の rotate3d で回転させてみましょう。

/* components/Piece/index.module.css */
.piece {
  animation: piece linear infinite;
  animation-duration: 2s;
}

@keyframes piece {
  0% {
    transform: rotate3d(1, 1, 1, 0deg);
  }
  100% {
    transform: rotate3d(1, 1, 1, 359deg);
  }
}

上記で一見回転してくれそうなのですが、上記の CSS ではうまく動かないブラウザが存在します。

Mac Chrome Mac Safari

この現象は rotate3d ではなく rotate では発生せず、意外と気づけないので注意が必要です。

解決策としては Mac Safari のようなブラウザでは、最終形のアニメーションの結果に対して最短のアニメーションをとるようなので、その中間の状態を追加することです。

@keyframes piece {
  0% {
    transform: rotate3d(1, 1, 1, 0deg);
  }
  25% {
    transform: rotate3d(1, 1, 1, 90deg);
  }
  50% {
    transform: rotate3d(1, 1, 1, 180deg);
  }
  75% {
    transform: rotate3d(1, 1, 1, 270deg);
  }
  100% {
    transform: rotate3d(1, 1, 1, 359deg);
  }
}

1 回転させようとすると 50% だけではなく 25%75% 時点の状態も指定して上げる必要があります。これで Mac Safari でも 1 回転させることができました。

紙吹雪に裏面をつくる

紙吹雪の裏面を表面よりすこし薄い色にしてみます。

// components/Piece/Piece.tsx
export const Piece = forwardRef<HTMLDivElement, Props>((props, ref) => {
  const {
    color: frontColor,
    width,
    height,
    style: pieceStyle,
    ...rest
  } = props;

  return (
    <div
      ref={ref}
      className={style.root}
      style={{ ...pieceStyle, width, height }}
      {...rest}
    >
      <div className={style.front} style={{ backgroundColor: frontColor }} />
      <div className={style.back} />
    </div>
  );
});

実際に 3D の空間を回転するアニメーションを行うのは、style.root が指定された <div> になります。紙の表裏になるのは 2 つの子要素で、それらを同一の 3D 空間に所属させるには CSS の transform-style プロパティを使います。

.root {
  transform-style: preserve-3d;
  animation: piece linear infinite;
  animation-duration: 2s;
}

.front {
  position: absolute;
  width: 100%;
  height: 100%;
}

.back {
  position: absolute;
  width: 100%;
  height: 100%;
  background-color: rgba(255, 255, 255, 0.8);
  backface-visibility: hidden;
}

style.frontstyle.back はそれぞれ紙吹雪の全面と背面になります。ここで気をつける必要があるのは style.back に指定されている backface-visibility: hidden; です。この指定によって、表面が表示されているとき背面を表示しないようにしています。

紙吹雪を 1 枚落下させる

先ほど作成した Piece を 1枚だけ、落下させるコンポーネントを作成します。

まずは 1 個だけ落下させるコンポーネント Confetti.tsx を作成してみます。

// components/Confetti/Confetti.tsx
import { DetailedHTMLProps, forwardRef, HTMLAttributes } from "react";

import { Piece } from "../Piece";
import style from "./index.module.css";

type Props = Omit<
  DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>,
  "className"
>;

export const Confetti = forwardRef<HTMLDivElement, Props>((props, ref) => {
  return (
    <div ref={ref} className={style.root} {...props}>
      <div className={style.piece}>
        <Piece color="green" width={10} height={10} />
      </div>
    </div>
  );
});
Confetti.displayName = "Confetti";
/* components/Confetti/index.module.css */
.root {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.piece {
  position: absolute;
  height: 100%;
  animation: fall linear infinite;
  animation-duration: 2s;
  margin-left: 50%;
}

@keyframes fall {
  0% {
    transform: translate(0%, 0);
  }
  25% {
    transform: translate(-200%, 25%);
  }
  50% {
    transform: translate(0%, 50%);
  }
  75% {
    transform: translate(200%, 75%);
  }
  100% {
    transform: translate(0%, 100%);
  }
}

アニメーションには bottom や margin を使用したくないので、Piece をラップする <div> の高さを 100% に指定しています。これで translate で % を利用したときに親要素の上から下まで移動できるようになります。

紙吹雪を指定枚数落下させる

先ほど作成した Confetti を指定枚数分、落下させるコンポーネントに修正していきます。

// components/Confetti/Confetti.tsx
const getRandomArbitrary = (min: number, max: number) => {
  return Math.random() * (max - min) + min;
};

export const Confetti = (props: Props) => {
  const { count, duration, delay } = props;

  const pieces = useMemo(() => {
    return (
      <>
        {[...Array(count)].map((_, i) => {
          const pieceStyle = {
            marginLeft: `${Math.random() * 100}%`,
            animationDelay: `-${getRandomArbitrary(...delay)}s`,
            animationDuration: `${getRandomArbitrary(...duration)}s`,
          };

          return (
            <div key={i} className={style.piece} style={pieceStyle}>
              <Piece color="green" width={10} height={10} />
            </div>
          );
        })}
      </>
    );
  }, [count, delay, duration]);

  return <div className={style.root}>{pieces}</div>;
};

紙吹雪の降り始める位置と開始位置、落下速度は pieceStyles で指定しています。

降り始める位置は margin-left0% から 100% の間でランダムに配置しています。

開始位置は animation-delay で指定しています。getRandomArbitrary() は第 1 引数を最小値、第 2 引数を最大値とするランダムの値を戻す関数で、MDN からそのままもってきたコードです。負の値で指定することで、直ちに開始させる指定時間後のアニメーションを実行できます。

落下速度は animation-duration で指定しています。こちらも getRandomArbitrary() でランダムになるように調整しています。

紙吹雪の落下パターンを増やす

このままだと多少はランダムに散っているように見えますが、もうすこしランダムに降ってくるとそれっぽい感じになりそうなので、落下のアニメーションパターンを増やしてみます。

const classNameList = [style.fallA, style.fallB, style.fallC];

export const Confetti = (props: Props) => {
  const { count, duration, delay } = props;

  const pieces = useMemo(() => {
    return (
      <>
        {[...Array(count)].map((_, i) => {
          const pieceStyle = {
            marginLeft: `${Math.random() * 100}%`,
            animationDelay: `-${getRandomArbitrary(...delay)}s`,
            animationDuration: `${getRandomArbitrary(...duration)}s`,
          };

          const className =
            classNameList[Math.floor(Math.random() * classNameList.length)];

          return (
            <div
              key={i}
              className={`${style.piece} ${className}`}
              style={pieceStyle}
            >
              <Piece color="green" width={10} height={10} />
            </div>
          );
        })}
      </>
    );
  }, [count, delay, duration]);

  return <div className={style.root}>{pieces}</div>;
};

CSS に fallA, fallB, fallC のアニメーションを追加すると、それらのアニメーションがランダムで選択されるようになります。

紙吹雪を回転をランダムにする

紙吹雪の回転タイミングが同じになっているため、表を向いたときにはすべての紙吹雪が表を、裏のときには裏を向いてしまっています。そのため紙吹雪の回転の速度を変更していきます。

type Props = {
  count: number;
  pieceContainer: {
    duration: [number, number];
    delay: [number, number];
  };
  piece: {
    duration: [number, number];
  };
};

export const Confetti = (props: Props) => {
  const { count, pieceContainer, piece } = props;

  const pieces = useMemo(() => {
    return (
      <>
        {[...Array(count)].map((_, i) => {
          const pieceContainerStyle = {
            marginLeft: `${Math.random() * 100}%`,
            animationDelay: `-${getRandomArbitrary(...pieceContainer.delay)}s`,
            animationDuration: `${getRandomArbitrary(
              ...pieceContainer.duration
            )}s`,
          };

          const className =
            classNameList[Math.floor(Math.random() * classNameList.length)];

          const pieceStyle = {
            animationDuration: `${getRandomArbitrary(...piece.duration)}s`,
          };

          return (
            <div
              key={i}
              className={`${style.piece} ${className}`}
              style={pieceContainerStyle}
            >
              <Piece color="green" width={10} height={10} style={pieceStyle} />
            </div>
          );
        })}
      </>
    );
  }, [count, pieceContainer.delay, pieceContainer.duration, piece.duration]);

  return <div className={style.root}>{pieces}</div>;
};

まとめ

CSS だけで紙吹雪を作ってみました。ランダムで色を変える機能を作ってみたりしてみても良さそうです。

黒い背景に黄色の紙吹雪などの配色にするとブラックフライデー感がでたり、紙吹雪の四角形を三角形にしてエフェクトにしてみたりとそこそこ応用がききやすいアニメーションだと思います。