Skip to content

Component

field

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and field.css. No scripts.

Source

Copy both files, or run npx nojsui add field.

Source for field
<!-- .sk-field — a text input whose label sits in the box until there is
     something in it, then moves out of the way.

     TWO THINGS HERE ARE LOAD-BEARING AND LOOK LIKE MISTAKES:

     1. placeholder=" " — a single space. :placeholder-shown only matches while
        the input HAS a placeholder, so the float depends on one existing; a
        space keeps it invisible. It is NOT the label and must never become
        one. The label below is a real <label for>, so the accessible name
        survives with the placeholder removed — which is the failure this
        pattern is famous for, where the label IS the placeholder and disappears
        the moment the user types.

     2. The <label> comes AFTER the <input> in source order. The float is
        driven by `:not(:placeholder-shown) + .sk-field__label`, and CSS has no
        previous-sibling combinator that would let the label come first. It is
        placed visually by the CSS, and `for`/`id` means the association does
        not depend on order — a screen reader reads the label with the field
        either way.

     The hint is linked with aria-describedby. There is deliberately no custom
     error message: see the README on why a conditionally-announced one needs
     script, and why :user-invalid plus the browser's own validation message is
     the honest zero-JS answer. -->
<div class="sk-field">
  <input
    class="sk-field__input"
    id="sk-field-email"
    name="email"
    type="email"
    placeholder=" "
    required
    aria-describedby="sk-field-email-hint"
  />
  <label class="sk-field__label" for="sk-field-email">Email address</label>
  <p class="sk-field__hint" id="sk-field-email-hint">
    Try typing something that is not an address, then click away.
  </p>
</div>

<div class="sk-field">
  <input
    class="sk-field__input"
    id="sk-field-name"
    name="name"
    type="text"
    placeholder=" "
    value="Ada Lovelace"
  />
  <label class="sk-field__label" for="sk-field-name">Full name</label>
  <p class="sk-field__hint" id="sk-field-name-hint">
    Starts filled, so its label starts floated.
  </p>
</div>

<!-- The textarea variant. With field-sizing: content it grows as you type;
     without it, it is an ordinary textarea at its rows= height. -->
<div class="sk-field" data-variant="grow">
  <textarea
    class="sk-field__input"
    id="sk-field-note"
    name="note"
    rows="2"
    placeholder=" "
    aria-describedby="sk-field-note-hint"
  ></textarea>
  <label class="sk-field__label" for="sk-field-note">Note</label>
  <p class="sk-field__hint" id="sk-field-note-hint">
    Grows with what you type, where field-sizing is supported.
  </p>
</div>

<!-- data-label="above": the label sits in its own row. Note the real
     placeholder, which the floating variant cannot have — not because
     :placeholder-shown behaves differently (it does not; it keys off an empty
     value plus a present attribute, whatever the attribute says) but because
     the floating label rests over the input's padding box, exactly where the
     placeholder is painted. Here it never overlaps the control. -->
<div class="sk-field" data-label="above">
  <input
    class="sk-field__input"
    id="sk-field-company"
    name="company"
    type="text"
    placeholder="Acme Inc."
    aria-describedby="sk-field-company-hint"
  />
  <label class="sk-field__label" for="sk-field-company">Company</label>
  <p class="sk-field__hint" id="sk-field-company-hint">Optional.</p>
</div>
/* .sk-field — a text input with a floating label.

   The whole component is a real <input> and a real <label for>. Nothing is
   faked and nothing is repositioned by script: the label is absolutely
   positioned over the input's padding box and moves when the input is focused
   or is not showing its placeholder.

   THE RULE THIS FILE EXISTS TO OBEY: validation styling uses :user-invalid,
   never :invalid. `:invalid` matches a required field before the user has
   touched it, so a fresh form renders every field in red and the error state
   means nothing by the time it is earned. :user-invalid waits for the user to
   have interacted. That is the entire reason the selector was added to the
   platform, and getting it wrong is invisible to axe and to any snapshot taken
   after typing.

   Specs:
   - :user-invalid ......... https://drafts.csswg.org/selectors-4/#user-invalid-pseudo
   - :placeholder-shown .... https://drafts.csswg.org/selectors-4/#placeholder-shown
   - field-sizing .......... https://drafts.csswg.org/css-ui-4/#field-sizing */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-field {
  --_bg: var(--sk-field-bg, var(--sk-color-surface));
  --_fg: var(--sk-field-fg, var(--sk-color-text));
  --_border-color: var(--sk-field-border-color, var(--sk-color-border-strong));
  --_invalid-color: var(--sk-field-invalid-color, var(--sk-color-danger));
  --_label-fg: var(--sk-field-label-fg, var(--sk-color-text-muted));
  --_hint-fg: var(--sk-field-hint-fg, var(--sk-color-text-subtle));
  --_radius: var(--sk-field-radius, var(--sk-radius-md));
  --_padding-block: var(--sk-field-padding-block, var(--sk-space-sm));
  --_padding-inline: var(--sk-field-padding-inline, var(--sk-space-sm));

  position: relative;
  display: grid;

  /* The label overlays the input, so it must not take a row of its own. */
  grid-template-areas:
    "control"
    "hint";
}

.sk-field__input {
  grid-area: control;

  /* Room above the text for the floated label to sit in. */
  padding-block: calc(var(--_padding-block) * 1.75) calc(var(--_padding-block) * 0.5);
  padding-inline: var(--_padding-inline);
  border: var(--sk-border-width) solid var(--_border-color);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);

  /* Fills its grid area rather than the UA's size attribute default, so two
     fields in a column line up whatever their type. */
  inline-size: 100%;
}

/* The float. Two states, one rule: focused, or holding something. */
.sk-field__label {
  grid-area: control;

  /* Positioned over the input's own box rather than laid out beside it.
     pointer-events: none so a click lands on the input under it — the <label
     for> association still focuses the input on click, this just keeps the
     caret from being placed oddly when clicking the floated text. */
  align-self: start;
  margin-block-start: calc(var(--_padding-block) * 1.75);
  margin-inline-start: var(--_padding-inline);
  color: var(--_label-fg);
  pointer-events: none;
}

.sk-field__input:focus + .sk-field__label,
.sk-field__input:not(:placeholder-shown) + .sk-field__label {
  /* Physical translate, on the block axis, matching the padding it moves
     into — the field's own layout is logical, but a label lifting out of a
     box lifts on the block axis in every writing mode this component
     supports. */
  translate: 0 calc(-1 * var(--_padding-block) * 1.35);
  color: var(--_fg);
  font-size: var(--sk-text-xs);
}

/* data-label="above" — the label takes its own row instead of overlaying the
   control.

   The floating label is a DESIGN CHOICE, not a neutral default, and it is the
   one reason a site with static labels cannot use this component — while
   :user-invalid, the hint slot and the aria-describedby convention are exactly
   what such a site wants. This variant keeps all of that and moves only the
   label.

   It also drops the placeholder=" " requirement and makes a REAL placeholder
   usable. Note why the float cannot have one, because it is not the obvious
   reason: :placeholder-shown keys off the value being empty and an attribute
   being present, not off what the attribute says, so a real placeholder
   matches exactly as a single space does. The conflict is that the resting
   label sits over the input's padding box — where the browser paints the
   placeholder — so the two would print on top of each other. Here the label
   never overlaps the control, so there is nothing to collide with. */
.sk-field[data-label="above"] {
  grid-template-areas:
    "label"
    "control"
    "hint";
}

.sk-field[data-label="above"] .sk-field__label {
  grid-area: label;

  /* Back into normal flow: no overlay, and nothing to float. The label
     transition further down still applies to this label, but translate and
     font-size never change here, so the only thing that animates is the
     colour when :user-invalid starts matching — which is wanted.

     This rule and the float rules above tie on specificity; the variant wins
     on source order alone. Keep it below them. */
  position: static;
  margin-block: 0 var(--sk-space-2xs);
  margin-inline: 0;
  translate: none;
  color: var(--_label-fg);
  font-size: var(--sk-text-sm);
  pointer-events: auto;
}

.sk-field[data-label="above"] .sk-field__input {
  /* Even padding: there is no floated label to leave room for. */
  padding-block: var(--_padding-block);
}

.sk-field[data-label="above"] .sk-field__input:user-invalid + .sk-field__label {
  color: var(--_invalid-color);
}

.sk-field__hint {
  grid-area: hint;
  margin-block-start: var(--sk-space-2xs);
  color: var(--_hint-fg);
  font-size: var(--sk-text-xs);
}

/* :user-invalid, never :invalid — see the header. The message itself is the
   browser's own: it is announced, and it is localized, neither of which a
   CSS-revealed string in the DOM can manage (README). */
.sk-field__input:user-invalid {
  border-color: var(--_invalid-color);
}

.sk-field__input:user-invalid + .sk-field__label {
  color: var(--_invalid-color);
}

/* The focus ring comes from base.css and is deliberately not overridden here:
   a custom-styled control quietly losing its ring is the most common
   accessibility regression in this category. */

@media (prefers-reduced-motion: no-preference) {
  .sk-field__label {
    transition:
      translate var(--sk-motion-fast) var(--sk-ease-out),
      font-size var(--sk-motion-fast) var(--sk-ease-out),
      color var(--sk-motion-fast) var(--sk-ease-out);
  }
}

/* The textarea variant grows with its content instead of scrolling. Behind
   @supports because field-sizing is Baseline low (Chrome 123+, Firefox 152+,
   Safari 26.2+); without it the textarea is an ordinary one at its rows=
   height, which is a complete and unremarkable control. */
@supports (field-sizing: content) {
  .sk-field[data-variant="grow"] .sk-field__input {
    field-sizing: content;
    min-block-size: 3lh;
  }
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour — the demo stacks three fields.
--------------------------------------------------------------------------- */

.sk-field + .sk-field {
  margin-block-start: var(--sk-space-lg);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
:placeholder-shownwidely477951947519
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
field-sizingnewly12312315226.212315226.2

Usage

A text input whose label sits inside the box until there is something in it.

<div class="sk-field">
  <input class="sk-field__input" id="email" type="email" placeholder=" " required />
  <label class="sk-field__label" for="email">Email address</label>
</div>

Two things in that markup look like mistakes and are not.

placeholder=" " is a single space, and it is not the label. :placeholder-shown only matches while the input has a placeholder, so the float depends on one existing; a space keeps it invisible. The accessible name comes from the real <label for> and survives the placeholder being deleted — which is the failure this pattern is famous for, where the label is the placeholder and vanishes the moment the user types.

The <label> comes after the <input>. The float is driven by :not(:placeholder-shown) + .sk-field__label, and CSS has no previous-sibling combinator. The label is placed visually by the CSS, and because the association is for/id rather than positional, a screen reader reads the two together regardless of source order.

Add data-variant="grow" to a <textarea> field to have it grow with its content.

How it works

The label is a grid item in the same cell as the input, positioned over the input’s own padding box, and it moves when either of two things is true: the input is focused, or it is not showing its placeholder.

.sk-field__input:focus + .sk-field__label,
.sk-field__input:not(:placeholder-shown) + .sk-field__label { … }

That second selector is what makes a pre-filled field render correctly on load with no script — the label is already floated because there was never a placeholder showing.

:user-invalid, never :invalid

This is the most valuable thing this component demonstrates, and getting it wrong is invisible to axe and to any screenshot taken after typing.

:invalid matches a required field before the user has touched it. Style with it and a fresh, untouched form renders every field in red, so the error state means nothing by the time the user has actually earned it. :user-invalid waits until the user has interacted — typed and left, or submitted. That is the entire reason the selector was added to the platform.

There is no custom error message, on purpose

A conditionally-announced error message needs JavaScript, and the reason is worth being precise about: text that sits in the DOM for CSS to reveal is also in the accessibility tree when the field is valid. Wire it up with aria-describedby and every screen reader user hears the error read out alongside the label whether or not anything is wrong. Hiding it with display: none until :user-invalid fixes the announcement but means the message is not in the tree at the moment it becomes relevant, and nothing tells assistive technology it appeared.

So: :user-invalid restyles the control, and the browser’s own validation message does the announcing. It is announced correctly, it is localized, and it costs nothing.

If you need an inline, custom-worded error, that is the part that needs script. See docs/polyfills.md.

Static labels

Add data-label="above" to .sk-field to give the label its own row instead of floating it over the control. The floating label stays the default — it is a shipped API with consumers, and changing that default silently would break them. data-label="above" is an opt-in for sites whose design does not make room for a floating label at all.

What it keeps: everything that makes this component worth adopting is still here — :user-invalid styling, the hint slot, the aria-describedby convention.

What it drops: the placeholder=" " requirement, and with it the mutual exclusivity that is the most surprising thing about the default variant.

With a floating label a real placeholder is unusable, and the reason is not the one people assume. :placeholder-shown matches whenever the value is empty and a placeholder attribute exists; the attribute’s content is irrelevant to it. placeholder="Acme Inc." and placeholder=" " match and stop matching at exactly the same moments, so the float would work fine.

The collision is visual. At rest — empty and unfocused — the label sits inside the input’s padding box, which is precisely where the browser paints placeholder text. Two strings, one position. The single space in placeholder=" " exists to satisfy the “an attribute is present” half of :placeholder-shown while rendering nothing to collide with.

data-label="above" reads :placeholder-shown for nothing and never puts the label over the control, so a real placeholder works normally:

<div class="sk-field" data-label="above">
  <input class="sk-field__input" id="company" type="text" placeholder="Acme Inc." />
  <label class="sk-field__label" for="company">Company</label>
</div>

Source order is unchanged, and that’s fine. The <label> still comes after the <input> in markup, for the same reason as the default variant: the float rule (:not(:placeholder-shown) + .sk-field__label) has no previous-sibling combinator to lean on, and this variant reuses the same markup shape rather than forking it. grid-template-areas puts the label back above the control visually; the for/id association means a screen reader reads label and input together regardless of where either sits in source order.

Keyboard contract

Entirely native — this component adds no key handling of its own.

KeyBehaviour
TabMoves focus to the input; the label is not a tab stop
Any printable keyTypes, and floats the label on the first character
EscNothing (the browser may clear a search-type input)

Verified manually in VoiceOver and NVDA: yes — the field is announced by its <label> text plus its hint, and the validation message is announced on blur.

Accessibility notes

The accessible name is the <label for>, never the placeholder. You can delete the placeholder attribute and the field is still named correctly — the float stops working, but nothing about the control’s semantics changes. That is the right way round.

Link a hint with aria-describedby, as the demo does. Hints are always true, so they are always in the accessibility tree; errors are not, which is why they are handled differently above.

The focus ring comes from base.css and is not overridden here. A custom-styled input quietly losing its focus ring is the most common accessibility regression in this category, so if you restyle the field, leave :focus-visible alone.

The label has pointer-events: none so clicks land on the input underneath. This does not affect the label’s function — the for/id association is what makes clicking a label focus its field, and that still works because the click reaches the input directly.

Degradation

Feature usedBaseline statusBehavior without it
:placeholder-shownwidely (Chrome 47+, Firefox 51+, Safari 9+)The label never floats and overlaps the text being typed. This is the floor — the component needs it.
:user-invalidwidelyInvalid fields are not restyled. The browser’s native validation message still appears and is still announced, so nothing is lost but the colour.
field-sizing: contentlow (Chrome 123+, Firefox 152+, Safari 26.2+)The data-variant="grow" textarea is an ordinary textarea at its rows= height.
data-label="above"Requires none of the floor. It does not read :placeholder-shown, so it needs nothing beyond CSS grid. It still uses :user-invalid, which degrades as the row above describes.

Theming

PropertyDefaultControls
--sk-field-bg--sk-color-surfaceInput background
--sk-field-fg--sk-color-textInput text, and the floated label
--sk-field-border-color--sk-color-border-strongInput border at rest
--sk-field-invalid-color--sk-color-dangerBorder and label once :user-invalid matches
--sk-field-label-fg--sk-color-text-mutedLabel while it sits inside the box
--sk-field-hint-fg--sk-color-text-subtleHint text
--sk-field-radius--sk-radius-mdCorner radius
--sk-field-padding-block--sk-space-smVertical padding, and the distance the label travels
--sk-field-padding-inline--sk-space-smHorizontal padding, and the label’s inline offset