GODRICH

紙吹雪 アニメーション css キラキラ演出9選

会員登録や決済が終わった瞬間、画面いっぱいに色とりどりの紙の破片が舞い散る演出を見たことはありませんか。あれは紙吹雪 アニメーション cssと呼ばれるコードで作られていて、ボタンを押す、チェックを入れるといった操作一つで同じお祝いの動きを、一行でコピペOKなコードのまま自分のサイトに持ち込めます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9個は華やかさの順ではなく、実際にサイトで紙吹雪が使われる場面の順に並べています。ボタン一つで弾ける合図(01)と背景で降り続ける合図(02)から、ボタンや見出し文字そのものが光る強調(03〜04)へ進み、より大きなインパクトを与える爆発型の反応(05〜06)を経て、最後はマークアップを増やさない実装(07)、JavaScriptなしでトリガーをかける実装(08)、マウスに追従するインタラクション(09)で締めくくります。

01ボタンで紙吹雪爆発

登録や決済を終えたボタンを押すと、あらかじめSCSSの@forループで角度と距離を振り分けた紙吹雪12個が、transform: translate+rotateで四方に飛び出しながら薄れて消えます。もう一度押すと、クラスを外してから付け直すだけの短いJavaScript7行がアニメーションを最初から再生し直します。

@for 루프translate+rotate900ms
@for $i from 1 through 12 {
  .cb-piece:nth-child(#{$i + 1}) {
    background: nth($piece-colors, ($i - 1) % 4 + 1);
    --angle: #{$i * 30deg};
    --dist: #{64px + ($i % 3) * 22px};
    animation-delay: #{($i % 4) * 40ms};
  }
}

@keyframes cb-fly {
  0%   { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(.4); opacity: 0; }
  15%  { opacity: 1; }
  100% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(-1 * var(--dist))) scale(1); opacity: 0; }
}

02紙吹雪の雨

当選バナーの上で紙吹雪18個が、それぞれ別々のanimation-durationで速さを変えながら降り注ぎます。横位置(left)と同じ並び順でtopの開始位置も揃えてしまうと一列に固まった対角線になるため、topの並び順をわざと横位置とずらして散らしています。

@for 루프translateY2.4s
$tops: -40px, 160px, -180px, 20px, 100px, -120px, 60px, -200px, 140px, -20px, 180px, -100px, 40px, -160px, 120px, -60px, 200px, -140px;
@for $i from 1 through 18 {
  .cr-piece:nth-child(#{$i + 1}) {
    left: percentage($i / 19);
    top: nth($tops, $i);
    background: nth($piece-colors, $i % 4 + 1);
    animation-duration: #{1700ms + ($i % 5) * 140ms};
  }
}

@keyframes cr-fall {
  0%   { transform: translateY(0) rotate(0deg); }
  100% { transform: translateY(520px) rotate(300deg); }
}

03キラキラボタン

プレミアムボタンにマウスを乗せると、枠の外側にある小さな星4つがそれぞれ異なるanimation-delayで拡大・縮小を繰り返しながら光ります。マウスを離した瞬間はトランジションを挟まずぴたりと止まるので、画面がちらつきません。

scaleopacitystaggered delay
@keyframes sk-twinkle {
  0%, 100% { opacity: 0; transform: scale(.3) rotate(0deg); }
  50%      { opacity: 1; transform: scale(1) rotate(90deg); }
}

// 실사용: 호버 중에만 반짝인다. 캡처용 is-demo 는 항상 켠다
.sk-wrap:hover .sk-star,
.sk-wrap.is-demo .sk-star { animation: sk-twinkle $duration $easing infinite; }
.sk-star--2 { animation-delay: 160ms; }
.sk-star--3 { animation-delay: 320ms; }
.sk-star--4 { animation-delay: 480ms; }

04キラキラ文字

お祝いの見出し文字の周りに置いた3つのキラキラが、それぞれ別のタイミングで拡大・縮小して点滅します。主役はボタンではなく文字そのものなので、白い背景の中でも視線がまず見出しに向かいます。

scaleopacity@keyframes twinkle
.st-star {
  position: absolute;
  color: $color;
  font-size: 18px;
  line-height: 1;
  opacity: 0;
  transform: scale(.2);
  pointer-events: none;
}
.st-star--1 { top: -14px; left: -18px; color: $stage-orange; }
.st-star--2 { top: -20px; right: -14px; }
.st-star--3 { bottom: -16px; left: 40%; color: $stage-orange; }

@keyframes st-twinkle {
  0%, 100% { opacity: 0; transform: scale(.2) rotate(0deg); }
  50%      { opacity: 1; transform: scale(1) rotate(70deg); }
}

.st-wrap.is-demo .st-star { animation: st-twinkle $duration $easing infinite; }
.st-star--2 { animation-delay: 260ms; }
.st-star--3 { animation-delay: 480ms; }

05花火ドット

中心の点1つが--angleカスタムプロパティを8等分して持つ点8個に分かれ、rotate(var(--angle))translateYだけで花火のように広がりながら消えていきます。マークアップは空のspan8個だけで、位置の計算はすべてSCSS側が肩代わりします。

custom propertyrotate()+translateY1s
// 점 8개가 --angle 을 30~360deg 로 나눠 갖고, rotate + translateY 로 불꽃처럼 퍼진다
@for $i from 1 through 8 {
  .fw-dot:nth-child(#{$i}) {
    background: nth($piece-colors, ($i - 1) % 4 + 1);
    --angle: #{$i * 45deg};
  }
}

@keyframes fw-burst {
  0%   { transform: rotate(var(--angle)) translateY(0) scale(1); opacity: 1; }
  70%  { opacity: 1; }
  100% { transform: rotate(var(--angle)) translateY(-92px) scale(.3); opacity: 0; }
}

06絵文字ポップ

応援ボタンを押すと絵文字3個が、それぞれ違う遅延でtranslateYscaleを重ねたキーフレームに乗って跳ね上がり、やがて消えます。もう一度押すと、01番と同じ仕組みの短いJavaScriptがアニメーションを再生し直します。

translateY+scalestaggered delay700ms
@keyframes ep-pop {
  0%   { transform: translateY(0) scale(.4); opacity: 0; }
  30%  { opacity: 1; }
  100% { transform: translateY(-64px) scale(1.1); opacity: 0; }
}

// 실사용: 클릭이 is-pop 을 다시 붙인다 (위 <script>)
.ep-wrap.is-pop .ep-emoji,
.ep-wrap.is-demo .ep-emoji { animation: ep-pop $duration $easing infinite; }
.ep-emoji--2 { animation-delay: 110ms; }
.ep-emoji--3 { animation-delay: 220ms; }

07擬似要素だけの紙吹雪

完了カードのマークアップに要素を一つも追加せず、::before::afterの2つだけにbox-shadowで点を6個ずつ描いて落とします。二つの疑似要素は開始位置と遅延だけを変えてあるので、重なって見えることはありません。

::before/::afterbox-shadow 다중점1.2s
// 마크업엔 요소를 하나도 더하지 않는다 — ::before·::after 각각 box-shadow 로 점 6개씩을 그린다
.pc-card::before,
.pc-card::after {
  content: "";
  position: absolute;
  top: -18px;
  left: 14px;
  width: 6px;
  height: 6px;
  border-radius: $r-pill;
  background: $color;
  box-shadow:
    22px -8px 0 -1px $stage-yellow,
    44px 6px  0 0  $stage-orange,
    66px -12px 0 -1px $subject-cream,
    88px 4px  0 0  $color,
    110px -6px 0 -1px $stage-yellow,
    132px 8px 0 0  $subject-cream;
  opacity: 0;
}

08:has(:checked)でお祝いトリガー

チェックボックスを押して「やることリスト」の完了を示すと、JavaScriptなしで親のlabel:has(.hc-check:checked)で状態を読み取り、紙吹雪8個をまとめて発動させます。チェックを外すとトリガーの条件が偽に戻るため、keyframesは再び発動しません。

:has():checked800ms
@keyframes hc-fly {
  0%   { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(.4); opacity: 0; }
  20%  { opacity: 1; }
  100% { transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-58px) scale(1); opacity: 0; }
}

.hc-wrap:has(.hc-check:checked) .hc-piece,
.hc-wrap.is-demo .hc-piece { animation: hc-fly $duration $easing infinite; }

09カーソルキラキラトレイル

バナーの上でマウスを動かすと、その座標が--mx--myに保存され、6つのキラキラがそれぞれ自分のオフセットを足したtranslate()でその点を追いかけながら点滅します。--mx--my@property<length>として登録しておかないと、translate()の値が滑らかに補間されずカクついて動きます。

pointermovecustom propertytranslate()
@keyframes sc-twinkle {
  0%, 100% { transform: translate(calc(var(--mx) + var(--ox)), calc(var(--my) + var(--oy))) translate(-50%, -50%) scale(0); opacity: 0; }
  50%      { transform: translate(calc(var(--mx) + var(--ox)), calc(var(--my) + var(--oy))) translate(-50%, -50%) scale(1); opacity: 1; }
}

// 캡처용: is-demo 는 --mx·--my 자체를 원을 그리며 돈다 (헤드리스는 pointermove 를 못 낸다)
@keyframes sc-drift {
  0%   { --mx: 44px;  --my: 40px; }
  25%  { --mx: 236px; --my: 34px; }
  50%  { --mx: 228px; --my: 140px; }
  75%  { --mx: 52px;  --my: 132px; }
  100% { --mx: 44px;  --my: 40px; }
}

.sc-wrap.is-demo { animation: sc-drift 2s linear infinite; }
.sc-wrap .sc-piece { animation: sc-twinkle $duration $easing infinite; }

紙吹雪アニメーションはどこで壊れるか

9個の中で最初につまずいたのは02番でした。紙吹雪18個にanimation-delayだけを変えて出発をずらしたところ、topの開始位置が全部そろっていたせいで一列に固まったまま同じ対角線を描いて落ちてしまいました。横位置(left)の並び順とは違う順番でtopの開始値を散らして、ようやく本物の雨のように見えるようになりました。もう一つ気づきにくかったのがanimationのショートハンドです。まとめてanimation: cr-fall $duration $easing infinite;のように書いてしまうと、その行はnth-childで1つずつ指定したanimation-durationより詳細度が高くなるため、せっかく散らした速さをすべて同じ値で上書きしてしまいます。02番ではanimation-nameanimation-timing-functionanimation-iteration-countだけをまとめて指定し、animation-durationnth-child側に残すことで、18個それぞれの速さを保っています。08番の:has()と09番の@propertyも比較的新しい機能で、Chrome・Edge・最新のSafariとFirefoxでは問題なく動きますが、対応していない古いブラウザではトリガーそのものが発動しなかったり、座標が滑らかに補間されずカクついたりします。zipを解凍するときに求められるパスワードはbjwrdptnで、この段落の文章をゆっくり読めば見つかります。

アクセシビリティ — 9個がreduced-motionで残すもの

OSの「視差効果を減らす」設定がオンになっていると、9個すべてでprefers-reduced-motion: reduceのメディアクエリが働き、アニメーションとトランジションが止まります。01・02・05・06・07のような装飾目的の紙吹雪は、止まった状態のままでも情報が欠けることはありません。03・04の点滅や09番のカーソルトレイルも、動いているかどうか自体が意味を持つわけではないので、消してもボタンや文字の内容はそのまま読めます。08番は:has(:checked)がアニメーションと関係なく状態を読み取るので、動きを減らしてもチェックボックスの完了表示そのものは消えません。

似たようなカード型の反応を探しているならCSSベントグリッドホバーアニメーション9選もあわせてご覧ください。ほかのCSSアニメーションのまとめはCSSカテゴリーから探せます。

FAQ — よくある質問

紙吹雪はJavaScriptがないと使えませんか?

いいえ、08番の:has(:checked)のようにチェックボックスの状態だけでトリガーをかける項目はJavaScriptが一行も要りません。05番の花火ドットや07番の疑似要素の紙吹雪もCSSだけで自動的に繰り返されます。01・06・09番のようにクリックやマウス座標が必要な項目だけ、短いJavaScriptを使います。

紙吹雪の個数を増やすと重くなりますか?

12〜18個程度ならtransformopacityだけを動かしているのでレイアウトの再計算が発生せず、ほとんどの端末で滑らかに動きます。ただし数十個以上に大きく増やすと合成レイヤーが増え、性能の低い端末では体感のフレームレートが落ちることがあるため、画面に同時に見える個数は20個前後を目安にするのがおすすめです。

zipの中のvanillaとReactのコードは動きが違いますか?

いいえ、見た目の動きは同じです。vanilla側はSCSS変数($duration$easing$colorなど項目ごとに対応する変数)をファイル冒頭にまとめており、React版ではその値をコンポーネントのpropsとして受け取り、内部でCSSカスタムプロパティに変換して渡す仕組みだけが異なります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。