ui.css · Cross-system: Cross-system reference § Forms
Native <input>, <textarea>, <select> with shared
styling from /ui/ui.css. Two structural classes:
.form-fields wraps a form, .form-group wraps each field + its label.
For customizable selects with option groups, use <x-select>.
.form-fields is a vertical flex container. Each child is a .form-group — a
<p> or <fieldset> with <label> (or
<legend>) followed by the input.
Indent the label and .field-help or .field-error text to align with the field's text: one border width plus --size-2, also used for .prefix icons. The border extends beyond that alignment. Apply this only beside framed fields. Radio and checkbox groups and legends over buttons keep their own alignment.
Show a value the caller may not change as a field as well, so the form keeps its shape whether or not the value can be edited. Put it in an .input-group with readonly on the input, give it a .prefix if it needs a leading icon, and keep the label and companion line where they sit for every other field. Do not replace the control with a .badge: a pill stretched across the field's width reads as a disabled control — something that could be switched on later — rather than a value that is simply stated. The states and their dimming are on Form field states, which also says what such a field does under the pointer and whether it stays a Tab stop.
Fields use --size-2 padding on both axes unless a specific design requires otherwise. Text line height adds space above and below the glyphs, so equal padding can still look taller than it is wide. Buttons retain --size-3 horizontal padding to give centered labels more room.
Wrap an input together with a prefix and/or suffix decoration (icons, units, clear buttons,
status indicators) using .input-group. It's a modern-CSS pattern — flex layout
plus :has(:focus-visible) plus Open Props tokens — not a custom element. The
group owns the border, background and focus ring; native inputs inside go transparent.
<ui-combobox> or other custom fields that ship their own chrome need the
raw attribute so the group owns the framing.
Any of <label>, <div role="group">, or
<fieldset> can carry the class; <label> is simplest for
single-field groups (implicit labelling), <fieldset> with
<legend> is right when you have multiple controls sharing a group label.
$
ui-combobox, x-select)
Custom form controls like <ui-combobox> and
<x-select> ship their own border, background, and
internal padding. Adding raw strips that chrome so the outer
.input-group owns the border and focus ring — and the
control's inner horizontal padding adapts to its siblings:
<input>, so content has breathing room from the border.<button class="suffix">) — keeps the left pad, drops the right so the content sits flush with the suffix..prefix — drops the left pad so the content sits flush with the prefix (the prefix already carries its own inline padding), and keeps the right one.
No consumer CSS required — the rules live on .input-group > ui-combobox[raw]
and .input-group > x-select[raw] in
/ui/ui.css. Each side keys off its own
sibling test: :first-child restores the left pad,
:last-child the right, so a control only loses the pad on the
side where a prefix or suffix already insets it. See the
<ui-combobox> rich-combobox demo (prefix + combobox) and
the x-select raw wrapping demo
(prefix + x-select) for concrete renders.
Chips modes (<ui-combobox raw chips> or multiple) follow the same three cases. Apply padding-inline-start and -end to the host because the chip row wraps; leave the inner input unpadded. Check the prefix and suffix sides independently. A single :first-child check for both sides could remove the far-side padding from a prefixed chip row.
Every undecorated framed field draws at the same height, so a form column reads as one
column whichever control a row happens to use. The height is the browser's
normal line box for the field's font, plus --size-2 above and
below, plus the border — 38 px for the 16 px reading font this site uses. An
<input>, an
<x-select>, a
<ui-combobox>, a native
<select> and a plain
.input-group all land there. A
<textarea> is the exception: it carries
min-height: 12rem so a multi-line field opens at a usable size.
The native <select> is the one control the stylesheet writes that
height out for. Left alone it draws 40 px in Chromium against everyone else's 38,
because Chromium wraps the chosen text in an internal button box two pixels taller than
the line box; Firefox draws 38 either way. Measured 2026-09-17 in Chrome 151, nothing
about appearance reaches those two pixels: appearance: none
still draws 40 and appearance: base-select draws 42, as do
line-height: 20px, line-height: 1,
field-sizing: content and align-items: center. So the select
keeps appearance: auto, each browser goes on drawing its own dropdown
arrow, and ui.css declares
block-size: calc(var(--font-lineheight-1) * 1em + 2 * var(--size-2) + 2 * var(--border-size-1))
instead. A <select multiple> or one carrying size is a
list box that sizes itself from its rows, and is left alone.
That calc is the paragraph above with one substitution: the browser's normal
line box is written as --font-lineheight-1, because CSS has no portable way
to read normal. The lh unit looks like the way to read it and
is not: Firefox resolves lh from the fallback font's metrics and does not
recompute it once the web font arrives, which put the select at 41 px on a cold
load while Chromium had it at 38. 1.25 is what normal measures
for this library's font at --font-size-1, so changing the font family would
move the other fields and leave the select behind. That is what the browser test in
public/ui/test/form/dimensions-wpt.spec.mjs is for: it compares each field
against the <input> beside it rather than against a figure.
A control that builds its field from a <button> or an inner
<input> has to declare line-height: normal for that to
hold. font: inherit otherwise hands it the page's body line height, which is
taller than the line box a native field keeps, and the row grows by the difference.
A decoration can make its group taller, and that is allowed: .prefix and
.suffix are flex siblings, so a group is as tall as its tallest child. Text
and icon decorations at the reading size fit inside the field height and change nothing;
a graphic larger than that raises the whole row. Read the height of a decorated group
from its content rather than expecting the figure above.
$
Focus ring is a shared rule on input, textarea, select,
.btn, .btn-icon, x-select > button — see
/ui/ui.css. Hover darkens the border of a
field the caller can edit; readonly, disabled and
locked fields keep their resting border and their focus ring
(Form field states).
Validation coloring on .input-group is driven by native
:user-valid / :user-invalid (Baseline 2023); a
<span class="suffix state"> picks up the group's state
color automatically. The full vocabulary — disabled,
readonly, user-valid / user-invalid,
error, flux, updating,
locked — plus the ranking that settles which of them shows
when several hold at once, live in
Form field states.
.field-help and .field-error
Use one <small> below the field for its hint, error or success message. Change its text and class between .field-help, .field-error and .field-success, keeping aria-describedby pointed at it. Add .info to .field-help for a line worked out from the value, or .warning for a value that is allowed but unusual; see Form field states § Info and warning lines. Set aria-invalid="true" on the input for an error; this also gives error styling the highest priority. aria-live="polite" announces updated text.
The second field's red border and icon come from aria-invalid="true". No group class or prior interaction is needed, so the same pattern can show server errors. Form field states § Asking the
server demonstrates the initial hint, loading feedback and server response.
Report a failure affecting the whole submission through toast(msg, {type: 'error'}). If it must remain visible, retain an active notify({id, …, is_current}) entry for <ui-statusbar>. See Status messages § Form errors for choosing between field feedback and submission feedback.
Textareas that hold long-form reading content use the .font-reading class (Literata serif).
<label for="…"> or a <legend> inside a <fieldset>. aria-label is a last resort — visible labels are always better.required attribute. For a custom native validation message, call setCustomValidity(msg) and reportValidity(). To keep a message visible, use the field's shared <small> companion line with aria-describedby and set aria-invalid="true" on the input. aria-errormessage applies only while invalid, so reserve it for a separate error element rather than a region that also contains a hint. See Form field states § Accessibility.
resize: vertical is the default in /ui/ui.css).<select> is preferred for simple single-choice cases. Use <x-select> only when you need option groups, custom rendering, or keyboard-accessible search.aria-hidden="true". Meaningful prefix text gets an id and is chained into the input's accessible name via aria-labelledby="label-id prefix-id". Interactive suffix <button>s need their own aria-label and must not be aria-hidden. An async state suffix uses aria-live="polite" so state changes are announced.See Cross-system reference § Forms for mappings to Material md-text-field (incl. prefix/suffix), Bootstrap input-group, and APG patterns.
<x-select> — customizable select with option groups.<ui-combobox> — autocomplete field; use raw to nest inside an input group..btn — form submit buttons.