css ボタン ホバー アニメーション 9選 — 受賞級の定番テク
css ボタン ホバー アニメーションとは、ポインターが近づいたときにボタンが返す反応を、合図の種類ごとに整理したものです。9 つのうち 1 つは、押すとボタンが円になるまで縮んでスピナーが回り、チェックを描いてから元の幅へ戻ります。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 カーソル スポットライト
- 02 ボーダーを走る光
- 03 マグネット吸着 + ラベル逆視差
- 04 リキッド充填(波の水面)
- 05 文字分割スライド
- 06 アイコン回転入れ替え
- 07 押し込みの反動(スクワッシュ)
- 08 コーナーブラケットの照準
- 09 ローディングモーフ(ボタン→スピナー→チェック)
デザイン賞を受賞したサイトをよく見ると、ボタンの演出はばらばらに見えても、使われる場所はいつも同じです。そこで人気順ではなく、ボタンがユーザーと出会う順番で並べました。まず離れた場所から気づかれ(01・02)、近づくポインターに応え(03)、狙っている間に表面・文字・アイコンが変わり(04・05・06)、押した瞬間に手応えを返し(07・08)、最後に待っている間の状態を知らせます(09)。特定のブランドやサイトをまねたものではなく技法だけを取り出しているので、9 つとも自社の色とテキストに差し替えて使えます。
01カーソル スポットライト
丸い光がポインターを追って、ボタンの表面をなぞります。動かしているのは光の中心の横位置だけで、その値を --mx として渡せば、あとはグラデーションが描いてくれます。この変数を @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 つ、縁を継ぎ目なく何周も回ります。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マグネット吸着 + ラベル逆視差
ボタンはポインターに引き寄せられ、中の文字は同じ座標に負の数を掛けて逆方向へずれます。この符号 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リキッド充填(波の水面)
下から色が満ちていきますが、水面は平らにならず、水のように傾きます。円ではなく、四隅を 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 つずつ上へ抜け、同じ文字が下から上がってきて入れ替わります。文字ごとに 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 軸まわりに半回転し、裏にあった別のアイコンへ入れ替わります。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押し込みの反動(スクワッシュ)
押した瞬間に縦へ潰れて横へ広がり、離すと大きく跳ね戻り、小さくもう一度揺れてから収まります。同じタイミングで影を弱めるのが残り半分の仕掛けで、拡大縮小だけではバグのように見え、影を添えるとゴムのようにたわんで見えます。
.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 字パーツが外から集まって枠を完成させます。隅ごとに違うのは削る 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ローディングモーフ(ボタン→スピナー→チェック)
押すと文字が消え、ボタンが円になるまで縮んでスピナーが回り、チェックを描いてから元の幅へ戻ります。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
JavaScript なしで使えるのはいくつですか
9 つのうち 7 つは CSS だけで完結します。実際のポインター座標が必要なのは 01 と 03 だけで、どちらも短いバニラ JavaScript を数行書くだけです。その数行がしているのも 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} のように書くだけで済み、ファイルの他の部分に触れる必要はありません。