GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

css ボタン ホバー アニメーション 9選 — 受賞級の定番テク

css ボタン ホバー アニメーションとは、ポインターが近づいたときにボタンが返す反応を、合図の種類ごとに整理したものです。9 つのうち 1 つは、押すとボタンが円になるまで縮んでスピナーが回り、チェックを描いてから元の幅へ戻ります。

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

デザイン賞を受賞したサイトをよく見ると、ボタンの演出はばらばらに見えても、使われる場所はいつも同じです。そこで人気順ではなく、ボタンがユーザーと出会う順番で並べました。まず離れた場所から気づかれ(01・02)、css ボタン ホバー エフェクトとして近づくポインターに応え(03)、狙っている間に表面・文字・アイコンが変わり(04・05・06)、押した瞬間に手応えを返し(07・08)、最後に待っている間の状態を知らせます(09)。特定のブランドやサイトをまねたものではなく技法だけを取り出しているので、9 つとも自社の色とテキストに差し替えて使えます。ホバー前の基本の見た目を決める話はcss ボタン デザインが先で、指を離した後に状態が変わる区間はcss ボタン 押したら変わるが引き継ぎます。

01カーソル スポットライト

css ボタン ホバー デザインとして何が見えるか

丸い光がポインターを追って、ボタンの表面をなぞります。動かしているのは光の中心の横位置だけで、その値を --mx として渡せば、あとはグラデーションが描いてくれます。

カーソル スポットライト
新しいタブで開く

01 · カーソル スポットライト — 計測 累積面積 7.528% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

radial-gradient@propertypointermove

コードのどこを見るか

この変数を @property で登録しておくのがポイントです。

@property --mx {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 50%;
}
.spot::before {
  background: radial-gradient(circle 96px at var(--mx) 50%, rgba($color, .95), rgba($color, 0) 72%);
}
@keyframes spot-sweep {
  0%   { --mx: 8%; }
  50%  { --mx: 92%; }
  100% { --mx: 8%; }
}

02ボーダーを走る光

画面で起きること

明るい点が 1 つ、縁を継ぎ目なく何周も回ります。

ボーダーを走る光
新しいタブで開く

02 · ボーダーを走る光 — 計測 累積面積 1.381% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

conic-gradientmask-composite@property --angle

コードの読み方

conic-gradient 全体を回転させ、枠線の太さの分だけマスクで残す作りなので、角丸でも長方形でも同じ記述のまま形に追従します。

.glow::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: $sp-1;
  background: conic-gradient(from var(--angle),
    rgba($color, 0) 0deg,
    rgba($color, 0) 232deg,
    rgba($color, 1) 300deg,
    rgba($color, .2) 330deg,
    rgba($color, 0) 360deg);
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  animation: glow-run $duration $easing infinite;
}

03マグネット吸着 + ラベル逆視差

どんな動きか

ボタンはポインターに引き寄せられ、中の文字は同じ座標に負の数を掛けて逆方向へずれます。

マグネット吸着 + ラベル逆視差
新しいタブで開く

03 · マグネット吸着 + ラベル逆視差 — 計測 累積面積 10.698% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translate역시차pointerleave

どこを変えるか

この符号 1 つで 2 つのレイヤーが分かれ、平面的なボタンがわずかに浮き上がって見えます。

field.addEventListener('pointermove', function (e) {
  var r = btn.getBoundingClientRect();
  var dx = (e.clientX - (r.left + r.width / 2)) * 0.35;
  var dy = (e.clientY - (r.top + r.height / 2)) * 0.35;
  btn.classList.remove('is-demo');
  btn.style.translate = dx + 'px ' + dy + 'px';
  label.style.translate = (-dx * 0.4) + 'px ' + (-dy * 0.4) + 'px';
});

04リキッド充填(波の水面)

何が見えるか

下から色が満ちていきますが、水面は平らにならず、水のように傾きます。

リキッド充填(波の水面)
新しいタブで開く

04 · リキッド充填(波の水面) — 計測 累積面積 6.315% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

물결 타일translateoverflow

コードのどこを見るか

円ではなく、四隅を 42% だけ丸めた 260px の正方形を回しながら同時に持ち上げているため、水面の角度が一周のあいだ変わり続けます。

.liq__wave {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 260px;
  height: 260px;
  border-radius: 42%;
  background: $color;
  transform: translate(-50%, 100%);
}
@keyframes liq-rise {
  0%   { transform: translate(-50%, 100%) rotate(0deg); }
  52%  { transform: translate(-50%, 68%) rotate(180deg); }
  66%  { transform: translate(-50%, 68%) rotate(230deg); }
  100% { transform: translate(-50%, 100%) rotate(360deg); }
}

05文字分割スライド

画面で起きること

文字が 1 つずつ上へ抜け、同じ文字が下から上がってきて入れ替わります。

文字分割スライド
新しいタブで開く

05 · 文字分割スライド — 計測 累積面積 2.35% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

글자별 지연translate: 0 -50%clip

コードの読み方

文字ごとに overflow: hidden の窓を作り、その中に同じ文字を 2 枚重ねておき、遅延を文字番号に比例させることで、文字ごとの別々の動きが 1 つの波になります。

.split__ch {
  display: inline-block;
  overflow: hidden;
  height: 1.25em;
  line-height: 1.25;
}
.split.is-demo .split__ch b {
  animation: split-roll $duration $easing infinite;
  animation-delay: calc(var(--i) * #{$stagger});
}
@keyframes split-roll {
  0%,  12% { translate: 0 0; }
  42%, 68% { translate: 0 -50%; }
  100%     { translate: 0 0; }
}

06アイコン回転入れ替え

どんな動きか

アイコンが Y 軸まわりに半回転し、裏にあった別のアイコンへ入れ替わります。

アイコン回転入れ替え
新しいタブで開く

06 · アイコン回転入れ替え — 計測 累積面積 0.485% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rotateYbackface-visibilityPhosphor

どこを変えるか

2 つのアイコンを同じ枠に重ね、それぞれの裏面だけを隠す作りなので、「追加」から「追加済み」へ意味が変わるときもラベルを書き換えずに済みます。

.swap__ic {
  position: relative;
  width: 34px;
  height: 34px;
  transform-style: preserve-3d;
}
.swap__face { position: absolute; inset: 0; display: flex; backface-visibility: hidden; }
.swap__face--back { transform: rotateY(180deg); }
@keyframes swap-spin {
  0%,  22%  { transform: rotateY(0deg); }
  50%, 72%  { transform: rotateY(180deg); }
  100%      { transform: rotateY(360deg); }
}

07押し込みの反動(スクワッシュ)

何が見えるか

押した瞬間に縦へ潰れて横へ広がり、離すと大きく跳ね戻り、小さくもう一度揺れてから収まります。

押し込みの反動(スクワッシュ)
新しいタブで開く

07 · 押し込みの反動(スクワッシュ) — 計測 累積面積 5.59% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scale$ease-popbox-shadow

コードのどこを見るか

同じタイミングで影を弱めるのが残り半分の仕掛けで、拡大縮小だけではバグのように見え、影を添えるとゴムのようにたわんで見えます。

.squash { transform-origin: center bottom; }
@keyframes squash-press {
  0%,  34%  { transform: scale(1, 1);      box-shadow: $shadow-raised; }
  46%       { transform: scale(1.06, .88); box-shadow: $shadow-press; }
  60%       { transform: scale(.97, 1.06); box-shadow: $shadow-raised; }
  72%       { transform: scale(1.02, .98); box-shadow: $shadow-raised; }
  86%, 100% { transform: scale(1, 1);      box-shadow: $shadow-raised; }
}
.squash:active {
  animation: none;
  transform: scale(1.06, .88);
  box-shadow: $shadow-press;
}

08コーナーブラケットの照準

画面で起きること

四隅の L 字パーツが外から集まって枠を完成させます。

コーナーブラケットの照準
新しいタブで開く

08 · コーナーブラケットの照準 — 計測 累積面積 2.501% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translatefocus-visible순차 지연

コードの読み方

隅ごとに違うのは削る 2 辺と進む方向だけで、移動距離は 4 つとも 18px に揃えてあるため、枠を広げるのも狭めるのも数値 1 つで済みます。

.brk__c {
  position: absolute;
  width: 22px;
  height: 22px;
  border: 3px solid $color;
  --tx: -18px;
  --ty: -18px;
}
.brk__c--tr { top: -8px; right: -8px; border-left: 0; border-bottom: 0; --tx: 18px; --i: 1; }
.brk.is-demo .brk__c {
  animation: brk-close $duration $easing infinite;
  animation-delay: calc(var(--i) * 60ms);
}
@keyframes brk-close {
  0%       { translate: var(--tx) var(--ty); opacity: 0; }
  38%, 66% { translate: 0 0;                 opacity: 1; }
  100%     { translate: var(--tx) var(--ty); opacity: 0; }
}

09ローディングモーフ(ボタン→スピナー→チェック)

どんな動きか

押すと文字が消え、ボタンが円になるまで縮んでスピナーが回り、チェックを描いてから元の幅へ戻ります。

ローディングモーフ(ボタン→スピナー→チェック)
新しいタブで開く

09 · ローディングモーフ(ボタン→スピナー→チェック) — 計測 累積面積 6.331% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

widthstroke-dashoffsetaria-busy

どこを変えるか

3 つの状態が 1 つの要素の中でつながるので、別のローディング画面を出す必要がなく、処理中の二重送信も防げます。

@keyframes morph-box {
  0%,  10%  { width: 200px; }
  28%, 62%  { width: 56px; }
  84%, 100% { width: 200px; }
}
@keyframes morph-draw {
  0%,  60%  { stroke-dashoffset: 26; opacity: 0; }
  64%       { stroke-dashoffset: 26; opacity: 1; }
  76%, 86%  { stroke-dashoffset: 0;  opacity: 1; }
  94%, 100% { stroke-dashoffset: 26; opacity: 0; }
}
.morph.is-busy { width: 56px; }
.morph.is-busy .morph__label { opacity: 0; }
.morph.is-busy .morph__spin { opacity: 1; animation: morph-turn 800ms linear infinite; }

どこで壊れるか — 落とし穴

9 つの中でいちばん手こずったのは、01 と 02 がまったく動かなかったことです。--mx や --angle をキーフレームに書けば滑らかに変わるはずだと思っていましたが、ブラウザは登録されていないカスタムプロパティをただの文字列として扱います。文字列には中間値がないので値は途中を飛ばし、24 フレーム書き出してみると光が 2 か所に止まったまま消えていました。@property で syntax: "<percentage>" や "<angle>" と型を宣言して、ようやくランナーが一周しました。

2 つ目は、ループの終わりと始まりがずれる継ぎ目です。02 のランナーを 340deg で切って 0deg に戻したところ、一周ごとに光が飛んで見えたため、角度をきっちり 360deg まで回して次の周の 0deg とつなげました。06 のアイコンも 180deg で止めて巻き戻すと裏面が手前へ滑って見えたので、こちらも 360deg まで続けています。04 は逆に速すぎました。最初は 320px の水滴を使っていたため、レンダリングの 5 フレーム目にはボタンが満ちてしまい波打つ水面が写らず、260px に縮めて上昇の区間を全体の 52% まで伸ばして、ようやく曲がった水面が文字を横切る場面をフレームに残せました。05 は窓の高さを 1.1em にしたせいで文字の下が切れてしまい、1.25em に上げています。ここまで直して、9 つとも 320px 幅の検査をはみ出し 0 で通りました。意図的な例外は 09 だけで、ここは transform ではなく width を動かします。レイアウトを再計算させる値なので普段は避けるべきですが、ピル型のボタンを本当の円にするには幅を縮めるしかないため、overflow: hidden と 1 マスのグリッドで文字が歪まないように押さえています。ボタン一つが待ち時間を示し始めると、画面全体がその役を担うローディング 画面 デザインと同じ問題に移ります。解凍パスワード cpm3zu6e で開く zip の中身にも、ここまでの修正がそのまま入っているので、ダウンロードしたファイルを開けば記事に載せたコードと同じものが読めます。

アクセシビリティ

prefers-reduced-motion: reduce では 9 つとも装飾のループを止めますが、合図は残します。光は中央で止まり、ランナーは落ち着いた枠線 1 本になり、液体は満ちた状態、4 つのパーツは閉じた状態で静止します。08 はマウスなしでも使えるように :focus-visible にも同じ形を用意し、09 は待っている間 aria-busy="true" で状態を伝え、終わったら外します。

状態 動きが有効なとき reduced-motion のとき
狙う(01・02・04・05・06) 光・液体・文字が 2 秒周期で回る 最終形で静止し、色のコントラストはそのまま
押す(03・07) 吸着と押し込みの反動が続く 移動も拡大縮小もなく、色と影だけ
フォーカス・待機(08・09) パーツが集まりチェックが描かれる パーツは閉じたまま、チェックは即座に表示

カスタムプロパティをアニメーションさせるには先に型の登録が必要だというルールは、MDN の @property 解説にまとまっています。同じトリガーで動く効果はホバーのカテゴリに、申し込みボタン向けのものはCTA のカテゴリに集めてあります。

FAQ

ボタン アニメーション css のうち JavaScript なしで使えるのはいくつですか

9 つのうち 7 つは CSS だけで完結します。実際のポインター座標が必要なのは 01 と 03 だけで、どちらも短いバニラ JavaScript を数行書くだけです。その数行がしているのも CSS 変数に値を入れることだけなので、アニメーションライブラリもビルド工程も必要ありません。受賞級のテクを一行まで削った版はマイクロインタラクション CSSに集めてあります。

タッチ端末にはホバーがありませんが、そのまま使えますか

ホバーが起点になる 5 つ(01・02・04・05・06)はタップでは何も起きないため、タッチ主体の画面では 07 のように :active でも同じ結果が得られるものと組み合わせるのが安全です。07 と 09 は押下と待機の状態を使うので指でも同じように動き、08 は :focus-visible でキーボード操作にも対応します。

色と速度を自社ブランドに合わせるにはどこを直しますか

各 SCSS ファイル冒頭の 3 行、$duration・$easing・$color(05 だけは $color の代わりに $stagger)が調整ポイントのすべてです。React 版は同じ 3 つを props で受け取って CSS カスタムプロパティへ渡すので、呼び出すときに duration={1200} のように書くだけで済み、ファイルの他の部分に触れる必要はありません。

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

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