Floating Labels with :placeholder-shown (No JavaScript, No Wrappers)
:placeholder-shown matches an input exactly while its placeholder is visible - which is a proxy for "the field is empty". That one bit of state, readable from CSS, is the entire engine behind floating labels:
<div class="field">
<input id="email" type="email" placeholder=" " required>
<label for="email">Email address</label>
</div>
.field {
position: relative;
}
.field label {
position: absolute;
inset-inline-start: 12px;
top: 50%;
translate: 0 -50%;
color: #64748b;
pointer-events: none; /* clicks fall through to the input */
transition: 0.15s ease;
}
/* Field has content OR focus: float the label up */
.field input:not(:placeholder-shown) + label,
.field input:focus + label {
top: 0;
font-size: 12px;
background: var(--surface, #fff);
padding-inline: 4px;
color: #4f46e5;
}
Empty and unfocused: label sits inside like a placeholder. Focused or filled: it floats to the border. Browser autofill triggers it correctly too, because autofilled fields stop showing their placeholder - the exact bug JS implementations of this pattern historically had.
The two tricks hiding in there
placeholder=" " - a single space. :placeholder-shown only matches if a placeholder exists; you don't want visible placeholder text competing with the label, so an invisible one satisfies the selector. Ugly, load-bearing, and the part everyone forgets.
Label after input in the markup, so the sibling combinator (+) can select it from the input's state. Visually reordered by absolute positioning; the for/id association keeps semantics intact. (With :has() you can keep label-first markup - .field:has(input:focus) label - which is where I'd write this today; the sibling version works back to much older browsers.)
Other things :placeholder-shown is quietly great at
/* Show the "clear" button only when there's something to clear */
.search:has(input:not(:placeholder-shown)) .clear-btn { visibility: visible; }
/* Validation styling that waits until the user typed something */
input:not(:placeholder-shown):invalid { border-color: #dc2626; }
That second one is the poor man's :user-invalid for engines where you need broader reach - errors appear only after content exists, not on pristine forms.
Accessibility notes worth taking seriously
This pattern's virtue is that it uses a real label - screen readers announce it regardless of visual position, unlike placeholder-only forms (a known WCAG failure: placeholders vanish on input, leaving no visible field identity... which floating solves too). Keep contrast on the floated state, don't shrink below ~12px, and never encode critical instructions in a floating label - they belong in persistent hint text.
Support: :placeholder-shown is universal (it's from 2015!). The whole pattern - markup, CSS, autofill behavior - is one of those rare tricks with no JavaScript to break and no library to update.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.