Designing Forms

Updated:

When building a form in a feature: how to lay out and space fields for fast, unambiguous entry, and how to validate them clearly.

A form is where users do the most work in a feature, so good form design is mostly about reducing effort and removing ambiguity. The work has two halves: lay the form out so it is fast to scan and fill, then validate it so mistakes are caught in clear, specific terms. The sections below move from layout to validation, and they build on the grouping and spacing rules in Layout & Spacing.

Default to a single column

Keep fields in one column. A single column gives the eye one straight path down the form, which is the fastest and least error-prone way to fill it. Multiple columns force the user to decide where to look next and make it easy to miss a field or misread which label belongs to which input. This holds up across usability testing and eye-tracking studies.

The one exception is a few short fields that are naturally paired, such as expiry month and year, and it applies on larger screens only. On mobile there is no room for side-by-side fields, so a form should always be a single column there. Keep the grid's base columns at 1 and add the second column at a breakpoint with md-columns, so the fields stack by default and pair up only when there is space. Everything else stays stacked.

<q2-grid columns="1" md-columns="2" gap="comfortable">
  <q2-grid-area>
    <q2-input label="Expiration month" type="number"></q2-input>
  </q2-grid-area>
  <q2-grid-area>
    <q2-input label="Expiration year" type="number"></q2-input>
  </q2-grid-area>
</q2-grid>

Let q2-form handle field spacing

Do not hand-roll margins between fields. Wrap the fields in a q2-form and it applies one consistent vertical rhythm to every control inside it (25px by default). The spacing prop adjusts that rhythm with named steps (compact, normal, comfortable, none), so a form stays in step with the spacing scale without any custom CSS.

<q2-form>
  <q2-input label="First name"></q2-input>
  <q2-input label="Last name"></q2-input>
  <q2-input label="Email" type="email"></q2-input>
</q2-form>

Label clearly, and skip placeholders

Every field needs a persistent, visible label. The Tecton form controls render a top-aligned label from their label property; top labels scan quickly and translate well. Mark genuinely optional fields with the optional attribute, which appends "(optional)" to the label and sets aria-required="false", so the required fields are the unmarked default.

For field-specific help, such as a format example or a constraint, use the hints array rather than placeholder text. Placeholder text disappears the moment the user starts typing, often fails contrast guidelines, and is not always announced by screen readers, so it is unreliable as the only guidance. A hint stays visible while the user works in the field.

// Field help belongs in the hints array, not in placeholder text.
emailInput.hints = ['We will send transfer confirmations to this address.'];

Rank the form's actions

A form has one primary action. Give the submit button intent="workflow-primary" and demote everything else (Reset, Cancel, Back) to workflow-secondary, grouped together in a q2-action-group. This mirrors the action ranking in Visual Hierarchy: the one thing you want the user to do should be the one button that stands out.

<q2-action-group>
  <q2-btn intent="workflow-primary">Add Payee</q2-btn>
  <q2-btn intent="workflow-secondary">Reset</q2-btn>
</q2-action-group>

Reveal long forms progressively

If a form is long or has optional sections, do not show all of it at once. Put secondary fields inside a collapsible q2-section, or break a long task into stages with a q2-stepper. Showing the required path first and letting the rest unfold on demand keeps the form approachable instead of presenting a wall of inputs.

Be careful with disabled controls

Avoid disabling a submit button to block invalid input. A disabled control gives no reason for why it is disabled, often has low contrast, and is skipped by keyboard and screen-reader navigation, so the user can be left stuck with no explanation. Prefer letting the user submit and then showing inline validation that says what to fix. Reserve disabled controls for actions that are genuinely unavailable, not for guarding against mistakes.

Validate at the right moment

Validate on submit by default. Checking a field before the user has finished it produces errors for input that was simply incomplete, which feels like nagging. Run every check when the user submits, and mark the fields that failed.

Validate in real time only when it genuinely helps: cross-field rules such as a password confirmation that must match, or a mistake that is expensive to discover late. Even then, wait until both fields involved have a value before flagging a mismatch.

Show errors on the field, in plain words

Set the failing field's errors property to a non-empty array to activate its error state, and back to an empty array to clear it. Clear a field's error as soon as the user starts correcting it, so the feedback that the problem is fixed is immediate. The errors array takes precedence over hints, so a field's help text reappears once its errors are resolved.

Write messages that say how to fix the problem ("Enter an amount greater than $0"), not just that something is wrong ("Invalid"). Tecton form controls pair an icon and the message text with the color, so the error never depends on color alone. For the states that follow a successful submit, such as loading, success, or failure, see Designing for States.

// A non-empty array activates the error; an empty array clears it.
emailInput.errors = ['Enter a valid email address.'];
emailInput.errors = [];

In practice

The form below applies the layout rules and validates on submit. The fields are wrapped in a q2-form for consistent spacing and stacked in a single column, each with a persistent label. The optional nickname is marked with optional, and the email field carries a hint instead of a placeholder. Submitting checks the required fields and the email format, marking any field that fails; each field clears its own error as the user corrects it, and Reset returns the form to empty.

Checking Savings Add Payee Reset
<q2-form>
  <q2-input id="payee-name" label="Payee name"></q2-input>
  <q2-input id="payee-email" label="Email" type="email"></q2-input>
  <q2-input id="payee-nickname" label="Nickname" optional></q2-input>
  <q2-select id="payee-account" label="Account type">
    <q2-option value="checking">Checking</q2-option>
    <q2-option value="savings">Savings</q2-option>
  </q2-select>
</q2-form>
<q2-action-group>
  <q2-btn id="payee-submit" intent="workflow-primary">Add Payee</q2-btn>
  <q2-btn id="payee-reset" intent="workflow-secondary">Reset</q2-btn>
</q2-action-group>

<script>
// Element references.
const nameInput = document.querySelector('#payee-name');
const emailInput = document.querySelector('#payee-email');
const accountSelect = document.querySelector('#payee-account');
const submitBtn = document.querySelector('#payee-submit');
const resetBtn = document.querySelector('#payee-reset');

// Field help belongs in the hints array, not in placeholder text.
emailInput.hints = ['We will send transfer confirmations to this address.'];

// View state. The optional nickname and account type are not validated.
const state = { name: '', email: '' };
const emailPattern = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

// Validates the required fields and the email format. Returns true when valid.
function validate() {
    let valid = true;

    if (!state.name.trim()) {
        nameInput.errors = ['Enter a payee name.'];
        valid = false;
    }

    if (!state.email.trim()) {
        emailInput.errors = ['Enter an email address.'];
        valid = false;
    } else if (!emailPattern.test(state.email.trim())) {
        emailInput.errors = ['Enter a valid email address.'];
        valid = false;
    }

    return valid;
}

// Each field writes to state and clears its own error as the user types.
nameInput.addEventListener('tctInput', event => {
    state.name = event?.detail?.value ?? '';
    if (nameInput.errors?.length) nameInput.errors = [];
});

emailInput.addEventListener('tctInput', event => {
    state.email = event?.detail?.value ?? '';
    if (emailInput.errors?.length) emailInput.errors = [];
});

// Submit validates only. A real application would add the payee here.
submitBtn.addEventListener('tctClick', () => {
    if (!validate()) return;
});

// Reset returns the form to empty and clears any errors.
resetBtn.addEventListener('tctClick', () => {
    state.name = '';
    state.email = '';
    nameInput.value = '';
    emailInput.value = '';
    accountSelect.value = '';
    nameInput.errors = [];
    emailInput.errors = [];
});
</script>
Do keep forms in a single column, wrap fields in a q2-form for spacing, give every field a persistent label, put help in hints, validate on submit, and write errors that say how to fix the problem.

Don't spread fields across columns to save vertical space, hand-roll field margins, use a placeholder as the label, disable the submit button to block invalid input, or signal an error with color and no message.

References