Field
.field is one control and everything attached to it: a label above, the
control, and help or error text below. It is a flex column with a
.5rem gap, which means
a field never needs a margin — and that is the point. Margins on
form parts are how forms end up with inconsistent spacing.
When to use it
Wrap every control in one. A form is then a .stack of fields, and the
only spacing decisions left are the stack's gap and the field's gap — both of which
are already made.
min-inline-size: 0 is on the field for the same reason it is on
.list-main: a grid or flex child defaults to a minimum of its content,
so a long value in a field inside a .field-row would otherwise push the
row wider than its track.
As it appears on the invoice.
<div class="field" style="max-inline-size:24rem">
<label class="label" for="f-name">Full name</label>
<input class="input" id="f-name" placeholder="Ada Chen">
<p class="help">As it appears on the invoice.</p>
</div>
<div class="field" style="max-inline-size:24rem">
<label class="label" for="f-name">Full name</label>
<input class="input" id="f-name" placeholder="Ada Chen">
<p class="help">As it appears on the invoice.</p>
</div>
The parts
Label
.label is a flex row, not a block, so a marker sits beside the text
with no extra wrapper. .required is coloured with
--bad-500; .optional is faint and normal weight.
<div class="stack stack-4" style="max-inline-size:24rem">
<div class="field">
<label class="label" for="f-vat">VAT number <span class="required">*</span></label>
<input class="input" id="f-vat">
</div>
<div class="field">
<label class="label" for="f-co">Company <span class="optional">optional</span></label>
<input class="input" id="f-co">
</div>
</div>
<div class="stack stack-4" style="max-inline-size:24rem">
<div class="field">
<label class="label" for="f-vat">VAT number <span class="required">*</span></label>
<input class="input" id="f-vat">
</div>
<div class="field">
<label class="label" for="f-co">Company <span class="optional">optional</span></label>
<input class="input" id="f-co">
</div>
</div>
An asterisk means nothing to a screen reader — it is announced as "star" or
skipped. Put required on the input as well; that is what is actually
announced, and the asterisk is the visual echo of it.
Help and error
.help is muted; .error is a flex row in a red that is
lightened for dark mode with light-dark(), so it stays readable
against a dark surface rather than turning into a dark red on near-black.
Enter a complete email address.
<div class="field is-invalid" style="max-inline-size:24rem">
<label class="label" for="f-em">Email <span class="required">*</span></label>
<input class="input" id="f-em" value="ada@" aria-describedby="f-em-err">
<p class="error" id="f-em-err">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#alert-circle"></use></svg>
<span>Enter a complete email address.</span>
</p>
</div>
<div class="field is-invalid" style="max-inline-size:24rem">
<label class="label" for="f-em">Email <span class="required">*</span></label>
<input class="input" id="f-em" value="ada@" aria-describedby="f-em-err">
<p class="error" id="f-em-err">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#alert-circle"></use></svg>
<span>Enter a complete email address.</span>
</p>
</div>
align-items: flex-start keeps the icon on the first line of a
two-line error rather than centring it against the block.
Fieldset
.fieldset is a real <fieldset> with a border, a
radius and a flex column inside, and its <legend> is small and
muted. Use it wherever a group of controls answers one question — most
importantly, around a radio group.
<fieldset class="fieldset" style="max-inline-size:24rem">
<legend>Billing period</legend>
<label class="check">
<input type="radio" name="f-period" checked>
<span>Monthly</span>
</label>
<label class="check">
<input type="radio" name="f-period">
<span>Annual</span>
</label>
</fieldset>
<fieldset class="fieldset" style="max-inline-size:24rem">
<legend>Billing period</legend>
<label class="check">
<input type="radio" name="f-period" checked>
<span>Monthly</span>
</label>
<label class="check">
<input type="radio" name="f-period">
<span>Annual</span>
</label>
</fieldset>
File
.file is a dashed drop zone. The real
<input type="file"> inside it is positioned off and made 1px —
not display: none, which would take it out of the tab order.
:focus-within on the label is what gives the zone a visible focus
state when the hidden input is focused.
<label class="file" style="max-inline-size:24rem">
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#upload"></use></svg>
<span>Drop a CSV here, or choose a file</span>
<input type="file" accept=".csv">
</label>
<label class="file" style="max-inline-size:24rem">
<svg class="icon icon-lg" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#upload"></use></svg>
<span>Drop a CSV here, or choose a file</span>
<input type="file" accept=".csv">
</label>
Deck styles the drop zone; it does not implement dropping. Without a
drop handler the dashed border is a promise the page does not keep —
the reader can still click to choose a file, but dragging one onto it does
nothing.
Pairing fields
.field-row is one column on a phone and an auto-fitting grid above
40rem, with a 14rem floor per column. Because it is
auto-fit rather than a fixed count, two fields become two columns and
four become four or two depending on the room — no variant per count.
<div class="field-row">
<div class="field">
<label class="label" for="f-first">First name</label>
<input class="input" id="f-first">
</div>
<div class="field">
<label class="label" for="f-last">Last name</label>
<input class="input" id="f-last">
</div>
</div>
<div class="field-row">
<div class="field">
<label class="label" for="f-first">First name</label>
<input class="input" id="f-first">
</div>
<div class="field">
<label class="label" for="f-last">Last name</label>
<input class="input" id="f-last">
</div>
</div>
Pairing on the container
.field-row-cq does the same thing on its own container's width instead
of the viewport's. The source comment calls this "the one that keeps biting people",
and it is right: a two-up field row inside a narrow modal or a sidebar should stay
stacked even on a desktop, and a viewport media query cannot know that.
<div class="cq" style="resize:horizontal;overflow:auto;min-inline-size:16rem;inline-size:24rem;max-inline-size:100%;padding-inline-end:var(--space-3)">
<div class="field-row-cq">
<div class="field">
<label class="label" for="f-city">City</label>
<input class="input" id="f-city">
</div>
<div class="field">
<label class="label" for="f-post">Postcode</label>
<input class="input" id="f-post">
</div>
</div>
</div>
<div class="cq" style="resize:horizontal;overflow:auto;min-inline-size:16rem;inline-size:24rem;max-inline-size:100%;padding-inline-end:var(--space-3)">
<div class="field-row-cq">
<div class="field">
<label class="label" for="f-city">City</label>
<input class="input" id="f-city">
</div>
<div class="field">
<label class="label" for="f-post">Postcode</label>
<input class="input" id="f-post">
</div>
</div>
</div>
It needs a query container above it — .cq on the parent. Without one
the @container rule never matches and the row stays in one column
forever, which is a safe failure but not the one you wanted.
Form actions
.form-actions is a wrapping flex row for the buttons at the end of a
form. .form-actions-sticky pins it to the bottom of the viewport with a
blur behind it, and adds env(safe-area-inset-bottom) so it clears the
home indicator on a phone. Its buttons are full width below 48rem and
natural width above.
<div class="form-actions">
<button class="btn btn-primary">Save changes</button>
<button class="btn btn-ghost">Cancel</button>
</div>
<div class="form-actions">
<button class="btn btn-primary">Save changes</button>
<button class="btn btn-ghost">Cancel</button>
</div>
.form-actions-sticky is not shown live here because it would pin itself
to the bottom of this page. src/99-print.css forces it back to
position: static, so a printed form has its buttons where the form ends
rather than stamped across the last page.
Classes
Generated from the stylesheet source. The field component proper is
three classes and one state; the rest are the parts that live around it in
src/06-forms.css and are documented here because they have nowhere
better to go.
| Class | Layer | What it does | Source |
|---|---|---|---|
.field |
deck.components |
One form control and everything attached to it: label, input, help text, error. The vertical gap between them comes from here, so a field never needs margins. | 06-forms.css:11 |
.field-row |
deck.components |
Two fields side by side on anything wider than a phone | 06-forms.css:368 |
.field-row-cq |
deck.layout |
Form fields that pair up inside their own container, not the viewport. This is the one that keeps biting people: a two-up field row inside a narrow modal should stay stacked even on a desktop. | 18-container.css:83 |
.is-invalid |
deck.components |
No declarations. | 06-forms.css:110 |
.label |
deck.components |
The label for a control. A flex row, so a "required" or "optional" marker sits beside the text without extra markup. | 06-forms.css:20 |
.help |
deck.components |
A hint below a control. Needs aria-describedby on the control to reach a screen reader — sitting next to it is not an association. | 06-forms.css:37 |
.error |
deck.components |
A validation message below a control. Lightened for dark mode with light-dark(), and aligned to flex-start so an icon stays on the first line of a two-line message. | 06-forms.css:41 |
.required |
deck.components |
No declarations. | 06-forms.css:33 |
.optional |
deck.components |
No declarations. | 06-forms.css:30 |
.form-actions |
deck.components |
Sticky save bar for long forms on mobile | 06-forms.css:378 |
.form-actions-sticky |
deck.components |
Pins the action row to the bottom of the viewport with a blur behind it and a safe-area inset for the home indicator. Add matching bottom padding to the form, or the last field sits underneath it. | 06-forms.css:386 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-4 |
1rem |
|
--space-5 |
1.25rem |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--line-strong |
light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral))) |
|
--r-md |
12px |
|
--text-sm |
.8125rem |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--text-faint |
light-dark(var(--ink-500), var(--ink-500)) |
|
--bad-500 |
oklch(58% calc(var(--chroma-status) * 1.25) var(--hue-bad)) |
|
--bad-700 |
oklch(44% calc(var(--chroma-status) * 1.1) var(--hue-bad)) |
|
--brand-500 |
oklch(60.8% var(--chroma-brand) var(--hue-brand)) |
|
--brand-soft |
light-dark(var(--brand-50), oklch(26% .05 var(--hue-brand))) |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--bg |
light-dark(var(--ink-50), var(--ink-1000)) |
|
--z-sticky |
— | 100 |
Accessibility
-
.fieldhas no semantics. It is a<div>that supplies spacing. The label/control association comes fromforandid, and nothing about the class creates or checks it. -
Help and error text need
aria-describedby. Visual proximity is not association. Give the.helpor.erroranidand point the control at it, or the message is invisible to a screen reader. -
An asterisk is not "required". Put the
requiredattribute on the control..requiredis the visual marker for sighted readers and is announced as punctuation or not at all. -
Marking the optional ones is often better. If most fields are
required,
.optionalon the few that are not is less visual noise and reads more clearly than an asterisk on almost every label. -
A radio or checkbox group needs a
<fieldset>and<legend>. A.labelabove a group of radios looks like a group label and is not one. -
The file input is hidden but focusable. It is 1px and
transparent rather than
display: none, so it keeps its place in the tab order.:focus-withinon the label is what makes that focus visible — without it the input would be reachable but invisible. -
.erroris not a live region. Showing it does not announce it. For a message that appears after a failed submit, addrole="alert"at the moment it is inserted, or move focus to the first invalid control. -
.form-actions-stickycan cover content. It isposition: stickyat the bottom, so the last field of a long form can end up underneath it. Addpadding-block-endto the form equal to the bar's height.
Right to left
Everything here is a flex column, a grid, or logical padding, so the whole assembly
mirrors with no extra rules. The legend's padding-inline and the error
row's gap both follow the writing direction.
كما يظهر على الفاتورة.
<div dir="rtl" class="field-row">
<div class="field">
<label class="label" for="f-rtl-a">الاسم الأول <span class="required">*</span></label>
<input class="input" id="f-rtl-a">
</div>
<div class="field">
<label class="label" for="f-rtl-b">اسم العائلة</label>
<input class="input" id="f-rtl-b">
<p class="help">كما يظهر على الفاتورة.</p>
</div>
</div>
<div dir="rtl" class="field-row">
<div class="field">
<label class="label" for="f-rtl-a">الاسم الأول <span class="required">*</span></label>
<input class="input" id="f-rtl-a">
</div>
<div class="field">
<label class="label" for="f-rtl-b">اسم العائلة</label>
<input class="input" id="f-rtl-b">
<p class="help">كما يظهر على الفاتورة.</p>
</div>
</div>
Reduced motion
src/16-motion.css shakes an invalid field once —
dk-shake, 420ms, six pixels each way. Two things make it well behaved:
it is declared inside @media (prefers-reduced-motion: no-preference), so
a reader who has asked for less motion never gets the rule; and the selector is
.field.is-invalid:not(.was-shaken), so adding
.was-shaken after the first run stops it repeating on every re-render.
A form that shakes every time it re-renders is worse than one that never shakes.
:not(.was-shaken) is the whole reason that does not happen.
Printing
.fieldset gets the same surface treatment as a card — a hairline, no
radius, break-inside: avoid — so a group of controls is not split across
two sheets.
.form-actions is display: none. The buttons do not print at
all, which is right: a printed Save button is a control nobody can operate.
.form-actions-sticky is also forced to
position: static — belt and braces, since a fixed element that did
somehow print would be stamped across every page.
If a form is meant to be filled in on paper, that is a different document: the controls print as empty boxes, and the submit button is replaced by whatever the reader is supposed to do with the sheet.
Overriding it
@layer app.components {
/* A label beside the control instead of above it */
.field-inline {
display: grid;
grid-template-columns: 10rem minmax(0, 1fr);
align-items: center;
}
/* Pair fields sooner */
@media (min-width: 32rem) {
.field-row { grid-template-columns: repeat(auto-fit, minmax(min(11rem, 100%), 1fr)); }
}
}
Deck ships no inline-label variant on purpose: a label beside its control breaks down on a phone, needs a width guess that is wrong for long labels, and is harder to scan. If you need one it is four declarations, and the trade-off is then yours rather than the framework's.
When not to use it
-
Not for a checkbox or radio. Those already carry their own label
by wrapping it — see checkbox and radio. A
.fieldaround one adds a second label above the row and reads as two questions. -
.field-rowversus.grid. The field row is a grid with the form gap and a sensible column floor already chosen. If you are laying out anything that is not a pair of fields, use.gridand stop borrowing the form's spacing. -
.field-rowversus.field-row-cq. Use the plain one for a form that owns the page width. Use the container one whenever the form could appear in a modal, a drawer or a sidebar — which is most of the time, and is why the source comment says it keeps biting people. -
Not for a whole form's spacing. A
.fieldspaces one control's parts. The space between fields is a.stackon the form. Nesting fields to get more spacing is how a form ends up with four different gaps. -
Not around a submit button. Use
.form-actions, which wraps and keeps buttons on one line where there is room.