GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Floating Label: 9 Inputs on Spring Physics

Within 0.3 seconds of a tap on a field, the way a css floating label rises sets the first impression of the form — scene 01's spring and scene 02's

Tap All Nine Floating Labels Right on This Page

Auto-plays · click a tile to jump to its section · all nine in one zip

CSS Floating Label: 9 Fields Where Springs, Measurement and Interpolation Bring the Label to Life

Every scene is a card up to 440px wide with two or three fields stacked inside it. Fields are 52px tall with 16px between them and 20px of padding around, so a three-field card stands 228px tall and a two-field card 160px. The card sits in the middle of the 480×300 stage, and on a phone viewport 250px tall or less the third field (the second one in 06) is hidden.

None of the nine files contains a CSS transition or a @keyframes rule. The label travel, the outline gap and the shadow are all numbers computed inside requestAnimationFrame, and a scripted plan types into the fields on a 2-second cycle, with the first change at 200ms. The first real focus on a field stops that plan, and from then on your own focus and input events drive everything.

The order follows where the push on the label comes from. Scenes 01, 04, 07 and 09 keep a velocity and integrate a spring, while 02, 03, 05 and 08 close a fixed share of the remaining distance each frame, which the source calls approach. Scene 06 moves its label by approach too and computes only the shake directly, as a sine whose amplitude shrinks. The default move is the same everywhere, 15px up at a scale of 0.74 (02 alone uses 26px and 0.78), and what differs is the extra layer on top.

Start at 01 for a sign-up form, at 02 for an outlined style, and at 06 if error feedback is the goal. For the plain look of each input widget, open CSS Input Design: 9 Form Widgets You Can Paste In, and keep 9 Form Validation UI Patterns — As You Type next to it to decide when an error message should appear.

Code for Each Technique Behind the CSS Floating Label Input

Each snippet is an unbroken run of lines from the <script> in that scene's index.html, 25 lines or fewer. Most of what people call a css floating label input is a calculation like these with one layer added to the label, so reading where the number comes from shows you which line to change.

01A Label That Rises on a Damped Spring, Overshoots Slightly, and Settles

Integrating the label position with a spring equation when focus arrives gives a small overshoot and a settle with no tween curve. Height and scale come from the same x, so on the overshoot the label climbs a little higher and shrinks a little more, which suits the email and name fields of a sign-up form. The render capture gave 48.9% cumulative area and 76.0 mean intensity, with 23/23 frames moving.

A Label That Rises on a Damped Spring, Overshoots Slightly, and Settles
Open in a new tab

01 · A Label That Rises on a Damped Spring, Overshoots Slightly, and Settles — Measured area 48.867% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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;
        }
    }

02A Notched Outline That Opens Exactly as Wide as the Measured Label

The label is measured with getBoundingClientRect and the gap in the outline's top edge is cut with clip-path to that width, so the gap fits whatever the text length. The gap target is the measured width times 0.78 plus 5px of margin on each side, a good fit for Material-style settings and profile forms. This scene scored 49.0% cumulative area and 83.6 mean intensity over 23/23 moving frames.

A Notched Outline That Opens Exactly as Wide as the Measured Label
Open in a new tab

02 · A Notched Outline That Opens Exactly as Wide as the Measured Label — Measured area 48.998% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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;
        });
    }

03An Input Whose Underline Chases the Caret with Inertia

The text before the caret is placed in a hidden mirror element and measured, and a light dot on the underline follows that x with exponential smoothing, so fast typing leaves the dot visibly behind. The farther the dot lags behind the caret, the more opaque the underline's tail becomes (opacity is 0.45 + lag/80), which works for a search bar or a one-line memo. At 27.3% cumulative area it is the smallest of the nine, with a mean intensity of 43.0 and movement in 23/23 frames.

An Input Whose Underline Chases the Caret with Inertia
Open in a new tab

03 · An Input Whose Underline Chases the Caret with Inertia — Measured area 27.345% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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;
    }

04A Wave Label Whose Letters Rise One by One on Hashed Delays

The label is split into one <span> per letter, each letter gets a delay from hash(i × 17.3 + fi × 91.7 + 7) times 260ms, and each rises on its own spring, so the label stands up as a ripple instead of a block. The label element itself stays a <label for>, so a newsletter field on a landing page still has a one-sentence name for assistive technology. Numbers read 32.6% cumulative area, 66.7 mean intensity and 23/23 moving frames.

A Wave Label Whose Letters Rise One by One on Hashed Delays
Open in a new tab

04 · A Wave Label Whose Letters Rise One by One on Hashed Delays — Measured area 32.578% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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);
            });
        });
    }

05A Field Whose Outline Fills Around as a Progress Ring with Input Length

An <svg> rect is measured with getTotalLength and stroke-dashoffset is interpolated by the share of characters typed, so how much room is left shows in the outline itself. At a field width of 400px the perimeter is about 880.8px, and when the card narrows to 254px it is about 589.6px; at 24 characters the border turns mint, which suits nicknames and short bios. It recorded 48.7% cumulative area and the strongest mean intensity of the nine, 102.0, over 23/23 moving frames.

A Field Whose Outline Fills Around as a Progress Ring with Input Length
Open in a new tab

05 · A Field Whose Outline Fills Around as a Progress Ring with Input Length — Measured area 48.748% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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");
        });
    }

06A Field That Shakes on a Damped Sine on Error While the Label Changes to a Warning Tint

When you leave the field with a non-empty value that does not look like an email, the script sets aria-invalid and aria-describedby and starts a 9px sine shake whose amplitude is multiplied by a power of 0.9 each frame, so the field feels rejected once. The label tint and the message change in the same instant and both clear when the value is fixed, which makes it useful for login and checkout validation. It measured 34.0% cumulative area and 57.2 mean intensity, and only 22/23 frames moved; I did not check why.

A Field That Shakes on a Damped Sine on Error While the Label Changes to a Warning Tint
Open in a new tab

06 · A Field That Shakes on a Damped Sine on Error While the Label Changes to a Warning Tint — Measured area 34.009% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

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();
    }

07A Card-Style Input Whose Shadow Elevation Springs Up on Focus

One elevation value e sets the offset, blur and opacity of two box-shadow layers together, and the label rise and a 3px lift of the field come from the same e. At e = 0 there is one layer at 1.0px/2.0px/0.10 (vertical offset, blur, opacity), and at 1 it is 8.2px/20.4px/0.26 plus 18.0px/61.2px/0.224, a fit for settings and filter inputs in a dashboard. The capture gave 45.4% cumulative area, 54.7 mean intensity and 23/23 moving frames.

A Card-Style Input Whose Shadow Elevation Springs Up on Focus
Open in a new tab

07 · A Card-Style Input Whose Shadow Elevation Springs Up on Focus — Measured area 45.378% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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) + ")";
    }

08A Textarea That Grows Smoothly with Its Lines While the Label Floats at the Top

scrollHeight gives the height the text needs, and the current height eases toward it with inertia, so the layout does not jump when a line is added. The box grows from 52px to a four-line cap of 124px and scrolls beyond that, which suits contact and review forms. This scene took 59.7% cumulative area, 71.6 mean intensity and 23/23 moving frames.

A Textarea That Grows Smoothly with Its Lines While the Label Floats at the Top
Open in a new tab

08 · A Textarea That Grows Smoothly with Its Lines While the Label Floats at the Top — Measured area 59.731% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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);
    }

09A Select Field Whose Chevron Turns on a Spring and Whose Label Lifts When an Option Is Picked

A single open amount x produces the chevron angle, the list height and the label rise, so opening, closing and the label stay locked to one state. It is a <button role="combobox"> with a role="listbox" list rather than a real <select>, aimed at country or job role pickers, and the keyboard works through the arrow keys and Escape (handled in keydown) plus Enter and Space (the button's native click). At 62.6% cumulative area it is the widest of the nine, with 44.0 mean intensity and 21/23 moving frames.

A Select Field Whose Chevron Turns on a Spring and Whose Label Lifts When an Option Is Picked
Open in a new tab

09 · A Select Field Whose Chevron Turns on a Spring and Whose Label Lifts When an Option Is Picked — Measured area 62.578% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

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";
        });
    }

Where the Values Change the Feel — Stiffness, Damping, Lerp Ratio

Three constants change the character of a label more than any others. The numbers below come from calling springStep, approach and shakeStep from the source in node at a fixed 60 steps per second, so they are separate from the browser render captures in the table further down.

A Stiffer Spring Doubles the Overshoot

Raising STIFFNESS in 01 from 170 to 320 with damping held at 14 drops the damping ratio from 0.54 to 0.39. The label first reaches 15px at 117ms instead of 200ms, but the overshoot grows from 12.4% (peak 16.9px) to 25.1% (18.8px), and it stays within 0.3px of the target from about 433ms to about 467ms, a little later rather than sooner.

A Lower Lerp Ratio Stretches the Dot's Tail

Dropping LERP in 03 from 0.18 to 0.06 moves the halfway point of a 120px jump from 67ms to 200ms, and the time to land within 1px from 417ms to 1300ms. Typing quickly leaves the dot well behind the letters.

A Higher Decay Keeps the Shake Going Long Enough to Annoy

Raising SHAKE_DECAY in 06 from 0.9 to 0.97 moves the point where the amplitude falls under 1px from 350ms to 1217ms, and the swings larger than 1px grow from 5 to 19. The rejection feels stronger, and it also lingers.

The nine browser captures are collected below. The smallest scene is 03 at 27.3% and the largest is 09 at 62.6%, and only 06 and 09 fell short of 23 moving frames.

Scene Cumulative area Mean intensity Moving frames
01 spring label 48.9% 76.0 23/23
02 notch gap 49.0% 83.6 23/23
03 caret underline 27.3% 43.0 23/23
04 letter wave 32.6% 66.7 23/23
05 progress ring 48.7% 102.0 23/23
06 error shake 34.0% 57.2 22/23
07 shadow elevation 45.4% 54.7 23/23
08 growing area 59.7% 71.6 23/23
09 select 62.6% 44.0 21/23

Where It Breaks — Autofill, Long Labels, Reduced-Motion Settings

Autofill Can Fill a Value Without the Label Rising

isFilled() is defined in all nine files and called in scenes 01 through 08; 09 defines it but never calls it. It returns true when value is not empty and otherwise asks el.matches(":-webkit-autofill") inside a try/catch. A comment in the source explains why: autofill may fill a value without an input event, so the check looks at :autofill too.

A Long Label Pushes 02's Gap Past the Field

The gap is the measured label width times 0.78 plus 10px, so a long translated label can open a gap wider than the field and the outline nearly vanishes. When a font arrives late the width taken by measure() is wrong, which is why document.fonts.ready and a ResizeObserver measure again.

An Email Field's type Can Freeze 03

On a type="email" field selectionStart is null and setSelectionRange throws. My first version used that type, the scripted loop stopped on the exception one second in, and only 8/23 frames moved; the scene now uses type="text" with inputmode="email", and any field where you read the caret position needs the same fix.

Characters Are Counted in UTF-16 Units

Scene 05 fills by value.length; from our research notes, that length counts UTF-16 code units rather than visible characters, so some emoji and rarer ideographs count as two. Text still being composed is already in value, so it counts toward width and length, while the cap itself belongs to the real maxLength attribute.

Reduced Motion Leaves Color and Words to Report the Error

With prefers-reduced-motion on, 06 zeroes only the shake offset and still switches data-err and the aria-* attributes. The message text changes along with the color, so the error is never signaled by color alone.

Interpolating height Pushes the Fields Below

Two scenes interpolate height directly, 08 for the box and 09 for the list. The 08 card grows from 160px to 232px and shoves what is under it, so it reads scrollHeight only on input or width change and does plain arithmetic in between, while the 09 list is position: absolute and pushes nothing.

A Label on the Top Edge Lets the Overshoot Escape

In 01 the label peaks at 16.9px and still stays inside the 24px of space above it. Scene 02 hangs the label across the outline's top edge, so a spring overshoot would cross the line outside the field, and that is why it uses approach.

The List's mousedown Must Be Cancelled or the Click Never Lands

In 09, without preventDefault on mousedown over the list, the button loses focus, blur closes the list and the click never reaches an option. Of the nine files only 09 calls preventDefault at all.

Edit Three Values to Feel the Edges Yourself

Change STIFFNESS in 01, LERP in 03 and SHAKE_DECAY in 06 one at a time and the numbers above shift in front of you. The password for the archive is f8c3a4n6, and inside are the nine scenes as HTML and SCSS plus a React version.

Three Variants and Accessibility

Name Value Feel
Change the spring stiffness 01 STIFFNESS 170 → 320 First touch drops from 200ms to 117ms, while the overshoot doubles from 12.4% to 25.1%.
Change the underline lerp ratio 03 LERP 0.18 → 0.06 Landing within 1px takes 1300ms instead of 417ms, so the dot trails the letters.
Change the shake decay 06 SHAKE_DECAY 0.9 → 0.97 The shake takes 1217ms instead of 350ms to fall under 1px, and the swings over 1px go from 5 to 19.

The values in this table came from the same node calls as the three sections above.

Scenes 01 to 08 use a real <input> (a <textarea> in 08) and have no keydown handler and no preventDefault, so nothing blocks typing. Only 09 has a keydown handler, which calls preventDefault for ArrowDown and ArrowUp alone, and aria-expanded, aria-activedescendant and aria-selected appear in 09 only. Every label is a <label for> tied to its field, no file has an aria-live region, and focus shows as a 2px outline through :focus-visible.

When prefers-reduced-motion is on, none of the nine starts the requestAnimationFrame loop, and typing paints the target values directly with no interpolation. Scene 09 sets x straight to 0 or 1 and closes at once.

Is a Pure CSS Floating Label Enough?

If a field only switches between an empty state and a filled state, the calculations in this post are more than you need. The nine scenes keep CSS transition and @keyframes at zero and compute in JavaScript because each one needs a length or one value feeding several properties: 02 needs the label's measured width, 03 the width of text before the caret, 05 the perimeter, and 08 scrollHeight.

When you set this code beside a floating label css codepen snippet you found, look at whether isFilled(), which decides if the field is empty, is separate from render(), which moves the label. If they are separate, only the calculation on the right needs swapping. To count what people type, go to Textarea Character Counter: 9 Grapheme-Aware UIs, and for showing the shape of a value, Password Strength Meter UI: 9 Calculation Patterns comes next.

Frequently Asked Questions Before Reusing a CSS Form Floating Label

Where do I change the rise height and scale of a css floating label input?

The values sit in the const lines of each scene's <script>. The rise height is RISE_PX (26 in 02 only), the shrink ratio is LABEL_SCALE, and the names shown as chips on each scene are the values worth trying first.

Does it work with the keyboard and with reduced motion on?

The fields never block key input, and only the list in 09 moves with the arrow keys and Escape, plus Enter and Space on the button. With reduced motion on, the loop does not start and only the result is painted, while 06's error stays visible through color and text.

What happens if the label gets much longer?

In 02 the gap widens with the label until it passes the field width and the outline almost disappears, and in 04 every extra letter adds a delay, so the ripple gets longer. If you plan to translate the labels, open the scene once with the longest phrase before shipping it.

Enter the archive password

The password is inside this article. You will find it as you read.