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

CSS 入力フォーム フォーカスアニメーション9選 一行でコピペOK

css 入力フォームのアニメーションは、クリックする前・クリックした瞬間・値を入力し終えたあとまで、ブラウザがすでに持っている:focus:focus-within:placeholder-shown:valid:invalidという状態をそのまま動きに変換する作業です。

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

9つとも本物の<input>を使い、「入力フォーム css おしゃれ」のような派手さの順ではなく入力欄が必要とする判定が増える順に並べました。01・02・04・05は:focus``:focus-within``:placeholder-shownだけで完結し、03はそこに:has()を重ねて離れた要素の状態を検知し、06はブラウザ自身のHTML5検証(:valid/:invalid)が判定を肩代わりし、07〜09はクリックやキー入力のたびに状態が切り替わる場面なので、リストを開く・文字数を数える・目アイコンを切り替えるというCSSの状態だけでは無理な3か所にだけ最小限のJavaScriptが入ります。ここの9つは利用者が入力欄に触れたときだけ反応しますが、触れなくても回り続ける@keyframes側の9つはCSS キーフレーム アニメーション 9選に別途まとめています。08の文字数カウントや09の目アイコンのように入力欄へ添える印そのものを線として描いて動かしたい場合は、CSS アイコン アニメーション 9選のSVGの組み方がこの9つとそのまま噛み合います。

01フローティングラベル

ラベルが浮かぶ条件

inputの後ろに兄弟として置いたlabelが、:focusになるか値が入って:not(:placeholder-shown)になった瞬間に左上へ浮かび上がって縮小します。プレースホルダーとラベルを一つの要素にまとめるので、狭いフォームでラベル専用の行を一本節約できます。

浮上にはcubic-bezier(.3, 1.6, .4, 1)のスプリングオーバーシュートが付いていて、目標位置を通り過ぎてから戻って落ち着きます。同じ瞬間、入力枠の縁から外へ広がるradialグローが一度だけ流れます。デモの自動再生も.focus()と.blur()を行き来する3行のJSがあるだけで、実際のページでは値を打つかタブキーでフォーカスするだけで同じラベルが浮かびます。

フローティングラベル
新しいタブで開く

01 · フローティングラベル — 計測 累積面積 7.982% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

:placeholder-showntransform scale220ms

デモの自動再生の仕組み

.fl__label {
  position: absolute;
  left: $sp-4;
  top: 50%;
  transform: translateY(-50%) scale(1);
  transform-origin: left center;
  transition: transform $duration $easing, color $duration $easing;
}

.fl__input:focus ~ .fl__label,
.fl__input:not(:placeholder-shown) ~ .fl__label {
  transform: translateY(-24px) scale(.78);
  color: $color;
}

02フォーカスで下線が伸びる

下線の役目を持つspan一本

枠線のないinputの下に置いたspan一本が下線の役目を持ち、親の.ugが:focus-withinになるとtransform-origin: centerからscaleX(0)がscaleX(1)まで育ちます。

下線はscaleX(1)を通り越して一度伸びきってから巻き戻るスプリングオーバーシュート(cubic-bezier(.2, 1.5, .35, 1))で伸び、フォーカスの瞬間に下線の下へ楕円形のradialグローが一度だけ広がります。見た目のラベル文はinputの上に別のテキストとして置いてあります。グリッドの中では.focus()と.blur()を往復する3行のJSがループさせているだけで、実際のフォームでは中のinputをクリックするかタブで移動するだけで下線が伸びます。

フォーカスで下線が伸びる
新しいタブで開く

02 · フォーカスで下線が伸びる — 計測 累積面積 1.592% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

:focus-withinscaleX300ms

グリッドのループと実際のフォーム

.ug__underline {
  height: 2px;
  background: $color;
  transform: scaleX(0);
  transform-origin: center;
  transition: transform $duration $easing;
}

.ug:focus-within .ug__underline { transform: scaleX(1); }

03:has(:focus)でグロー

グローリングはどこにあるか

グローリングはinputの兄弟ではなく.fgの外側に別に置かれたspanで、親が:has(.fg__input:focus)で子の状態を検知してglow-pulseという@keyframesをopacityとscaleで無限に繰り返します。

突入時にはまずglow-inがscale(.8)から1.08を経て.92へ巻き込むスプリング進入を一度だけ演じ、その着地位置から呼吸のループが引き継ぎます。フォーカスが外れると呼吸も止まります。デモのループも.focus()と.blur()を切り替える3行のJSで再生していて、:has()対応ブラウザなら実際のページでもタブ移動だけでリングが呼吸を始めます。

:has(:focus)でグロー
新しいタブで開く

03 · :has(:focus)でグロー — 計測 累積面積 6.35% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

:has()opacity scale2s 루프

対応ブラウザでの動き

.fg__glow {
  border: 2px solid $color;
  opacity: 0;
  transform: scale(.92);
}

.fg:has(.fg__input:focus) .fg__glow {
  animation: glow-pulse $duration $easing infinite;
}

@keyframes glow-pulse {
  0%, 100% { opacity: .35; transform: scale(.92); }
  50%      { opacity: .9;  transform: scale(1.04); }
}

04検索ボックス拡張

「css 検索ボックス」が広がる幅

ピル型のlabel.seは普段148pxに折りたたまれていて、.se:focus-withinになった瞬間に240pxまで広がります。

幅は240pxを少し行き過ぎてから整うスプリングカーブ(cubic-bezier(.3, 1.4, .4, 1))で広がり、広がる瞬間にピルの内側でradialグローが一度だけ咲きます。左のアイコンは円と線一本だけで描いたインラインSVGなので、アイコンフォントを読み込まなくても欠けません。自動再生も.focus()と.blur()を交互に呼ぶ3行のJSだけで動いていて、実際のヘッダーではクリックかタブでの移動がそのままきっかけになります。

検索ボックス拡張
新しいタブで開く

04 · 検索ボックス拡張 — 計測 累積面積 7.744% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

:focus-withinwidth300ms

自動再生のきっかけ

.se {
  width: 148px;
  transition: width $duration $easing, border-color $duration $easing;
}

.se:focus-within { width: 240px; border-color: $color; }

05プレースホルダースライド

placeholderを空欄1文字にする

実際のplaceholder属性は空欄1文字だけにして、重ねたspanが案内文の表示を代わりに担います。inputが:focusになるか値が入って:not(:placeholder-shown)になると、このspanが右へtranslate(10px, -50%)しながらopacity 0で消えます。

消えゆくspanは13pxへscale(.94)を添えて、cubic-bezier(.3, 1.6, .4, 1)のスプリングオーバーシュートで止まり際に行き過ぎてから収まり、去る瞬間にフィールド内をradialグローが一度だけ流れます。こちらも自動再生は.focus()と.blur()を往復する3行のJSに過ぎず、実際のフォームでは値を打ち始めた瞬間に同じspanが消えます。

プレースホルダースライド
新しいタブで開く

05 · プレースホルダースライド — 計測 累積面積 7.516% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

:placeholder-showntranslate opacity220ms

自動再生と実際の入力

.ps__ghost {
  transform: translate(0, -50%);
  transition: transform $duration $easing, opacity $duration $easing;
}

.ps__input:focus ~ .ps__ghost,
.ps__input:not(:placeholder-shown) ~ .ps__ghost {
  transform: translate(10px, -50%);
  opacity: 0;
}

06エラーで揺れる

ブラウザの:invalid判定だけで揺らす

type="email" requiredのinputに値を入れたあと(:not(:placeholder-shown))、形式が誤っていればブラウザ自身の:invalid判定だけで枠が揺れて×アイコンが現れます。

揺れはサイクルごとに振幅が減る減衰振動(1 → .75 → .55 → .34)として静まり、エラーが現れる瞬間には枠に赤いradialグローが一度だけ走ります。JavaScriptは一行も使っておらず、形式が合っていれば同じ場所に:validのチェックアイコンが出ます。

エラーで揺れる
新しいタブで開く

06 · エラーで揺れる — 計測 累積面積 2.305% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

:invalidtranslateX600ms

形式が合えばチェックが出る

.ie__input:invalid:not(:placeholder-shown) {
  border-color: $error;
  animation: shake $duration $easing;
}
.ie__input:invalid:not(:placeholder-shown) ~ .ie__icon--bad { transform: translateY(-50%) scale(1); opacity: 1; }
.ie__input:valid:not(:placeholder-shown)   ~ .ie__icon--ok  { transform: translateY(-50%) scale(1); opacity: 1; }

@keyframes shake {
  10%, 90% { transform: translateX($amount * -.5); }
  20%, 80% { transform: translateX($amount); }
  30%, 70% { transform: translateX($amount * -1); }
  40%, 60% { transform: translateX($amount * .7); }
  50%      { transform: translateX($amount * -.7); }
}

07カスタムセレクトが開く

ネイティブselectを使わない理由

ネイティブのselectは選択肢リストの見た目を変えられないため、この項目だけbuttonとul[role=listbox]を自分で組みました。クリックでis-openクラスを切り替える5行のJSを挟み、開くときはリストがscaleYで伸びてシェブロンが180度回転します。

リストはscaleY(1)を通り越して1.06まで伸びてから巻き戻るスプリングで開き、ボタン下端から下へ広がるradialグローが開いたことを一度だけ確かめます。

カスタムセレクトが開く
新しいタブで開く

07 · カスタムセレクトが開く — 計測 累積面積 18.322% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

aria-expandedscaleY220ms

5行のJSが開くリスト

.cs__chevron {
  transition: transform $duration $easing;
}
.cs.is-open .cs__chevron { transform: rotate(180deg); }

.cs__list {
  transform: scaleY(0);
  transform-origin: top;
  opacity: 0;
  transition: transform $duration $easing, opacity $duration $easing;
}
.cs.is-open .cs__list { transform: scaleY(1); opacity: 1; }

08文字数カウント

CSSでは数えられないもの

残り文字数はCSSだけでは数えられないので、textareaのinputイベントのたびに数字を書き換え、is-bumpクラスを付け外しするリフローでbumpの@keyframesを再生します。

弾みは反対方向へ二度跳ね返って止まる減衰スプリング反動(1.35 → .853 → 1.062 → 1、減衰係数.42)で、数字が行き過ぎと戻りを経て落ち着きます。aria-live="polite"を添えて、変更をスクリーンリーダーにも伝えます。

文字数カウント
新しいタブで開く

08 · 文字数カウント — 計測 累積面積 1.504% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

maxlengthscale bump140ms

スクリーンリーダーにも伝える

.cc__num { display: inline-block; transform: scale(1); }
.cc__count.is-bump .cc__num { animation: bump $duration $easing; }

@keyframes bump {
  0%   { transform: scale(1); }
  50%  { transform: scale(1.35); }
  100% { transform: scale(1); }
}

09パスワード目アイコン切替

typeが実際に入れ替わる

目アイコンのボタンを押すとinputのtypeが実際にpasswordとtextの間を行き来します。重ねた目のSVGと斜線入りの目のSVGはis-shownクラスに応じてopacityとscaleでクロスフェードします。

切り替えは1.12まで伸びて戻るスプリングオーバーシュートで行われ、ボタン中心からradialグローが一度だけ広がって目が入れ替わったことを確かめます。

パスワード目アイコン切替
新しいタブで開く

09 · パスワード目アイコン切替 — 計測 累積面積 0.572% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

type toggleopacity scale220ms

2つの目SVGのクロスフェード

.pw__icon {
  transform: scale(1);
  opacity: 1;
  transition: transform $duration $easing, opacity $duration $easing;
}
.pw__icon--off { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--eye { transform: scale(.5); opacity: 0; }
.pw__eye.is-shown .pw__icon--off { transform: scale(1); opacity: 1; }

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

貼り付けた後に最初に触るのはたいていフォーカス色で、入力欄に載せる明るい配色はビタミンカラー デザイン LPキットのシトラスカラーのセクション9選から選んでそのまま入れられます。色より先に引っかかるのはセレクタの方です。

:has()未対応でも残るもの

03のグローと06のエラーメッセージは、形としては兄弟でも子でもない要素まで親の:has()一つで反応させています。Chrome・Edge 105以降、Safari 15.4以降、Firefox 121以降より前のブラウザではこのセレクタ自体が無視され、グローもエラーメッセージも表示されませんが、○×アイコンは~による通常の兄弟セレクタで動くため:has()の対応状況に関係なく切り替わり続け、input自体は値を受け取り続けて:invalidの判定も生きているため機能が完全に止まるわけではありません。04の検索ボックス拡張は数少ないwidthそのものを動かす項目で、隣に別の要素を置いていると広がった瞬間にその要素が実際に押しのけられるレイアウト変化が起きます。上限を240pxに決めたのもこのためです。06でrequiredを外すと空欄でも:invalidと判定され、ページを開いた瞬間からまだ何も打っていない枠が赤く見えてしまうので、:not(:placeholder-shown)を必ず一緒に付ける必要があります。このzipのロックを外す文字列は4qrx8ve2で、9つのファイルが入ったindex.htmlには自動再生の仕込みではなく人が押す・打つコードだけが残っています。

アクセシビリティ

ラベルと状態を読み上げさせる

01はlabel for="fl-nick"とinput id="fl-nick" autocomplete="nickname"を結び付けていて、ラベルが浮いて小さくなってもスクリーンリーダーは同じ関連付けをたどります。04はinputにaria-label="検索"を添え、label自体がinputを包むのでクリック領域がラベルごと広がります。05は見た目の案内文を担うspanにaria-hidden="true"を付けて隠し、スクリーンリーダーにはinput自身のplaceholderとaria-labelだけを読ませます。06はrequiredを付けたうえでpにrole="alert"を持たせ、エラー文が出た瞬間をすぐに読み上げさせます。07はaria-haspopup="listbox"とaria-expandedをクリックごとに書き換え、ulにはrole="listbox"、各liにはrole="option"を付けてネイティブのselectに近い読み上げを保ちます。08はaria-live="polite"で残り文字数の変化を急がずに伝え、09はaria-pressedを切り替えながらautocomplete="current-password"を保ちます。どの項目も:focusでアウトラインを消しているので、次のように:focus-visibleで復元しておくとキーボード操作の手がかりを消さずに済みます。

<label for="fl-nick">ニックネーム</label>
<input id="fl-nick" class="fl__input" autocomplete="nickname">

.fl__input:focus-visible {
  outline: 2px solid $color;
  outline-offset: 2px;
}

動きを減らしたときの挙動

prefers-reduced-motion: reduceでは01・02・04・05・07・08・09がtransition: noneで最終状態へ即座に切り替わり、03はanimation: noneのままopacity: .7・scale(1)で固定されます。06だけは:invalidの判定自体は生きたまま揺れるanimationだけを止めます。

次に読む

「css フォーカス 枠」や検索ボックス以外のCSSアニメーションはCSSアニメーションのカテゴリーにまとめてあり、サイトの成り立ちは紹介ページで読めます。

FAQ

9つとも本当にCSSだけで動きますか?

01〜06はCSSの状態セレクタだけで動き、JavaScriptは使っていません。07カスタムセレクトが開く・08文字数カウント・09パスワード目アイコン切替の3つだけ5〜9行ほどのバニラJSを持ち、リストの開閉・文字数の集計・type属性の書き換えというCSS単独では不可能な仕事を担います。入力欄の外で同じようにホバーと選択状態だけで組んだ例としては、料金表 CSSアニメーション9選があります。

「css 入力フォーム デザイン」でlabelを付けずplaceholderだけで済ませてもいいですか?

済ませない方がいいです。placeholderは値を入力した瞬間に消え、スクリーンリーダーの扱いもlabelほど一定しません。01はlabel for="fl-nick"をinputと結び付け、05はplaceholderを空欄にしたまま見た目の案内文だけを別のspanに任せています。

React 版でも :focus-within や :has() はそのまま動きますか?

動きます。CSS Modulesにコンパイルされても:focus``:focus-within``:placeholder-shown``:valid``:invalid``:has()のようなブラウザの状態セレクタはそのまま生きています。useStateが管理するのは07の開閉・08の入力値・09の表示切替のようにCSSだけでは分からない状態に限られ、フォーカスと検証の判定自体はブラウザに任されたままです。

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

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