Floating label
A label that sits inside the field when it is empty and lifts above the text when it is
not. There is no JavaScript: :placeholder-shown tells the stylesheet
whether the field has content, so the label can never get out of sync with the value the
way a scripted version can.
When to use it
Use it where vertical space is genuinely tight and the labels are short — a dense settings panel, a payment form on a phone. It buys you roughly one label's height per field.
That is the whole benefit, and it is worth being honest that it is not large. A
.field with a label above it is easier to read,
easier to translate, and survives a long label without breaking. Reach for
.float when you have measured that you need the space, not by default.
The markup, and the order that matters
Two rules, and both are easy to get wrong because neither fails loudly:
-
The label comes after the control. Every selector that
lifts the label is a sibling combinator —
.float > .input:not(:placeholder-shown) ~ label— and~only looks forward. Put the label first and it simply never moves. -
The control needs
placeholder=" "— a single space. Without a placeholder attribute:placeholder-shownnever matches, so the label stays lifted over an empty field. The space is what keeps the placeholder invisible while still existing.
<div class="float" style="max-inline-size:22rem">
<input class="input" id="dx-fl-email" type="email" placeholder=" ">
<label for="dx-fl-email">Email address</label>
</div>
<div class="float" style="max-inline-size:22rem">
<input class="input" id="dx-fl-email" type="email" placeholder=" ">
<label for="dx-fl-email">Email address</label>
</div>
The label is still a real <label for>, so it names the control for a
screen reader whether it is lifted or resting. The visual trick is entirely in
translate and scale; nothing about the semantics changes.
What it works with
.input, .textarea and .select. The wrapper adds
the top padding the lifted label needs, and the textarea gets a little more because its
text starts at the top rather than being centred.
<div class="stack stack-3" style="max-inline-size:22rem">
<div class="float">
<input class="input" id="dx-fl-name" placeholder=" " value="Ada Lovelace">
<label for="dx-fl-name">Full name</label>
</div>
<div class="float">
<select class="select" id="dx-fl-plan">
<option>Starter</option>
<option>Team</option>
</select>
<label for="dx-fl-plan">Plan</label>
</div>
<div class="float">
<textarea class="textarea" id="dx-fl-note" placeholder=" " rows="3"></textarea>
<label for="dx-fl-note">Note</label>
</div>
</div>
<div class="stack stack-3" style="max-inline-size:22rem">
<div class="float">
<input class="input" id="dx-fl-name" placeholder=" " value="Ada Lovelace">
<label for="dx-fl-name">Full name</label>
</div>
<div class="float">
<select class="select" id="dx-fl-plan">
<option>Starter</option>
<option>Team</option>
</select>
<label for="dx-fl-plan">Plan</label>
</div>
<div class="float">
<textarea class="textarea" id="dx-fl-note" placeholder=" " rows="3"></textarea>
<label for="dx-fl-note">Note</label>
</div>
</div>
A <select> has no placeholder and always shows something, so its
label is lifted permanently — .float > .select ~ label carries no
:placeholder-shown test. That is correct, but it means a select in a
.float gets none of the space saving, only the appearance.
The outlined variant
.float-outline parks the lifted label on the border itself, in a notch cut
by giving it the surface colour as a background. It is the Material-style treatment, and
it works with Deck's own bordered controls without any extra markup.
<div class="stack stack-3" style="max-inline-size:22rem">
<div class="float float-outline">
<input class="input" id="dx-fo-a" placeholder=" ">
<label for="dx-fo-a">Company</label>
</div>
<div class="float float-outline">
<input class="input" id="dx-fo-b" placeholder=" " value="ACME Corp">
<label for="dx-fo-b">Trading name</label>
</div>
</div>
<div class="stack stack-3" style="max-inline-size:22rem">
<div class="float float-outline">
<input class="input" id="dx-fo-a" placeholder=" ">
<label for="dx-fo-a">Company</label>
</div>
<div class="float float-outline">
<input class="input" id="dx-fo-b" placeholder=" " value="ACME Corp">
<label for="dx-fo-b">Trading name</label>
</div>
</div>
The notch is background: var(--surface) on the label, not a real gap in the
border. On any other background — a tinted card, a
gradient — the label paints a surface-coloured rectangle
over it, and the effect breaks. Override background on the label to match
whatever it actually sits on.
Invalid
Two ways in, and they do the same thing. :user-invalid is the browser's
own — it applies after the reader has interacted and the value fails validation, which
is the right moment rather than on page load. .is-invalid on the wrapper is
the manual override for server-side errors the browser cannot know about.
That address is not registered.
<div class="stack stack-2" style="max-inline-size:22rem">
<div class="float is-invalid">
<input class="input" id="dx-fl-bad" placeholder=" " value="not-an-email" aria-describedby="dx-fl-bad-e">
<label for="dx-fl-bad">Email address</label>
</div>
<p class="error" id="dx-fl-bad-e">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#alert-circle"></use></svg>
<span>That address is not registered.</span>
</p>
</div>
<div class="stack stack-2" style="max-inline-size:22rem">
<div class="float is-invalid">
<input class="input" id="dx-fl-bad" placeholder=" " value="not-an-email" aria-describedby="dx-fl-bad-e">
<label for="dx-fl-bad">Email address</label>
</div>
<p class="error" id="dx-fl-bad-e">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#alert-circle"></use></svg>
<span>That address is not registered.</span>
</p>
</div>
It behaves differently from .field.
.field.is-invalid reaches into the control and reddens its border —
src/06-forms.css has a rule for exactly that. .float.is-invalid
has no such rule: it colours the label and stops. So the same class name on the two
wrappers does two different amounts of work, and a .float marked invalid
shows a red label above a normal-looking field. Use :user-invalid where you
can, since that reaches the control through
.input's own rule, and add the border
yourself where you cannot. Recorded in FINDINGS.md.
Classes
Generated from src/23-inputs.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.float |
deck.components |
Two things about the markup that this file cannot enforce and that fail silently when they are wrong: 1. The label must come AFTER the control. Every lift selector below is `~ label`, and the general sibling combinator only looks forward. Label first and it simply never moves. 2. The control needs placeholder=" " (one space). Without a placeholder attribute :placeholder-shown never matches, so the label sits lifted over an empty field. FINDINGS 61: .float.is-invalid only colours the label. .field.is-invalid in 06-forms.css also reddens the control's border; there is no equivalent rule here, so the same class name does two different amounts of work depending on which wrapper it is on. :user-invalid is unaffected -- it matches the control directly through the 06-forms.css rule. | 23-inputs.css:27 |
.float-outline |
deck.components |
Outlined variant, where the label sits in a notch in the border | 23-inputs.css:66 |
.is-invalid |
deck.components |
No declarations. | 06-forms.css:110 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--control-h-lg |
50px |
|
--text-faint |
light-dark(var(--ink-500), var(--ink-500)) |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--brand |
light-dark(var(--brand-600), var(--brand-400)) |
|
--bad-700 |
oklch(44% calc(var(--chroma-status) * 1.1) var(--hue-bad)) |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--text-base |
.9375rem |
|
--dur-2 |
190ms |
Accessibility
-
The label is real, and that is the reason to use this over a placeholder.
A placeholder-as-label disappears the moment someone types and is announced
inconsistently. A
.floatlabel is a<label for>that stays on screen and stays in the accessibility tree. This pattern is a fix for that problem, not another instance of it. -
The resting label is low-contrast. It is
light-dark(var(--ink-500), var(--ink-500)), which is chosen to read as a hint rather than as content. On an empty form that is the only thing naming the field, so check it against your surface — this is the same trade-off every floating-label implementation makes and it is the weakest point of the pattern. -
The lifted label is 78% size. On top of a small base size that can
land under 11px.
scaledoes not respond to the reader's font settings the way afont-sizewould, so it shrinks below whatever minimum they have set. -
Clicking the label works, by accident of stacking. The label is
pointer-events: none, so the click passes through to the control underneath and focuses it. The result is right; it is worth knowing it is not theforattribute doing it, so a label moved outside the wrapper stops responding to clicks. - A long label has nowhere to go. The label is absolutely positioned and does not wrap. In a language where your six-character label becomes twenty-four — German, Finnish — it runs out of the field. Test with your longest translation, not your English.
-
The error colour is not the error message. A red label says something
is wrong and not what. Pair it with
.errorandaria-describedby, as the example does.
Right to left
The label is placed with inset-inline-start and its
transform-origin is inline-start center — both logical, so the
label starts on the correct side and shrinks toward it rather than away. Nothing in
src/19-logical.css is needed for this component.
<div dir="rtl" class="float" style="max-inline-size:22rem">
<input class="input" id="dx-fl-rtl" placeholder=" " value="آدا لوفلايس">
<label for="dx-fl-rtl">الاسم الكامل</label>
</div>
<div dir="rtl" class="float" style="max-inline-size:22rem">
<input class="input" id="dx-fl-rtl" placeholder=" " value="آدا لوفلايس">
<label for="dx-fl-rtl">الاسم الكامل</label>
</div>
Reduced motion
The lift is a 190ms transition on
translate, scale and color, and the global reset
in src/02-reset.css collapses it to .01ms under
prefers-reduced-motion: reduce. The label still moves — it has to, or the
text would sit under it — but it arrives instantly rather than sliding.
Printing
.float is not in src/99-print.css. A filled field prints with
its label lifted and legible. An empty field prints with the label resting
inside it, which on paper is indistinguishable from a field someone has already filled
in with that word.
If the form is meant to be printed and completed by hand, use
.field instead. This is the one context where the
pattern actively misleads.
Overriding it
@layer app.components {
/* The outlined notch on a tinted card rather than the page surface */
.card .float-outline > label { background: var(--surface-2); }
/* A gentler lift for a form with long labels */
.float:focus-within > label,
.float > .input:not(:placeholder-shown) ~ label { scale: .88; }
/* Give .float.is-invalid the reach .field.is-invalid already has */
.float.is-invalid :is(.input, .textarea, .select) {
border-color: var(--bad-500);
}
}
If you change the scale, check the wrapper's top padding still clears the
label — the two are set independently and nothing warns you when they disagree.
When not to use it
-
Not as a default. A
.fieldwith the label above is more legible and more robust. Floating labels are a space optimisation, and space is rarely the binding constraint on a form. - Not with help text. The pattern saves a line and then you add one back underneath. If a field needs an explanation, use a normal label and put the explanation where it belongs.
- Not for a form you will translate. The label cannot wrap. English labels fit; the same labels in German frequently do not.
- Not on a form to be printed and filled in. See Printing — the resting label reads as an answer.
- Not for a checkbox or radio. There is nothing to float out of; the label belongs beside the control.
- Not mixed with plain fields in the same form. Two label conventions in one column reads as a mistake, and the reader has to learn both.