CSS Floating Labels
Form labels that float above the input on focus using :placeholder-shown.
A CSS floating label starts inside the input as placeholder text and slides up to the top edge once the field has focus or content. It solves the problem that placeholders create: the moment somebody starts typing, the placeholder disappears and the field loses its name. The floating label keeps that name on screen while still saving the vertical space a separate label would cost.
The whole thing runs on :placeholder-shown, a pseudo-class that matches an input only while its placeholder is still visible. Negate it and you have a selector that means the field has something in it. Two more versions follow: one that notches the label into the input border in the outlined style, and one that uses :has() so the label can come first in the markup.
Label floats on focus and on input
HTML
<!-- the placeholder must be a single space for :placeholder-shown to work -->
<div class="field">
<input type="text" id="fl-name" placeholder=" ">
<label for="fl-name">Full name</label>
</div>
<div class="field">
<input type="email" id="fl-email" placeholder=" ">
<label for="fl-email">Email address</label>
</div>
CSS
.field {
position: relative;
margin: .5rem 0;
}
.field input {
width: 240px;
padding: 1.25rem 1rem .5rem;
border: 2px solid #2a2a2d;
border-radius: 8px;
background: #141415;
color: #f0f0f0;
font-family: inherit;
font-size: 1rem;
outline: none;
transition: border-color .2s;
}
.field input:focus {
border-color: #b8ff57;
}
.field label {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
font-size: 1rem;
color: #88888f;
pointer-events: none;
transition: top .2s, font-size .2s, color .2s;
}
.field input:focus + label,
.field input:not(:placeholder-shown) + label {
top: .5rem;
font-size: .75rem;
color: #b8ff57;
transform: none;
}
Other ways to build it
Label notched into the border
Instead of parking the label inside the field, this version lifts it onto the top border and gives it a background that matches whatever sits behind the input, so the border appears to break around the text. The input gets even padding on all four sides, since nothing needs to be reserved inside it. The one thing to get right is the chip color: it has to match the page behind the field, not the field's own fill, or the notch shows as a colored rectangle over the border.
HTML
<div class="field field-notched">
<input type="text" id="notch" placeholder=" ">
<label for="notch">Company name</label>
</div>
CSS
.field-notched input {
padding: .9rem 1rem;
}
.field-notched label {
transition: top .2s, left .2s, font-size .2s, color .2s;
}
.field-notched input:focus + label,
.field-notched input:not(:placeholder-shown) + label {
top: -.55rem;
left: .75rem;
padding: 0 .35rem;
font-size: .75rem;
transform: none;
/* must match what is BEHIND the input, not the input's own fill */
background: #141415;
}
Label first in the markup, using :has()
The sibling combinator forces the input to be written before its label, which is the wrong way round for anyone reading the raw HTML. :has() removes that limit by letting the wrapper be selected on the state of the input inside it, so the label can sit where it belongs and still float. The same relational selector is what drives the error styling on input validation and the step highlighting on a multi step form.
HTML
<div class="field field-has">
<label for="job">Job title</label>
<input type="text" id="job" placeholder=" ">
</div>
CSS
.field-has:has(input:focus) label,
.field-has:has(input:not(:placeholder-shown)) label {
top: .5rem;
font-size: .75rem;
color: #b8ff57;
transform: none;
}
.field-has:has(input:focus) input {
border-color: #b8ff57;
}
How it works
The <label> sits on top of the <input> at normal size. The key selector is input:not(:placeholder-shown) + label, which fires when the placeholder is hidden because the user typed something, and floats the label up. A placeholder of a single space is required to make :placeholder-shown behave on an empty input.
Two selectors move the label and they cover different situations. input:focus + label handles the moment the field is focused but still empty. input:not(:placeholder-shown) + label handles a field that has content but no focus, which is the state a form sits in after the reader tabs away or after the browser autofills it. Leave the second one out and every filled label drops back down on blur, straight on top of the text the reader just entered.
The placeholder has to be a single space rather than an empty string. placeholder="" is treated as no placeholder at all, so :placeholder-shown never matches and the label never comes back down when the field is cleared. A space is a real placeholder that happens to be invisible. Some codebases use placeholder=" " with a comment, because it looks like a typo to the next person reading the file.
Order in the DOM matters for the first version. The + combinator only looks forward, so the input has to be written before the label. That is backwards from the usual label then input arrangement, and it is why the label carries a for attribute rather than wrapping the input. The pairing is what makes clicking the label focus the field, and it is what a screen reader reads out when focus lands. The :has() version below removes that constraint entirely.
The label needs pointer-events: none. Without it, the label is a box sitting over the middle of the input, so a click that lands on the label text focuses the field only because of the for attribute, and a click and drag to select text inside the input does nothing at all. Turning off pointer events lets every click fall through to the input underneath.
The input needs top padding larger than its bottom padding, because the floated label parks in that space. Here it is 1.25rem top against 0.5rem bottom. Get this wrong and the label overlaps the first line of text, which only shows up once somebody types, so it survives a casual visual check.
CSS properties used
:placeholder-shown- Matches an input while its placeholder is still visible, meaning the field is empty. Negated with
:not()it means the field has content. :focus- Floats the label while the field is focused, even before anything is typed. Needed alongside the placeholder rule, not instead of it.
positionrelativeon the wrapper,absoluteon the label. The label is taken out of flow so it can sit over the input and move without pushing anything.pointer-eventsnoneon the label lets clicks and text selection pass through to the input underneath it.transition- Animates
top,font-sizeandcolortogether. Animatingtophere is cheap because the label is a single short absolutely positioned line. :has()- Lets the wrapper be selected by the state of the input inside it, which frees the label to come before the input in the markup.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
:placeholder-shown | 47 | 51 | 9 | 79 |
::placeholder | 57 | 19 | 10.1 | 79 |
transition | 4 | 5 | 5.1 | 12 |
:focus-within | 60 | 52 | 10.1 | 79 |
:has() | 105 | 121 | 15.4 | 105 |
Figures are from Can I Use. The first two versions work in every browser released in the last several years. The :has() version is the newest piece here, with Firefox arriving late in December 2023, so check your own analytics before shipping it as the only implementation. A browser that does not understand :has() drops the entire rule rather than partially applying it, which leaves the label sitting in its resting position over typed text, so the fallback is worse than plain.
Accessibility notes
The label is a real <label> tied to the input by for and id, so a screen reader announces the field name on focus regardless of where the label is drawn. That is the difference between this pattern and using a placeholder alone. Placeholder text is announced inconsistently, disappears on input, and is not a label as far as assistive technology is concerned.
Contrast is the weak point. A label shrunk to 0.75rem and dimmed to a muted gray can fall below the 4.5 to 1 ratio that WCAG asks for on small text. The demos here move the floated label to the accent color partly for that reason. Check the resting state too, since a gray label sitting on a dark input is the state most readers see first.
Autofill breaks the effect in Chrome and Safari. The browser fills the value without firing the events a stylesheet would notice, and for a short window the field has text while :placeholder-shown still matches, so the label sits over the filled value. Adding input:autofill + label to the float rule covers Chrome and Safari. Firefox does not support :autofill, so it needs the -moz-autofill pseudo-class or nothing.
Do not remove the focus outline the way the base demo does with outline: none unless you replace it. The demo swaps in a two pixel accent border, which is a visible focus indicator, but a border color change alone can be hard to see against a dark field. A separate :focus-visible outline is safer on a real form.
What you can build with it
- Sign in and sign up forms. Two or three fields where vertical space is tight and every field needs a permanent name.
- Checkout address blocks. Long forms where a stacked label above every field doubles the height of the page.
- Search and filter panels in a sidebar. Narrow columns where a label above the field would wrap onto two lines.
- Inline editing in a table or card. The label floats only once the field is in use, so the resting state stays compact.
- Mobile forms. The label stays visible above the on-screen keyboard, where a placeholder would have vanished on the first keystroke.
Mistakes worth avoiding
- Using
placeholder=""instead of a single space.:placeholder-shownnever matches, so the label never returns to its resting position and an empty field looks permanently filled. - Writing the label before the input while relying on
+. The combinator only selects forward, so nothing happens at all and the label stays put. Either reorder the markup or move to the:has()version. - Leaving
pointer-events: noneoff the label. Click and drag inside the field stops selecting text, because the drag starts on the label instead of the input. - Giving the input equal top and bottom padding. The floated label lands on the first line of the value, which is invisible until someone actually types into the field.
- Forgetting the autofill case. Chrome fills a saved email address and the label sits right on top of it, which reads as a rendering bug to the person filling the form.
Frequently asked questions
Why does my floating label not come back down when the field is cleared?
placeholder="" counts as no placeholder, so :placeholder-shown never matches and the :not() rule is always true. Set the placeholder to a single space instead.Can the label come before the input in the HTML?
+ combinator, because sibling selectors only look forward. With :has() it can: select the wrapper on .field:has(input:focus) and style the label from there, in whatever order the two elements appear. The second variant on this page does that.Does a floating label work with a textarea?
:placeholder-shown applies to textarea as well as input. The only change is that the label should sit near the top of the box rather than vertically centered, since a textarea is several lines tall.How do I fix floating labels breaking on browser autofill?
input:autofill + label to the same rule that floats the label. Chrome, Safari and Edge support :autofill. Firefox does not, so it needs :-moz-autofill in a separate rule, since one unrecognised selector invalidates the whole list.Is a floating label better than a placeholder?
Can I animate the label with transform instead of top?
transform: translateY(-1rem) scale(.75) with transform-origin: left top instead of changing top and font-size. The label scales rather than reflowing, so the browser skips a layout pass, though at this size the difference is not something a reader would notice.