GODRICH

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

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

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

9つとも本物の<input>を使い、派手さの順ではなく入力欄が必要とする判定が増える順に並べました。01・02・04・05は:focus``:focus-within``:placeholder-shownだけで完結し、03はそこに:has()を重ねて離れた要素の状態を検知し、06はブラウザ自身のHTML5検証(:valid/:invalid)が判定を肩代わりし、07〜09はクリックやキー入力のたびに状態が切り替わる場面なので、リストを開く・文字数を数える・目アイコンを切り替えるというCSSの状態だけでは無理な3か所にだけ最小限のJavaScriptが入ります。

01フローティングラベル

inputの後ろに兄弟として置いたlabelが、:focusになるか値が入って:not(:placeholder-shown)になった瞬間に左上へ浮かび上がって縮小します。プレースホルダーとラベルを一つの要素にまとめるので、狭いフォームでラベル専用の行を一本節約できます。デモの自動再生も.focus().blur()を行き来する3行のJSがあるだけで、実際のページでは値を打つかタブキーでフォーカスするだけで同じラベルが浮かびます。

: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フォーカスで下線が伸びる

枠線のないinputの下に置いたspan一本が下線の役目を持ち、親の.ug:focus-withinになるとtransform-origin: centerからscaleX(0)scaleX(1)まで育ちます。見た目のラベル文はinputの上に別のテキストとして置いてあります。グリッドの中では.focus().blur()を往復する3行のJSがループさせているだけで、実際のフォームでは中のinputをクリックするかタブで移動するだけで下線が伸びます。

: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で無限に繰り返します。フォーカスが外れると呼吸も止まります。デモのループも.focus().blur()を切り替える3行のJSで再生していて、:has()対応ブラウザなら実際のページでもタブ移動だけでリングが呼吸を始めます。

: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検索ボックス拡張

ピル型のlabel.seは普段148pxに折りたたまれていて、.se:focus-withinになった瞬間に240pxまで広がります。左のアイコンは円と線一本だけで描いたインラインSVGなので、アイコンフォントを読み込まなくても欠けません。自動再生も.focus().blur()を交互に呼ぶ3行のJSだけで動いていて、実際のヘッダーではクリックかタブでの移動がそのままきっかけになります。

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

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

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

実際のplaceholder属性は空欄1文字だけにして、重ねたspanが案内文の表示を代わりに担います。input:focusになるか値が入って:not(:placeholder-shown)になると、このspanが右へtranslate(10px, -50%)しながらopacity 0で消えます。こちらも自動再生は.focus().blur()を往復する3行のJSに過ぎず、実際のフォームでは値を打ち始めた瞬間に同じspanが消えます。

: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エラーで揺れる

type="email" requiredinputに値を入れたあと(:not(:placeholder-shown))、形式が誤っていればブラウザ自身の:invalid判定だけで枠が揺れて×アイコンが現れます。JavaScriptは一行も使っておらず、形式が合っていれば同じ場所に:validのチェックアイコンが出ます。

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

aria-expandedscaleY220ms
.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だけでは数えられないので、textareainputイベントのたびに数字を書き換え、is-bumpクラスを付け外しするリフローでbump@keyframesを再生します。aria-live="polite"を添えて、変更をスクリーンリーダーにも伝えます。

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パスワード目アイコン切替

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

type toggleopacity scale220ms
.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; }

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

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はinputaria-label="検索"を添え、label自体がinputを包むのでクリック領域がラベルごと広がります。05は見た目の案内文を担うspanaria-hidden="true"を付けて隠し、スクリーンリーダーにはinput自身のplaceholderaria-labelだけを読ませます。06はrequiredを付けたうえでprole="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: .7scale(1)で固定されます。06だけは:invalidの判定自体は生きたまま揺れるanimationだけを止めます。

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

FAQ

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

01〜06はCSSの状態セレクタだけで動き、JavaScriptは使っていません。07カスタムセレクトが開く・08文字数カウント・09パスワード目アイコン切替の3つだけ5〜9行ほどのバニラJSを持ち、リストの開閉・文字数の集計・type属性の書き換えという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だけでは分からない状態に限られ、フォーカスと検証の判定自体はブラウザに任されたままです。

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

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