Skip to main content
ENKO

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

FormShapeWhy
Sign inOne pageTwo fields; a step would be absurd
Send moneyStepsEach step is a decision that affects the next
Add contactStepsMethod details depend on the method chosen
KYCStepsEach step can fail independently and be retried alone
SettingsOne page per groupChanges 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.

DoDon'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 loading before the request starts, and the control ignores presses until loading clears. 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

TokenValuePurpose
--ds-formcard-bgvar(--ds-white)Card background
--ds-formcard-border1px solid var(--ds-border)Card border
--ds-formcard-radiusvar(--ds-radius-xl)Corner radius
--ds-formcard-shadowvar(--ds-shadow-xs)Elevation shadow
--ds-formcard-paddingvar(--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

RelationshipToken
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

FieldValidationMin length
StreetAlphanumeric + -#.,/ allowed5 characters
CityLetters and spaces2 characters
State/ProvinceLetters and spaces2 characters
Postal codeAlphanumeric + space/hyphen3 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
  • autocomplete tokens 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 autocomplete tokens are correct.
  • A form that can be interrupted persists its state.

Do and don't

DoDon't
Keep submit enabled and explain what is missingDisable submit and leave the user guessing
Persist form state as the user typesLose four minutes of typing to a dropped connection
Put format hints above the fieldPut format hints below, after the mistake
Split steps by dependencySplit steps to make the form look shorter
Set autocomplete on address fieldsDisable autofill and make users type their address again
Mark the few optional fieldsAsterisk 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.
Form pattern — grouped inputs with validation
Contact details
Enter a valid email address
Include country code, e.g. +82
Success state