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

css floating label 9パターン — ばねと慣性でラベルが動く入力欄

入力欄を押してからの0.3秒、css floating label がどう上がるかがフォームの第一印象を決める — 01番のばねと02番のノッチがその0.3秒を計算で満たす。九つの場面はどれも CSS の transition を使わず、JavaScript で計算した値だけで動きます。

九つのフローティングラベルを画面でそのまま押してみる

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

css floating label 9パターン — ばね・測定・補間でラベルが生きる

一つの場面は、幅が最大440pxのカードに入力欄を二つか三つ縦に積んだ形です。欄の高さは52px、欄どうしの間は16px、カードの内側の余白は20pxなので、欄が三つの場面はカードの高さが228px、二つの場面は160pxになります。480×300のステージではカードが中央に置かれ、高さ250px以下の携帯向けの画面では3番目の欄(06は2番目の欄)を隠します。

九つの場面には CSS の transition も @keyframes もありません。ラベルの移動・枠線の隙間・影はすべて requestAnimationFrame の中で計算した値で、自動の実演は2秒周期の予定表が欄に文字を入れていき、最初の変化は200msで起きます。人が欄に最初のフォーカスを置くと実演は止まり、本物のフォーカスと input イベントが引き継ぎます。

並べた順番は、ラベルを動かす力がどこから来るかで決めました。01・04・07・09 は速度を持つばねで、02・03・05・08 は目標へフレームごとに一定の割合で近づく指数接近(approach)です。06 はラベルが指数接近で、揺れだけは振幅の縮む正弦を直接計算します。ラベルが15px上がって倍率0.74まで縮む基本の動きはどの場面も同じで(02だけは26pxと0.78)、その上に重ねた一層が場面ごとに違います。

会員登録フォームなら 01 から、枠線の上辺にラベルを乗せる形なら 02 から、入力の検証結果を見せたいなら 06 から開くのが近道です。入力欄そのものの形が知りたいときは 入力欄 デザイン 9選 — コピペで動くフォーム部品 を、エラー文を出すタイミングは フォーム バリデーション UI 9選 — 打ち込んだ瞬間に判定するエラー表示 を、横に開いておくと読み進めやすくなります。

css floating label input を技法別のコードで見る

ここに載せるのは各場面の index.html の <script> の中で途切れず続く行だけで、断片は25行以内です。css floating label input と呼ばれる動きの多くは、ここにある計算の上にラベルを一枚重ねたものなので、値の出どころから読むと直す場所が見つかります。

01行き過ぎてから落ち着く減衰ばねのラベル

フォーカスが入った瞬間、ラベルの位置をばねの方程式で積分するので、トゥイーン曲線がなくても目標を少し行き過ぎて戻る動きが出ます。ラベルの高さと縮小率が同じ x から出るため、行き過ぎたときはラベルがさらに高く小さくなり、メールや名前の欄が並ぶ会員登録フォームに合います。累積面積は48.9%、平均の強さは76.0で、23/23フレームが動きました。

ラベルが減衰ばねで上がり、少し行き過ぎて落ち着くフローティングラベル
新しいタブで開く

01 · ラベルが減衰ばねで上がり、少し行き過ぎて落ち着くフローティングラベル — 計測 累積面積 48.867% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STIFFNESSDAMPINGRISE_PXLABEL_SCALE
    function springStep(s, target, k, c, dt) {
        let left = dt;
        while (left > 0) {
            const h = Math.min(left, 1 / 120);
            s.v += (-k * (s.x - target) - c * s.v) * h;
            s.x += s.v * h;
            left -= h;
        }
    }

02枠線がラベルの幅だけ開くノッチ型のラベル

ラベルを getBoundingClientRect で測り、枠線の上辺の隙間をその幅に合わせて clip-path で切り抜くので、文字が長くても短くても隙間がぴったり合います。隙間の目標はラベルの実測幅に0.78を掛けて左右の余白5pxずつを足した値で、枠線の上にラベルが乗る設定・プロフィールのフォームに向きます。累積面積は49.0%、平均の強さは83.6で、23/23フレームが動きました。

枠線がラベルの実測幅だけ開くノッチ型ラベル
新しいタブで開く

02 · 枠線がラベルの実測幅だけ開くノッチ型ラベル — 計測 累積面積 48.998% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

NOTCH_PADGAP_LERPRADIUSBORDER_W
    function render() {
        fields.forEach(function (f) {
            const s = 1 - (1 - LABEL_SCALE) * f.p;
            f.label.style.transform = "translate3d(0," + (-RISE_PX * f.p).toFixed(2) + "px,0) scale(" + s.toFixed(3) + ")";
            f.box.style.setProperty("--p", f.p.toFixed(3));
            const x0 = LABEL_X - NOTCH_PAD;
            const x1 = x0 + f.gap;
            const cut = f.gap > 0.5 ? "polygon(0 0," + x0.toFixed(1) + "px 0," + x0.toFixed(1) + "px " + (BORDER_W + 1) + "px," + x1.toFixed(1) + "px " + (BORDER_W + 1) + "px," + x1.toFixed(1) + "px 0,100% 0,100% 100%,0 100%)" : "none";
            f.outline.style.clipPath = cut;
        });
    }

03下線の光る点がキャレットを慣性で追う入力欄

キャレットより前の文字の幅を、隠した鏡の要素に入れて測り、下線の上の光る点がそのx座標へ指数減衰で追いかけるので、速く打つほど点が遅れるのが見えます。点が遠く遅れるほど下線の尾の不透明度が上がる作りで、検索窓や一行メモの入力に載せやすい形です。累積面積は27.3%で九つのうち最小、平均の強さは43.0、23/23フレームで動きが検出されました。

下線がキャレットの位置を慣性で追う入力欄
新しいタブで開く

03 · 下線がキャレットの位置を慣性で追う入力欄 — 計測 累積面積 27.345% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LERPUNDERLINE_HCARET_OFFSETTRAIL_FADE
    function caretX(f) {
        const at = f.input.selectionStart;
        const n = at === null ? f.input.value.length : at;
        f.mirror.textContent = f.input.value.slice(0, n);
        return INPUT_PAD + f.mirror.getBoundingClientRect().width + CARET_OFFSET;
    }

04ラベルの文字がハッシュの遅れで一文字ずつ浮かぶ波

ラベルを文字ごとの <span> に分け、各文字に hash(i × 17.3 + fi × 91.7 + 7) から作った遅れ(260ms未満)を与えて、それぞれがばねで上がるので、ラベルが一塊ではなく波のように立ち上がります。ラベルの要素そのものは <label for> のままなので、ランディングページのニュースレター欄に使ってもアクセシブルな名前は一続きの文字列として残ります。累積面積は32.6%、平均の強さは66.7、動いたフレームは23/23でした。

ラベルの文字がハッシュ遅延で一文字ずつ浮かぶ波ラベル
新しいタブで開く

04 · ラベルの文字がハッシュ遅延で一文字ずつ浮かぶ波ラベル — 計測 累積面積 32.578% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STAGGER_MSWAVE_AMPNOISE_SEEDSETTLE
    function step(dt, ts) {
        fields.forEach(function (f) {
            f.chars.forEach(function (c) {
                c.t += dt;
                if (!c.fired && c.t >= c.wait) {
                    c.fired = true;
                    c.goal = f.up ? -RISE_PX : 0;
                    c.sp.v += (f.up ? -1 : 1) * WAVE_AMP;
                }
                springStep(c.sp, c.goal, SETTLE, DAMP, dt);
            });
        });
    }

05入力の長さだけ枠線が一周ぶん埋まる進行リング

<svg> の中の rect の周長を getTotalLength で測り、stroke-dashoffset を文字数の割合で補間するので、上限までの残りが枠線そのものに見えます。欄の幅400pxで周長は約880.8px、カードが縮んで254pxになると約589.6pxで、24文字に達すると縁の色が変わるため、ニックネームや一行紹介のような字数制限のある欄に合います。累積面積は48.7%、平均の強さは102.0で九つのうち最大、23/23フレームが動きました。

入力の長さに応じて枠線が一周分埋まる進行リング欄
新しいタブで開く

05 · 入力の長さに応じて枠線が一周分埋まる進行リング欄 — 計測 累積面積 48.748% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_LENRING_LERPSTROKE_WSTART_FILL
    function render() {
        fields.forEach(function (f) {
            const s = 1 - (1 - LABEL_SCALE) * f.p;
            f.label.style.transform = "translate3d(0," + (-RISE_PX * f.p).toFixed(2) + "px,0) scale(" + s.toFixed(3) + ")";
            f.box.style.setProperty("--p", f.p.toFixed(3));
            f.rect.style.strokeDashoffset = (f.perimeter * (1 - f.fill)).toFixed(2);
            if (f.fill > 0.995) f.box.setAttribute("data-full", "");
            else f.box.removeAttribute("data-full");
        });
    }

06エラーで減衰する正弦に揺れて案内色が点く欄

欄を離れたとき、値が空でなくメールの形に合わなければ aria-invalid と aria-describedby を付け、振幅9pxの正弦の揺れをフレームごとに0.9のべき乗で小さくして、一度はじかれた感触を出します。ラベルの色とエラー文が同じ瞬間に切り替わり、正しく直すと両方とも外れるので、ログインや決済フォームの検証フィードバックに使えます。累積面積は34.0%、平均の強さは57.2で、動いたフレームは22/23にとどまり、理由は確かめていません。

エラー時に減衰する正弦で揺れ、ラベルが案内色に変わる欄
新しいタブで開く

06 · エラー時に減衰する正弦で揺れ、ラベルが案内色に変わる欄 — 計測 累積面積 34.009% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHAKE_AMPSHAKE_DECAYSHAKE_FREQERR_HOLD
    function shakeStep(s, dt) {
        s.amp *= Math.pow(SHAKE_DECAY, dt * 60);
        s.phase += SHAKE_FREQ * dt;
        return s.amp * Math.sin(s.phase);
    }
    function setErr(f, bad) {
        if (f.err === bad) return;
        f.err = bad;
        if (bad) {
            f.amp = SHAKE_AMP;
            f.phase = 0;
            f.flash = 1;
            f.box.setAttribute("data-err", "");
            f.input.setAttribute("aria-invalid", "true");
            if (f.msg) f.input.setAttribute("aria-describedby", f.msg.id);
        } else {
            f.box.removeAttribute("data-err");
            f.input.removeAttribute("aria-invalid");
            f.input.removeAttribute("aria-describedby");
            f.flash = 0;
        }
        if (reduce) render();
    }

07フォーカスで影がばねで立ち上がるカード型の欄

高さの値 e 一つで二重の box-shadow のオフセット・ぼかし・不透明度をまとめて計算し、ラベルの上昇と欄が3px浮く量も同じ e から出します。e が0なら1.0px/2.0px/0.10の一重(縦のオフセット・ぼかし・不透明度の順)で、1なら8.2px/20.4px/0.26に18.0px/61.2px/0.224を重ねた二重になり、ダッシュボードの設定や絞り込みの入力に載せやすい形です。累積面積は45.4%、平均の強さは54.7、23/23フレームが動きました。

フォーカスで影の高さがばねで立ち上がるカード型入力欄
新しいタブで開く

07 · フォーカスで影の高さがばねで立ち上がるカード型入力欄 — 計測 累積面積 45.378% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ELEVATIONSHADOW_BLUR_KSPRING_KTINT_ALPHA
    function shadowOf(e) {
        const h = clamp(e, 0, 1.4);
        const y1 = 1 + h * ELEVATION * 0.4;
        const b1 = 2 + h * ELEVATION * 0.6 * SHADOW_BLUR_K;
        const a1 = 0.1 + h * TINT_ALPHA;
        const y2 = h * ELEVATION;
        const b2 = h * ELEVATION * 2 * SHADOW_BLUR_K;
        const a2 = h * TINT_ALPHA * 1.4;
        return "0 " + y1.toFixed(1) + "px " + b1.toFixed(1) + "px rgba(23,20,26," + a1.toFixed(3) + "), 0 " + y2.toFixed(1) + "px " + b2.toFixed(1) + "px rgba(95,70,200," + a2.toFixed(3) + ")";
    }

08行が増えるとなめらかに伸び、ラベルは上に浮くテキストエリア

scrollHeight で必要な高さを測って目標を決め、今の高さがそこへ慣性で補間されるので、行が増えても画面ががくっと跳ねません。欄は52pxから4行が上限の124pxまで伸び、それを超えるとスクロールが出るので、お問い合わせやレビュー投稿のフォームに合います。累積面積は59.7%、平均の強さは71.6、動いたフレームは23/23でした。

行数に応じてなめらかに伸び、ラベルは欄の上部に浮かぶテキストエリア
新しいタブで開く

08 · 行数に応じてなめらかに伸び、ラベルは欄の上部に浮かぶテキストエリア — 計測 累積面積 59.731% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_ROWSGROW_LERPLINE_HPAD_TOP
    function wanted(f) {
        if (!f.grow) return MIN_H;
        const ta = f.input;
        ta.style.height = "0px";
        const need = ta.scrollHeight;
        ta.style.height = "";
        const cap = PAD_TOP + PAD_BOTTOM + MAX_ROWS * LINE_H;
        ta.style.overflowY = need > cap ? "auto" : "hidden";
        return clamp(need, MIN_H, cap);
    }

09シェブロンがばねで回り、選ぶとラベルが上がるセレクト欄

開き具合 x 一つからシェブロンの角度・リストの高さ・ラベルの上昇がすべて導かれるので、開閉とラベルが一つの状態で噛み合います。本物の <select> ではなく <button role="combobox"> と role="listbox" のリストで作ってあり、国や職種を選ぶ欄に使え、キーボードは矢印キーとEscape(keydown)、EnterとSpace(ボタン標準の click)で動きます。累積面積は62.6%で九つのうち最大、平均の強さは44.0、動いたフレームは21/23でした。

シェブロンがばねで回り、選ぶとラベルが上がるセレクト欄
新しいタブで開く

09 · シェブロンがばねで回り、選ぶとラベルが上がるセレクト欄 — 計測 累積面積 62.578% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CHEV_STIFFOPEN_DEGPICK_DELAYOPT_H
    function render() {
        fields.forEach(function (f) {
            const x = f.sp.x;
            const o = clamp(x, 0, 1);
            const r = f.sel >= 0 ? Math.max(1, clamp(x, 0, 1.25)) : clamp(x, 0, 1.25);
            const s = 1 - (1 - LABEL_SCALE) * r;
            f.label.style.transform = "translate3d(0," + (-RISE_PX * r).toFixed(2) + "px,0) scale(" + s.toFixed(3) + ")";
            f.chev.style.transform = "rotate(" + (OPEN_DEG * x).toFixed(1) + "deg)";
            f.box.style.setProperty("--p", clamp(r, 0, 1).toFixed(3));
            const h = (OPT_H * f.opts.length + 2 * MENU_PAD) * o;
            f.menu.style.height = h.toFixed(1) + "px";
            f.menu.style.opacity = clamp(o * 1.6, 0, 1).toFixed(3);
            f.menu.style.visibility = h < 1 ? "hidden" : "visible";
        });
    }

値がラベルの印象を変えるところ — 剛性・減衰・補間の割合

値一つで印象が大きく変わる場所は三つあります。次の三つの数字は、ソースの springStep・approach・shakeStep をノードから60fps固定で直接呼んで得た値であり、ブラウザで撮影して測った表とは別物です。

剛性を上げると速くなる代わりに行き過ぎが倍になる

01 の STIFFNESS を170から320へ上げると(減衰は14のまま、減衰比は0.54から0.39)、ラベルが15px上に初めて届く時刻は200msから117msへ早まります。一方で行き過ぎは12.4%(最高点16.9px)から25.1%(18.8px)に広がり、目標の0.3px以内に収まる時刻は約433msから約467msへ、むしろ遅れます。

補間の割合を下げると点の尾が長くなる

03 の LERP を0.18から0.06へ下げると、120pxのジャンプの中間点までが67msから200ms、1px以内に収まる時刻が417msから1300msへ延びます。速く打つと点が文字よりかなり遅れて残ります。

減衰を上げると揺れが入力の邪魔になるほど長引く

06 の SHAKE_DECAY を0.9から0.97へ上げると、振幅が1px未満になる時刻は350msから1217msへ、1pxを超える左右の揺れは5回から19回へ増えます。はじかれた感触は強まりますが、そのぶん長く画面に残ります。

九つの場面を撮影して数えた値は次の表のとおりで、面積がいちばん小さいのは03の27.3%、いちばん大きいのは09の62.6%です。動いたフレームが23に届かなかった場面は06と09の二つだけでした。

項目 累積面積 平均の強さ 動いたフレーム
01 ばねのラベル 48.9% 76.0 23/23
02 ノッチの隙間 49.0% 83.6 23/23
03 キャレットの下線 27.3% 43.0 23/23
04 文字の波 32.6% 66.7 23/23
05 進行リング 48.7% 102.0 23/23
06 エラーの揺れ 34.0% 57.2 22/23
07 影の高さ 45.4% 54.7 23/23
08 伸びる欄 59.7% 71.6 23/23
09 セレクト 62.6% 44.0 21/23

どこで壊れるか — オートフィル・長いラベル・動きを減らす設定

自動入力が値を入れてもラベルが上がらないことがある

isFilled() の定義は九つすべてにあり、呼んでいるのは01〜08です。この関数は、value が空かどうかに加えて :-webkit-autofill というセレクターも try/catch の中で確かめます。ソースのコメントは、自動入力が値を入れるときに input イベントが出ない場合があるためだと書いています。ただし確認が走るのは、フォーカス・入力・サイズの変化で retarget() が呼ばれたときだけで、ポーリングはありません。読み込み後に値が入る場合の挙動までは確かめていません。

ラベルが長いと02の隙間が欄を越える

隙間はラベルの実測幅に0.78を掛けて10pxを足した値なので、翻訳でラベルが長くなると隙間が欄の幅を超え、枠線がほとんど消えます。フォントが遅れて入ると measure() の時点の幅がずれるため、document.fonts.ready と ResizeObserver が幅を測り直します。

メール欄の type が03を止める

type="email" の欄は selectionStart が null になり、setSelectionRange は例外を投げます。最初にメール欄をそのまま type="email" にしたところ、実演のループがこの例外で止まりました。今は type="text" に inputmode="email" を添えていて、キャレットの位置を読む欄には同じ処方が要ります。

文字数はUTF-16の長さで数える

05 の塗りはソースが value.length だけで数えていて、絵文字などの数え方は、調査メモによるとUTF-16の長さに従うとされています。変換中の文字も value に入っているため幅や文字数に含まれ、上限は本物の maxLength 属性が受け持ちます。

動きを減らす設定ではエラーを色と文が担う

06 は prefers-reduced-motion のとき揺れのオフセットだけを0にし、data-err と aria-* はそのまま切り替えます。色だけでエラーを伝えないよう、文も一緒に変わります。

height を直接補間すると下の欄が押される

height を直接補間するのは、08の箱と09のリストの二つです。08 はカードが160pxから232pxへ伸びて下を押すので、scrollHeight は入力と幅の変化のときだけ測り、その間のフレームは数字の計算だけで進みます。09 のリストは position: absolute なので下を押しません。

上辺にかかるラベルではばねの行き過ぎが線の外へ出る

01 はラベルが最高16.9pxまで上がっても、上の余白24pxの中に収まります。02 のようにラベルが枠線の上辺にかかる構成にばねを使うと行き過ぎが線の外へ出るので、02 は指数接近で動かしています。

リストの mousedown を止めないとクリックが届かない

09 はリスト上の mousedown の preventDefault を外すと、ボタンがフォーカスを失って blur でリストが閉じ、クリックが項目に届きません。九つのファイルのうち preventDefault を使うのは09だけです。

自分で値を変えて境界を確かめる

01 の STIFFNESS、03 の LERP、06 の SHAKE_DECAY を一つずつ書き換えれば、上の数字が切り替わる位置を手元で確かめられます。下の圧縮ファイルのパスワードは f8c3a4n6 で、中に九つの場面の HTML・SCSS と React 版が入っています。

変形三つとアクセシビリティ

名前 値 感触
ばねの剛性を変える 01 STIFFNESS 170 → 320 初めて届く時刻が200msから117msへ早まるが、行き過ぎが12.4%から25.1%へ倍になる。
下線の補間割合を変える 03 LERP 0.18 → 0.06 1px以内に収まる時刻が417msから1300msへ延び、点が文字を長く追いかける。
揺れの減衰を変える 06 SHAKE_DECAY 0.9 → 0.97 1px未満までが350msから1217msへ、1pxを超える揺れは5回から19回へ増える。

この三行の数字は、上で見た三つの節をそのまま一行に縮めたものです。

入力のある 01〜08 は本物の <input>(08 は <textarea>)で、keydown のハンドラも preventDefault もないため、打鍵を止めるキーがありません。keydown は09に一つだけあり、ArrowDown と ArrowUp のときだけ preventDefault を呼び、aria-expanded・aria-activedescendant・aria-selected も09にしかありません。九つのラベルはすべて <label for> で欄につながり、aria-live はどのファイルにもなく、フォーカスの表示は :focus-visible の2pxの外枠です。

prefers-reduced-motion が有効なら、九つの場面すべてで requestAnimationFrame のループを始めず、入力が来ると補間せずに目標の値をそのまま描きます。09 は x を0か1に直接置き、閉じるときも即座に閉じます。

pure css floating label だけで足りるか?

ラベルが空のときと値があるときの二つの状態を行き来するだけの欄なら、この編の計算は大げさです。九つの場面が CSS の transition と @keyframes をゼロにして JavaScript で計算したのは、長さを測る必要があるか、一つの値から複数の属性を導く必要があったからで、02 の隙間にはラベルの実測幅、03 にはキャレット前の文字幅、05 には周長、08 には scrollHeight が要ります。

floating label css codepen で見つけた断片とこの編のコードを見比べるときは、欄が空かどうかを判断する isFilled() と、ラベルを動かす render() が分かれているかを見てください。分かれていれば、右側の計算だけを差し替えられます。入力した文字数を数える側が目的なら テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる が、値の良し悪しを伝える側なら パスワード強度チェック UI 9選 — 強度の値が計算される仕組み が続きます。

css form floating label を使い回す前によくある質問

css floating label input のラベルが上がる高さと大きさはどこで変えますか

値は場面ごとの <script> の const の行にまとまっているので、その数字を書き換えるだけです。上がる高さは RISE_PX(02 だけ26)、縮む倍率は LABEL_SCALE で、各場面の項目に挙げた名前がその場面で試せる値です。

キーボードや動きを減らす設定でも使えますか

入力欄は打鍵を止めず、09 のリストだけが動きます。矢印キーとEscapeは keydown で、EnterとSpaceはボタンの標準の click で処理されます。動きを減らす設定が有効だとループは始まらず結果だけが描かれ、06 のエラーは色と文で残ります。

ラベルがずっと長くなるとどうなりますか

02 は隙間がラベルの幅に合わせて広がり、欄の幅を超えると枠線がほとんど消えます。04 は文字の数だけ遅れが生まれて波が長くなるので、翻訳したラベルを使う予定なら、いちばん長い文言で一度開いてから組み込んでください。

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

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