Forms
Patterns for building and organizing forms in Senco. Most Senco flows — sending money, onboarding, adding recipients — are form-driven, so consistent structure across inputs, hints, and validation directly affects completion rates.
A form in this product is rarely a convenience. It is a KYC check that gates access to the account, an address that determines whether a payment clears, or an amount that cannot be wrong. The structure below exists to keep an abandoned form recoverable and a mistyped field correctable.
Covers
- Grouped inputs
- Fieldsets
- Hints
- Validation
- Submission feedback
Input grouping
- Group related inputs in a fieldset with a descriptive legend
- Order inputs in a logical sequence (name → email → message)
- One question per page for complex flows, or grouped on a single page for short forms
One page or many
| Form | Shape | Why |
|---|---|---|
| Sign in | One page | Two fields; a step would be absurd |
| Send money | Steps | Each step is a decision that affects the next |
| Add contact | Steps | Method details depend on the method chosen |
| KYC | Steps | Each step can fail independently and be retried alone |
| Settings | One page per group | Changes apply immediately; no submit |
The rule is not length, it is dependency. Fields that depend on an earlier answer belong on a later step; fields that are independent can share a page. Splitting independent fields across steps adds taps without adding clarity.
Field order follows the source document
Where a user is transcribing from something — a bank card, an ID, a payment slip — the field order matches the document, not the database schema. A card form is number, expiry, CVV because that is the order printed on the card.
Fieldsets
- Use fieldset + legend for radio groups, checkbox groups, and related input sets
- Legend text describes the group, not individual options
- Nested fieldsets are allowed but should be used sparingly
A <legend> is the only thing that gives a radio group an accessible name. Without it a screen reader announces four unlabelled radio buttons, and the question they answer is never spoken.
Hints
- Place hint text between the label and the input
- Hint text describes format, constraints, or examples
- Keep hints concise — one sentence or fragment
- Use aria-describedby to associate hints with inputs
Hints go before the field, not after
A format hint below the input arrives after the user has already typed. "10 digits, no spaces" under an account number is read once the mistake is made. Above the field, it is read in time to prevent it.
Errors are the exception — they appear below, because they refer to what is now in the field.
| Do | Don't |
|---|---|
| "10 digits, no spaces" above the account field | "Invalid format" below it, after submission |
| "As printed on your ID" | "Please enter a valid name" |
| "We only use this to send your receipt" | "Email address" as the hint for the Email field |
Submission
- Primary submit button at the end of the form
- Disable submit button while processing (with loading indicator)
- On success: confirm with a notice or redirect
- On failure: show error summary at top, inline errors at fields, move focus to summary
The submit button is never disabled to block validation
A disabled submit button gives the user nothing to act on: no message, no focus target, and no explanation of what is missing. Senco's submit stays enabled, and pressing it with invalid fields produces an error summary the user can read and navigate.
The button is aria-disabled only while a submission is genuinely in flight, and it keeps its label with an inline spinner rather than becoming a bare loading control.
Offline and interrupted submission
Senco's users lose connectivity mid-form routinely. Three rules follow:
- Form state is persisted locally as the user types. A dropped connection, a backgrounded app, or a phone call does not empty the form.
- A submission that cannot reach the server queues rather than failing outright, and the UI says so: "Saved — we'll send this when you're back online."
- Double submission is prevented by the caller. The press handler sets the submitting control's
loadingbefore the request starts, and the control ignores presses untilloadingclears. A user who taps twice on a slow connection must not send twice.
Recovering an abandoned form
Returning to an incomplete flow restores the data and the step, and says what happened: "You have an unfinished transfer to Maria Santos." Silently discarding entered data is the single fastest way to lose a user who was already on a bad connection.
Form card
A styled container for grouping form inputs within a flow. Uses data-slot="form-card" for CSS targeting and dedicated tokens for consistent card appearance.
Token system
| Token | Value | Purpose |
|---|---|---|
--ds-formcard-bg | var(--ds-white) | Card background |
--ds-formcard-border | 1px solid var(--ds-border) | Card border |
--ds-formcard-radius | var(--ds-radius-xl) | Corner radius |
--ds-formcard-shadow | var(--ds-shadow-xs) | Elevation shadow |
--ds-formcard-padding | var(--ds-space-4) | Internal padding |
CSS targeting
Applied via attribute selector in both DS layout and prototype scopes:
:is(.ds-layout, .senco-prototype-scope) [data-slot="form-card"] {
background: var(--ds-formcard-bg);
border: var(--ds-formcard-border);
border-radius: var(--ds-formcard-radius);
box-shadow: var(--ds-formcard-shadow);
padding: var(--ds-formcard-padding);
}
When to use
- Multi-step flows (Add Contact, KYC, Send) where each step's inputs need visual grouping
- Settings pages with distinct sections
- Any form section that benefits from a card container
When NOT to use
- Signin/login forms — keep flat to avoid double-boxing (form is the only content)
- Single-input pages where the card adds unnecessary visual weight
- Inside another card (no nested cards)
Spacing inside a card
| Relationship | Token |
|---|---|
| Card padding | --ds-formcard-padding (16px) |
| Field to field | --ds-space-4 (16px) |
| Label to helper | --ds-space-2 (8px) |
| Helper to input | --ds-space-2 (8px) |
| Input to error | --ds-space-2 (8px) |
| Section heading to first field | --ds-space-5 (24px) |
These come straight from Spacing. A form that invents its own gaps is the most visible way a product looks assembled by different people.
Contact address
Structured address form pattern used in Add Contact and billing address flows. Includes a billing address toggle for payment methods that require a separate billing address.
Address fields
| Field | Validation | Min length |
|---|---|---|
| Street | Alphanumeric + -#.,/ allowed | 5 characters |
| City | Letters and spaces | 2 characters |
| State/Province | Letters and spaces | 2 characters |
| Postal code | Alphanumeric + space/hyphen | 3 characters |
ContactAddress type
interface ContactAddress {
street: string;
city: string;
state: string;
postal: string;
}
Billing address toggle
The useBillingAddressToggle hook manages an optional separate billing address per payment method:
- Default: "Same as general address" (checkbox checked)
- When unchecked: reveals a separate billing address form
- Validation runs on the effective address (general or billing)
- Returns
effectiveAddress— the address that should be submitted
Usage
Used in Add Contact step 3 (Method Details), where each payment method can optionally have its own billing address distinct from the contact's general address.
International addresses
The four-field shape is a simplification, and it is the known weak point of this pattern. "State/Province" does not exist in many of the markets Senco serves, postal codes are optional or absent in several, and address order differs by country.
Current position: the labels are generic, State/Province is not required for countries that do not use it, and validation is length-based rather than format-based so a valid local address is never rejected for not looking American. A per-country address schema is open work, recorded in the gaps below.
Accessibility
- Checkbox toggle has explicit label: "Billing address same as general"
- Address fieldset wrapped in
<fieldset>with<legend> - Each field has visible label and validation error via
aria-describedby autocompletetokens are set on every field (address-line1,address-level2,address-level1,postal-code) so autofill completes the whole block in one action- Revealing the billing form moves focus to its first field and announces the change
Accessibility
- All form controls have visible labels
- Required fields are indicated in the label
- Error messages reference the field name
- Tab order follows visual order
- Form submission works with keyboard only
Rules that apply to every form
- Labels are visible and above the field. A placeholder is not a label.
- Validation runs on blur and on submit, never on keystroke.
- A failed submission moves focus to the error summary.
- The user's input survives every error.
- Autofill is enabled and
autocompletetokens are correct. - A form that can be interrupted persists its state.
Do and don't
| Do | Don't |
|---|---|
| Keep submit enabled and explain what is missing | Disable submit and leave the user guessing |
| Persist form state as the user types | Lose four minutes of typing to a dropped connection |
| Put format hints above the field | Put format hints below, after the mistake |
| Split steps by dependency | Split steps to make the form look shorter |
Set autocomplete on address fields | Disable autofill and make users type their address again |
| Mark the few optional fields | Asterisk every required field with no legend |
Known gaps
- The address pattern assumes a four-part Western address; no per-country schema exists.
- Local persistence and offline queueing are specified here but not implemented as a shared hook.
- No pattern is documented for async validation, such as verifying an account number with the receiving bank.
- Multi-step progress persistence is described in Transfer flow but not generalized to all stepped forms.