Component
field
Demo
tokens.css,
base.css and field.css. No scripts.
Source
Copy both files, or run npx nojsui add field.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| :placeholder-shown | widely | 47 | 79 | 51 | 9 | 47 | 51 | 9 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| field-sizing | newly | 123 | 123 | 152 | 26.2 | 123 | 152 | 26.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.
| Key | Behaviour |
|---|---|
Tab | Moves focus to the input; the label is not a tab stop |
| Any printable key | Types, and floats the label on the first character |
Esc | Nothing (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 used | Baseline status | Behavior without it |
|---|---|---|
:placeholder-shown | widely (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-invalid | widely | Invalid 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: content | low (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
| Property | Default | Controls |
|---|---|---|
--sk-field-bg | --sk-color-surface | Input background |
--sk-field-fg | --sk-color-text | Input text, and the floated label |
--sk-field-border-color | --sk-color-border-strong | Input border at rest |
--sk-field-invalid-color | --sk-color-danger | Border and label once :user-invalid matches |
--sk-field-label-fg | --sk-color-text-muted | Label while it sits inside the box |
--sk-field-hint-fg | --sk-color-text-subtle | Hint text |
--sk-field-radius | --sk-radius-md | Corner radius |
--sk-field-padding-block | --sk-space-sm | Vertical padding, and the distance the label travels |
--sk-field-padding-inline | --sk-space-sm | Horizontal padding, and the label’s inline offset |