Patterns
Forms
A form is the densest thing most Kaptio users interact with, and the place where small inconsistencies compound fastest. Everything here follows from one idea: a field, its label and its message are a single unit.
Field anatomy
Label above with its info button, control, error below. Always in that order.
<form style="display: grid; gap: var(--flux-space-5); max-width: 32rem">
<div class="flux-field">
<div class="flux-field__header">
<label class="flux-field__label" for="p-name">
Departure name <span class="flux-field__required" aria-hidden="true">*</span>
</label>
<span class="flux-info" data-flux-info>
<button class="flux-info__trigger" type="button" aria-label="About departure name" aria-describedby="p-name-info">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5" />
<path d="M8 7.25V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<circle cx="8" cy="5" r="0.875" fill="currentColor" />
</svg>
</button>
<span class="flux-info__bubble" id="p-name-info" role="tooltip" hidden>Shown to travellers on the booking page.</span>
</span>
</div>
<input class="flux-input" id="p-name" type="text" value="Golden Circle — Winter" required aria-describedby="p-name-info" />
</div>
<div class="flux-field">
<span class="flux-field__label" id="p-channel-label">Sales channel</span>
<div class="flux-select" data-flux-select>
<button class="flux-select__control" id="p-channel-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-controls="p-channel-list" aria-labelledby="p-channel-label p-channel-value">
<span class="flux-select__value" id="p-channel-value">Direct B2C</span>
<svg class="flux-select__chevron" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<input type="hidden" name="channel" value="direct" />
<div class="flux-select__panel" id="p-channel-list" role="listbox" aria-labelledby="p-channel-label" hidden>
<ul class="flux-select__options">
<li><button class="flux-select__option" type="button" role="option" aria-selected="true" data-value="direct"><span>Direct B2C</span><svg class="flux-select__check" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M3 8L6.5 11.5L13 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg></button></li>
<li><button class="flux-select__option" type="button" role="option" aria-selected="false" data-value="trade" tabindex="-1"><span>Trade partner</span><svg class="flux-select__check" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M3 8L6.5 11.5L13 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg></button></li>
<li><button class="flux-select__option" type="button" role="option" aria-selected="false" data-value="agent" tabindex="-1"><span>Agent network</span><svg class="flux-select__check" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M3 8L6.5 11.5L13 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg></button></li>
</ul>
</div>
</div>
</div>
<div class="flux-field">
<label class="flux-field__label" for="p-cap">Capacity</label>
<input class="flux-input" id="p-cap" type="number" value="18" aria-invalid="true" aria-describedby="p-cap-error" />
<p class="flux-field__help" data-tone="error" id="p-cap-error">Capacity cannot exceed the vehicle limit of 16.</p>
</div>
<div style="display: flex; justify-content: flex-end; gap: var(--flux-space-2)">
<button class="flux-button flux-button--ghost" type="button">Cancel</button>
<button class="flux-button" type="submit">Save departure</button>
</div>
</form>Labels sit above the control rather than beside it. Top-aligned labels are faster to scan down, survive translation into longer languages, and do not force a second column decision on every form in the product.
Explanation lives in the info bubble beside the label, never as text below the field. If a field has a format constraint, put it there so it is one hover, focus or tap away while it is still cheap to comply with. The space below the field is reserved for errors, so a message there always means something needs fixing.
Form layout
One column, unless two fields genuinely belong on one line.
.form {
display: grid;
gap: var(--flux-space-5);
max-width: 32rem;
}
/* Two fields on one row, only when they are one idea. */
.form__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--flux-space-4);
}A single column has one path through it, and it is unambiguous on every screen size. Put two fields side by side only when they are halves of one value — a date range, a first and last name, an amount and its currency.
Size a field to its expected content where that helps: a postcode field that is 32rem wide is telling the user something untrue about what goes in it.
Validation
Validate on blur, clear on input, and never surprise someone mid-keystroke.
<div class="flux-field">
<label class="flux-field__label" for="cap">Capacity</label>
<input
class="flux-input"
id="cap"
type="number"
aria-invalid="true"
aria-describedby="cap-error"
/>
<p class="flux-field__help" data-tone="error" id="cap-error">
Capacity cannot exceed the vehicle limit of 16.
</p>
</div>- On blur, not on keystroke. Telling someone their email is invalid while they are still typing it is a interruption, not help.
- Clear the error as soon as they start fixing it. A message that persists while the user corrects the field reads as though the correction is not working.
- Say what to do. "Capacity cannot exceed the vehicle limit of 16" is useful. "Invalid value" is not.
- Bind the message.
aria-describedbyplusaria-invalid, or a screen reader user hears that something is wrong without hearing what.
Actions
Right-aligned, cancel first, primary last.
<div style="display: flex; justify-content: flex-end; gap: var(--flux-space-2)">
<button class="flux-button flux-button--ghost" type="button">Cancel</button>
<button class="flux-button" type="submit">Save departure</button>
</div>The primary action goes last, closest to where the eye finishes and where the cursor already is. Cancel is a ghost button — it needs to be reachable, not prominent.
Label the primary action with what it does. "Save departure" survives being read out of context; "Submit" does not.
Destructive actions
Name the consequence, and put it in the button.
<dialog class="flux-modal" open>
<div class="flux-modal__header">
<h2 class="flux-modal__title">Delete the Golden Circle departure?</h2>
</div>
<div class="flux-modal__body">
<p>Four bookings are attached to this departure and will need to be moved manually. This cannot be undone.</p>
</div>
<div class="flux-modal__footer">
<button class="flux-button flux-button--ghost">Keep departure</button>
<button class="flux-button flux-button--danger">Delete departure</button>
</div>
</dialog>The title states what will happen, the body states what it costs, and the button repeats the verb. "Are you sure?" with Yes and No asks someone to remember what they clicked thirty seconds ago — and reads as nothing at all to a screen reader landing on the buttons.
Do and don’t
Do
- Keep the label, control and message together as one unit.
- Put explanatory text in the info bubble beside the label.
- Use one column unless two fields are halves of one value.
- Validate on blur and clear on input.
- Write errors that say what to do.
- Put the primary action last, and label it with the verb.
Don’t
- Do not use a placeholder as a label.
- Do not put explanatory text below a field. Only errors go there.
- Do not disable submit until the form is valid.
- Do not show a red border without a message.
- Do not validate on every keystroke.
- Do not label a destructive confirmation “Yes”.