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-shown never matches, so the label stays lifted over an empty field. The space is what keeps the placeholder invisible while still existing.
Control first, label second — the reverse of every other Deck form pattern
<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.

A filled input, a select, and an empty textarea
<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.

Empty and filled — the notch only appears once the label lifts
<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.

The colour is the signal; the message underneath is the information

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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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 .float label 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. scale does not respond to the reader's font settings the way a font-size would, 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 the for attribute 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 .error and aria-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.

The label lifts toward the right edge, which is where it started
<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 .field with 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.