Molecule · stable · v1.0.0 · owner: Site system lead · contract 19 of 20 points (open: figma)
Do and don't
Do: A required email
Rendered by form-field.tsOpen example: Form field · Do: A required email Use the full-page example to follow page links.
Do: An error with a fix
Rendered by form-field.tsOpen example: Form field · Do: An error with a fix Use the full-page example to follow page links.
Do: An optional company
Rendered by form-field.tsOpen example: Form field · Do: An optional company Use the full-page example to follow page links.
Do: Message hint and error together
Rendered by form-field.tsOpen example: Form field · Do: Message hint and error together Use the full-page example to follow page links.
Do: Japanese required email
Rendered by form-field.tsOpen example: Form field · Do: Japanese required email Use the full-page example to follow page links.
Do: Japanese message error and hint
Rendered by form-field.tsOpen example: Form field · Do: Japanese message error and hint Use the full-page example to follow page links.
Don't: Placeholder as label
Rendered by form-field.tsOpen example: Form field · Don't: Placeholder as label Use the full-page example to follow page links.
Don't: An error without a fix
Rendered by form-field.tsOpen example: Form field · Don't: An error without a fix Use the full-page example to follow page links.
Rules
| Rule | Why | Check |
|---|---|---|
| Every field has a visible label tied to its input. | Placeholders disappear and are not read reliably. | Automated: axe label rule (tests/site/contact-form.spec.ts) |
| Errors say how to fix them and are listed at the top on submit. | People fix what they can find. | Automated: tests/site/contact-form.spec.ts |
| Personal fields set autocomplete. | Paste and autofill must work (WCAG 3.3.7, 3.3.8). | Automated: tests/site/contact-form.spec.ts |
API
| Prop | Type | Required | Default | What it does |
|---|---|---|---|---|
id | string | Yes | The input's id (the label's for). | |
name | string | Yes | The submitted name. | |
label | string | Yes | The question, visible. | |
type | text | email | textarea | No | "text" | The input type. |
required | boolean | No | false | Required fields are the default; optional ones are marked. |
optional | string | No | The word 'optional' in the page's language. | |
hint | string | No | Help shown between label and input. | |
error | string | No | The error message, shown after a failed submit. | |
autocomplete | string | No | The autocomplete token, so browsers and password managers fill it. | |
maxlength | number | No | The limit the server enforces. |
Contract details
The full contract: identity, anatomy, variants, states, tokens, layout, motion, accessibility, content, tests
Identity
| Aspect | Value |
|---|---|
| Id | form-field |
| Layer | Molecules |
| Category | input |
| Owner | Site system lead |
| Status | stable |
| Version | 1.0.0 |
| Source | apps/showcase/site/system/molecules/form-field.ts (private repository; the contract and the render function are this one module) |
| Version | Date | Change |
|---|---|---|
| 1.0.0 | 2026-10-26 | Label, input, optional hint and error, for the contact form, the health check and the survey. |
Use when
- Any question in a form. Why: One field pattern makes every form on the site behave the same.
Do not use when
- For a single on-page control in a showcase. Why: That is a Control atom.
- Without a visible label. Why: Placeholders vanish while typing and are not labels (WCAG 3.3.2).
Anatomy
| Part | What it is |
|---|---|
| root | div.field |
| label | label (with span.optional when optional) |
| input | input or textarea |
| hint | p.small between label and input |
| error | p.field-error after the input, linked by aria-describedby |
Made of: text, callout, tokens:type, tokens:colour, tokens:radius.
Used in: contact-form, cost-calculator, health-check, read-your-stylesheet, stage-finder, survey.
Variants
| Variant | When | Why |
|---|---|---|
| text | Short answers. | One line. |
| Email. | The right keyboard on phones (inputmode=email). | |
| textarea | A message. | Room to write. |
States
| State | Rendered or not applicable |
|---|---|
| default | Label above an outlined input |
| hover | Not applicable: Inputs do not change on hover; the cursor already shows they take text. |
| focus-visible | A 2 px --focus outline. |
| active | Not applicable: Typing is the active state, shown by the focus outline. |
| disabled | Not applicable: Fields are never disabled; a field that cannot be used is not shown. |
| loading | Not applicable: Submission loading belongs to the containing form and submit Button; this field renderer has no busy lifecycle. |
| empty | Empty input with its label; no placeholder text. |
| error | Madder message under the input, aria-invalid=true and a linked description. The containing form owns the error summary. |
| current | Not applicable: Not a navigation element. |
| visited | Not applicable: Not a link. |
| expanded | Not applicable: Does not open. |
Tokens
| Part | Token |
|---|---|
| input.border | --rule-hair --line-strong |
| input.background | --surface |
| input.font | --type-body |
| label.font | --type-small-strong |
| error.color | --madder-ink |
| input.min-height | 48 px |
| --form-field-outline-rule-hair | var(--rule-hair) |
| --form-field-outline-focus | var(--focus) |
| --form-field-outline-offset-space-2 | var(--space-2) |
| --form-field-font-type-label | var(--type-label) |
| --form-field-letter-spacing-ls-label | var(--ls-label) |
| --form-field-gap-space-3 | var(--space-3) |
| --form-field-font-type-small-strong | var(--type-small-strong) |
| --form-field-margin-left-space-3 | var(--space-3) |
| --form-field-font-type-small | var(--type-small) |
| --form-field-color-ink-2 | var(--ink-2) |
| --form-field-gap-space-2 | var(--space-2) |
| --form-field-gap-space-5 | var(--space-5) |
| --form-field-margin-space-1 | var(--space-1) |
| --form-field-color-madder-ink | var(--madder-ink) |
| --form-field-padding-space-6 | var(--space-6) |
| --form-field-padding-space-7 | var(--space-7) |
| --form-field-background-surface | var(--surface) |
| --form-field-border-top-rule | var(--rule) |
| --form-field-border-top-ink | var(--ink) |
| --form-field-border-radius-radius | var(--radius) |
| --form-field-margin-bottom-space-2 | var(--space-2) |
| --form-field-font-type-strong | var(--type-strong) |
| --form-field-margin-bottom-space-3 | var(--space-3) |
| --form-field-gap-space-4 | var(--space-4) |
| --form-field-padding-top-space-4 | var(--space-4) |
| --form-field-padding-space-4 | var(--space-4) |
| --form-field-border-rule-hair | var(--rule-hair) |
| --form-field-border-line-strong | var(--line-strong) |
| --form-field-transition-dur-fast | var(--dur-fast) |
| --form-field-transition-ease-standard | var(--ease-standard) |
| --form-field-border-color-ink | var(--ink) |
| --form-field-background-ink | var(--ink) |
| --form-field-color-cotton | var(--cotton) |
| --form-field-box-shadow-space-2 | var(--space-2) |
| --form-field-box-shadow-saffron | var(--saffron) |
| --form-field-outline-offset-space-1 | var(--space-1) |
| --form-field-padding-space-5 | var(--space-5) |
| --form-field-margin-top-space-9 | var(--space-9) |
| --form-field-border-ink | var(--ink) |
| --form-field-color-ink | var(--ink) |
| --form-field-font-size-fs-small | var(--fs-small) |
| --form-field-margin-top-space-3 | var(--space-3) |
Layout
| Aspect | Value |
|---|---|
| max | --w-text |
| breakpoints | Required review at 390, 768, 1024 and 1440 px; execution is not implied by this declaration; full width of the form. |
| wrapping | Labels and errors wrap. |
| textExpansion | Japanese labels wrap; no fixed heights. |
Spacing and rhythm
| Aspect | Value |
|---|---|
| internal | --space-3 between label, hint, input and error. |
| external | --space-6 between fields. |
| rhythm | Inside a form panel. |
Motion
| Aspect | Value |
|---|---|
| None | Fields do not animate; errors appear in place on submit. |
Runtime and reuse
Render-only element; mount any registered child enhancements separately. Preserve required Site CSS, fonts, responsive and locale rules from nativeElement.
Accessibility passport
| Aspect | Value |
|---|---|
| role | textbox |
| name | The label (for/id) |
| keyboard | Tab to it, type; Enter submits from text fields. |
| focus | In form order; the error summary takes focus on a failed submit. |
| screenReader | Reads label, hint (aria-describedby), the required state and the error. |
| contrast | Ink on surface 14:1; madder-ink error 6.4:1. |
| target | 48 px tall. |
Content
| Slot | Length | Voice |
|---|---|---|
| label | 4 words | The question, plainly |
| error | 1 sentence | What to do: 'Enter your work email.' |
Good: “Your name” · “Enter your work email.”
Bad: “Name*” · “Invalid input”
Japanese: Strings come from the page's i18n files (contact/i18n/ja.json), reviewed per page.
Icons and illustrations
None: this element uses no icon or illustration of its own.
Decisions and exceptions
Usage
48 uses across the built pages (measured by npm run site:system).
Tests
| Aspect | Value |
|---|---|
| unit | tests/unit/site-system.test.ts: renders every do and don't example through the registry and checks the classes it owns |
| visual | Not yet: there are no screenshot baselines; the Library page is checked for sideways scroll at 1440 px by tests/site/system.spec.ts |
| a11y | tests/site/system.spec.ts: axe (0 serious or critical) on the element's Library page in light and dark |
| parity | scripts/site/system/check.ts: Library examples are rendered by the same render function the site imports |
Figma
not-yet: The Loom Site Figma library is the last stage of the plan; this element is not mapped yet.