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.

Label, control, help — one column, no margins

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.

.is-invalid on the field, aria-describedby joining input and message

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.

The legend is the group's accessible name
Billing period
<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.

Tab to it: the border lights up because of :focus-within
<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.

Two up above 40rem, stacked below it
<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.

Drag it narrow: the fields stack, whatever the window is doing
<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.

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

Design tokens this component reads
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

  • .field has no semantics. It is a <div> that supplies spacing. The label/control association comes from for and id, and nothing about the class creates or checks it.
  • Help and error text need aria-describedby. Visual proximity is not association. Give the .help or .error an id and point the control at it, or the message is invisible to a screen reader.
  • An asterisk is not "required". Put the required attribute on the control. .required is 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, .optional on 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 .label above 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-within on the label is what makes that focus visible — without it the input would be reachable but invisible.
  • .error is not a live region. Showing it does not announce it. For a message that appears after a failed submit, add role="alert" at the moment it is inserted, or move focus to the first invalid control.
  • .form-actions-sticky can cover content. It is position: sticky at the bottom, so the last field of a long form can end up underneath it. Add padding-block-end to 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.

A whole field row, mirrored, with no RTL-specific CSS

كما يظهر على الفاتورة.

<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 .field around one adds a second label above the row and reads as two questions.
  • .field-row versus .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 .grid and stop borrowing the form's spacing.
  • .field-row versus .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 .field spaces one control's parts. The space between fields is a .stack on 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.