Navigation

v0.0.4
Layouts

Form

The rhythm of a form: rows, sections and actions that follow their column.

On this page

A form layout sets the rhythm between fields: one distance down the column, short fields side by side, and the buttons that close it at the end. It is a layout, not a component: it draws no surface and holds no state. What it knows is when to stack.

The form is a size container, so its rows and sections follow the column the form is given, not the viewport. The same markup is two columns on a settings page and one column in a narrow dialog. Those thresholds are why this is a class and not a handful of utilities.

Form

<form class="form max-w(480) mx-auto">  <div class="form-row">    <label class="field">First name      <input class="input" name="given-name" autocomplete="given-name" value="Ada">    </label>    <label class="field">Last name      <input class="input" name="family-name" autocomplete="family-name" value="Lovelace">    </label>  </div>  <label class="field">Email    <input class="input" type="email" name="email" autocomplete="email" value="ada@example.com">    <span class="field-hint">We send a receipt here.</span>  </label>  <label class="field">Message    <textarea class="input input-auto" name="message"></textarea>  </label>  <div class="form-actions">    <button class="btn" type="button">Cancel</button>    <button class="btn btn-primary">Send</button>  </div></form>

#Rows

form-row holds fields that are read as one line: a first and a last name, a city and a postcode. From 28rem of form they share the line in equal columns; below that they stack. Keep a row to two or three short fields.

#Sections

A form-section puts a heading beside the fields it describes. From 40rem of form the heading takes a third of the width and the fields the rest, the way a settings page reads; below that the heading sits above its fields. Sections are parted by a hairline.

Sections

<form class="form w-full">  <section class="form-section" aria-labelledby="form-profile">    <div class="form-heading">      <h3 class="form-title" id="form-profile">Profile</h3>      <p class="form-description">Shown on your public page and next to your comments.</p>    </div>    <div class="form-fields">      <div class="form-row">        <label class="field">First name          <input class="input" value="Ada">        </label>        <label class="field">Last name          <input class="input" value="Lovelace">        </label>      </div>      <label class="field">Bio        <textarea class="input input-auto"></textarea>      </label>    </div>  </section>  <section class="form-section" aria-labelledby="form-notify">    <div class="form-heading">      <h3 class="form-title" id="form-notify">Notifications</h3>      <p class="form-description">Choose what reaches your inbox.</p>    </div>    <div class="form-fields">      <label class="check">        <input type="checkbox" class="checkbox" checked>Mentions and replies</label>      <label class="check">        <input type="checkbox" class="checkbox">A weekly summary</label>    </div>  </section>  <div class="form-actions">    <button class="btn" type="button">Discard</button>    <button class="btn btn-primary">Save changes</button>  </div></form>

#Actions

form-actions closes the form: buttons at the end of the line, the confirming one last. Below 24rem of form they stack at full width and the confirming button comes first, where the thumb is. Write the buttons in reading order, cancel before confirm; the stylesheet reverses the stack.

In a narrow column

<form class="form max-w(300) mx-auto">  <div class="form-row">    <label class="field">City      <input class="input" value="London">    </label>    <label class="field">Postcode      <input class="input" value="N1 9GU">    </label>  </div>  <div class="form-actions">    <button class="btn" type="button">Cancel</button>    <button class="btn btn-primary">Save address</button>  </div></form>
The same classes at 300px: the row and the actions stack by themselves.

#Accessibility

  • The form classes are layout only. Labels, hints and errors come from field; groups of related controls from fieldset.
  • Give each section a heading and point the <section> at it with aria-labelledby, so the group has a name.
  • The stacked actions change order visually, not in the document: the tab order stays cancel, then confirm.
  • A container needs a width from its context. Inside a shrink-to-fit box (an inline-flex parent, a popover without a width) give the form a width.

#Classes

ClassDescription
formThe column of fields, at one distance, and a size container named form. Its width must come from its context
form-rowFields read as one line; they share it in equal columns from 28rem of form and stack below
form-sectionA heading with the fields it describes; from 40rem of form the heading stands beside them at a third of the width. Sections are parted by a hairline
form-headingThe title and the description of a section
form-titleThe title of a section
form-descriptionA sentence under the title of a section
form-fieldsThe fields of a section, at the form's distance
form-actionsThe closing buttons, at the end with the confirming one last; below 24rem of form they stack at full width with the confirming one first

#Custom properties

PropertyDefaultDescription
--form-gap1.25remThe distance between fields

#Source

@master/ui/layouts/form.css
/* * Form: the rhythm of a form. Fields stack at one distance, a row puts short fields side by side, a section * sets a heading beside its fields, and the actions close it. * This is a layout: it draws no surface and holds no state. What it knows is when to stack. The form is a * size container, so rows and sections follow the column the form is given, not the viewport: in a narrow * dialog they stack by themselves. * The width must come from the context; inside a shrink-to-fit box a container collapses. */@layer components {  .form {    --form-gap: 1.25rem;    display: grid;    gap: var(--form-gap);    min-width: 0;    margin: 0;    container: form / inline-size;  }  /* Fields that are read as one line: first and last name, city and postcode. At 28rem of form they share it. */  .form-row {    display: grid;    gap: var(--form-gap) .75rem;    min-width: 0;    @container form (inline-size >= 28rem) {      grid-auto-columns: minmax(0, 1fr);      grid-auto-flow: column;      align-items: start;    }  }  /*   * A section: a heading that says what the fields below are for. From 40rem of form the heading moves beside   * its fields, a third of the width, the way a settings page reads. Sections are parted by a hairline.   */  .form-section {    display: grid;    gap: 1rem var(--form-gap);    min-width: 0;    & + & {      padding-top: var(--form-gap);      border-top: 1px solid var(--color-line-hair);    }    @container form (inline-size >= 40rem) {      grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);      gap: var(--form-gap) 2rem;    }  }  .form-heading {    display: grid;    align-content: start;    gap: .25rem;    min-width: 0;  }  .form-title {    margin: 0;    color: var(--color-text-strong);    font-size: .9375rem;    font-weight: var(--font-weight-control);    line-height: 1.4;    letter-spacing: -.005em;  }  .form-description {    margin: 0;    color: var(--color-text-muted);    font-size: .8125rem;    line-height: 1.55;    text-wrap: pretty;  }  .form-fields {    display: grid;    align-content: start;    gap: var(--form-gap);    min-width: 0;  }  /*   * Actions: the buttons that close the form, at its end with the confirming one last. Below 24rem of form   * they stack at full width and the confirming button comes first, under the thumb.   */  .form-actions {    display: flex;    flex-wrap: wrap;    justify-content: flex-end;    gap: .5rem;    min-width: 0;    @container form (inline-size < 24rem) {      flex-direction: column-reverse;      align-items: stretch;    }  }}