マイクロインタラクション CSS 9選 — 一行でコピペOK
マイクロインタラクション css は、ボタンを押す・保存する・タブを切り替える一つの操作に、画面がひと呼吸だけ反応して見せる小さなアニメーションです。この記事では9種類を選び、transitionと@keyframesだけで実装しました。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ボタンが押し込まれる
- 02 保存チェックアニメーション
- 03 コピー完了フィードバック
- 04 楽観的いいね(即時+ロールバック)
- 05 スケルトン→本文クロスフェード
- 06 空状態アイコンのバウンス
- 07 入力エラーで揺れる
- 08 タブインジケータースライド
- 09 トーストの積み重ね
並び順は派手さの順ではなく、反応がどこで完結するかです。まずボタン自身の見た目だけで完結する01〜03(押し込み・モーフ・アイコンスワップ)、次にサーバーの応答を待たず画面を先に書き換える04〜06、最後に要素の外側、入力欄の枠やタブの下線、通知の並びまで動く07〜09という順です。04〜06はAnimista・uiverseのようなライブラリではあまり見ない組み合わせを狙ったアイデア型として選びました。CSSだけで完結させるため、04のJavaScriptはis-likedというクラス名を付け外しするだけで、色や数値そのものは一行も触れていません。
01ボタンが押し込まれる
ボタンを押すとスケールが0.96まで縮み、指を離すとバネのように弾んで元の大きさへ戻ります。押した瞬間に影も薄くなるため、実際にクリックが受け付けられたという触覚に近い感覚を画面だけで伝えられます。送信ボタンやアイコンボタン、カード全体をクリック領域にした場合に向いています。
.btn {
transform: scale(1);
box-shadow: $shadow-raised;
transition: transform $duration $easing, box-shadow $duration $easing;
&:active {
transform: scale(.96);
box-shadow: $shadow-press;
}
}
@keyframes press-loop {
0%, 12% { transform: scale(1); box-shadow: $shadow-raised; }
22% { transform: scale(.96); box-shadow: $shadow-press; }
38%, 100% { transform: scale(1); box-shadow: $shadow-raised; }
}
02保存アイコンがチェックに変わる
保存ボタンを押すとフロッピー形状のアイコンがopacityとscaleだけで姿を消し、入れ替わりにチェックの2本の線がscaleXで描かれて完了を伝えます。背景の丸いバッジも同時にポップして色でも完了を強調します。フォームの自動保存ボタンやメモアプリの保存状態表示に向いています。
.save__check-bar {
height: 3px;
background: currentColor;
transform: scaleX(0);
transform-origin: left center;
}
.save.is-saved .save__check-bar--short {
animation: check-short $duration $easing forwards;
animation-delay: $duration * .35;
}
@keyframes check-short {
to { transform: rotate(45deg) scaleX(1); }
}
@keyframes disk-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(.5); }
}
03コピー完了フィードバック
コピーアイコンを押すと二重に重なった用紙のアイコンが縮んで消え、同じ場所にチェックの線が現れます。同時にボタンの上へ小さなツールチップが浮かび上がり、1.2秒表示されてから上へフェードアウトします。コードブロックのコピーボタンやAPIキーのコピーボタンに向いています。
.copy.is-copied .copy__sheet--front {
opacity: 0;
transform: scale(.6);
}
.copy.is-copied .copy__tip {
animation: tip-float 1.4s $easing forwards;
}
@keyframes tip-float {
0% { opacity: 0; transform: translate(-50%, 4px); }
15% { opacity: 1; transform: translate(-50%, -2px); }
85% { opacity: 1; transform: translate(-50%, -2px); }
100% { opacity: 0; transform: translate(-50%, -10px); }
}
04楽観的いいね(即時+ロールバック)
ハートを押すとサーバーの応答を待たずに即座に赤く塗りつぶされ、数字のカウントも1つ繰り上がります。このデモは失敗を仮定したループになっていて、赤くなってから0.8秒後にはハートも数字も元の状態へロールバックします。SNSのいいねボタンやブックマークトグルに向いています。
var b = document.querySelector('.like');
b.addEventListener('click', function () {
var liked = b.classList.toggle('is-liked');
b.setAttribute('aria-pressed', liked);
if (liked) {
setTimeout(function () {
b.classList.remove('is-liked');
b.setAttribute('aria-pressed', 'false');
}, 800);
}
});
05スケルトン→本文クロスフェード
グレーのスケルトンブロックと実際のテキスト・アバター画像が同じ位置に重なっていて、読み込みが終わるとopacityとscaleが入れ替わってクロスフェードします。位置がずれないため、読み込み完了の瞬間にレイアウトが跳ねません。カード一覧の読み込みやプロフィールページの初期表示に向いています。
.card__layer--skeleton {
opacity: 1;
transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.card__layer--content {
opacity: 0;
transform: scale(.98);
transition: opacity $duration $easing, transform $duration $easing;
}
.card.is-demo .card__layer--skeleton { animation: sk-fade $dur-loop $easing infinite; }
.card.is-demo .card__layer--content { animation: content-fade $dur-loop $easing infinite; }
@keyframes content-fade {
0%, 12% { opacity: 0; transform: scale(.98); }
55%, 100% { opacity: 1; transform: scale(1); }
}
06空状態アイコンのバウンス
検索結果や一覧が空のときに表示されるトレイのアイコンが、上下にゆっくりバウンスし続けます。動きが止まらないことで、この空白が読み込みエラーではなく意図された状態だと伝えます。検索結果なし画面や空のカート画面に向いています。
.empty__icon {
transform: translateY(0);
}
.empty.is-demo .empty__icon {
animation: bounce $duration $easing infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
07入力エラーで揺れる
不正な値を送信すると入力欄の枠に赤いリングが重なって現れ、枠全体が左右に短く揺れます。揺れの前後で速度を変えることで、機械的な単純往復ではなく実際に振動しているような手触りになります。ログインフォームのパスワード欄や決済フォームのカード番号入力に向いています。
.field__ring {
border: 2.5px solid $error;
opacity: 0;
}
.field.is-error .field__frame { animation: shake $duration $easing both; }
.field.is-error .field__ring { opacity: 1; }
@keyframes shake {
10%, 90% { transform: translateX(-$amount * .5); }
20%, 80% { transform: translateX($amount); }
30%, 70% { transform: translateX(-$amount); }
40%, 60% { transform: translateX($amount * .7); }
50% { transform: translateX(-$amount * .7); }
}
08タブインジケータースライド
タブをクリックするとカスタムプロパティ--iにインデックスが書き込まれ、下線のインジケーターがtranslateXで前のタブから次のタブの位置まで滑ります。MDNのCSSカスタムプロパティの解説にあるとおりモダンブラウザ全般で使える仕組みですが、対応していない古いブラウザでは--iが反映されず、インジケーターは常に最初のタブの位置のまま動きません。設定ページのタブやダッシュボードのカテゴリタブに向いています。
.tabs {
--i: 0;
}
.tabs__indicator {
width: calc((100% - 8px) / 3);
transform: translateX(calc(var(--i, 0) * 100%));
transition: transform $duration $easing;
}
09トーストの積み重ね
新しいトーストが出るたびに、既存のトーストが1段ずつ上へ押し出されながらわずかに縮み、opacityも少しずつ下がります。一番新しいトーストだけ背景色を変えているため、複数の通知が重なっても今どれが最新かひと目でわかります。保存完了通知や複数ファイルアップロードの進捗通知に向いています。
.toast {
opacity: 0;
transform: translateY(16px);
}
.toast--3 { background: $color; color: $subject-cream; }
@keyframes slot-3 {
0%, 70% { opacity: 0; transform: translateY(16px) scale(1); }
80%, 92% { opacity: 1; transform: translateY(0) scale(1); }
98%, 100% { opacity: 0; transform: translateY(0) scale(1); }
}
どこで崩れるか — 落とし穴
9つとも状態の変化をtransformとopacityだけで表していて、位置やサイズの数値を直接書き換えるコードは使っていません。1つ目の落とし穴は04を組み立てているときに踏んだもので、ハートを連打すると0.8秒後のロールバックが二重に走り、赤色と数字が一瞬でちらついて見えました。setTimeoutを呼ぶ前に既存のタイマーをclearTimeoutで消してから、この二重発火は直りました。
2つ目は08のタブを作ったときのことです。3つのタブの文字数を揃えずに試すと、それぞれの幅が微妙にずれてcalc((100% - 8px) / 3)で計算した位置とインジケーターの実際の位置が合わなくなり、下線がタブの真下からはみ出して止まって見えました。grid-template-columnsで3列を強制的に同じ幅にしてから、この崩れは直りました。修正済みの9ファイルを開くには dawuywyc という文字列が必要です。下のカードからzipを取ってください。
3つ目は09のトーストを組み立てたときのことです。リストの高さを固定していない状態で試すと、新しいトーストが増えるたびにコンテナ自体の高さが伸び縮みし、下にある要素がそのたびに位置を変えてがたつきました。heightを固定してからは、コンテナの外側は動かず内側のトーストだけが積み重なるようになりました。
アクセシビリティ
9つとも prefers-reduced-motion: reduce で動きが止まりますが、要素そのものは消えません。01のボタンは影も含めて:active時の見た目のまま止まり、04のハートと数字は最終的な色と値を保った状態でロールバックのアニメーションだけを省略します。08のタブインジケーターはtransitionだけを切り、クリックした瞬間に新しい位置へ即座に移動します。
@media (prefers-reduced-motion: reduce) {
.btn, .save__ring, .save__disk, .save__check-bar,
.copy__ring, .copy__sheet, .copy__check-bar, .copy__tip,
.like__heart--fill, .like__count-track,
.card__layer--skeleton, .card__layer--content,
.empty__icon, .field__frame, .field__ring, .field__msg,
.tabs__indicator, .toast {
transition: none !important;
animation: none !important;
}
}
ボタンや入力欄以外のCSSアニメーションはこちらのカテゴリー一覧に、このサイトの成り立ちは紹介ページにまとめています。
FAQ
ボタンを押した瞬間の動きは、モバイルのタップでも同じように出ますか?
はい、01の:active擬似クラスと04・07・08のJavaScriptイベントは、どちらもタッチデバイスのpointerdownやclickでも同じように発火します。ただしiOSのSafariでは:activeがタップ直後の一瞬しか適用されないことがあり(MDN の :active の解説にタッチ端末での挙動の注記があります)、その場合は04や08のようにpointerdownでクラスを付け外しする方式のほうが安定して動きます。zipのvanilla/フォルダの各index.htmlは、この違いを踏まえてボタン系(01・02・03・04)にクリックイベントを使っています。
9個の状態はReactではどう管理しますか?
zipのreact/フォルダの各コンポーネントは、これまでと同じクラス名をdiv要素やbutton要素に付けているだけで、動きの速さと曲線はCSSのanimationとtransitionが担います。04のいいねボタンと07の入力エラー、09のトースト一覧だけはuseStateで状態(liked・error・トースト配列)を持ち、そのstateに応じてis-likedやis-errorに相当するクラス名を付け外しします。08のタブは選択中のインデックスをstateに持ち、インラインスタイルではなく--iカスタムプロパティへ渡しています。
04の「先に画面を更新してあとで戻す」という考え方はどんな場面に向いていますか?
04の考え方は、サーバーの応答を待つ間もユーザーの操作を止めたくない場面に向いています。いいねやブックマークのように、失敗しても押し直せば済むうえ見た目の被害が小さい操作に向いていて、送金や削除のように取り返しがつかない操作には使うべきではありません。今回のデモは失敗を仮定したロールバックのみを再現していますが、実際のアプリでは成功した場合にサーバーからの返り値で数字を上書きする処理が別途必要です。