# Form
Form wraps a set of inputs and actions, provides error context to fields, and manages error display and focus.
Section: components
Docs: https://nebula.kaluza.com/components/form
---
## When to use
- When collecting multiple pieces of information from a user in a single submission flow
- When you need form-level error handling: an error summary listing field-level validation errors, or a general error for non-field failures such as an API timeout
- When fields need to participate in automatic error context so each field can read its own error state by name
## When not to use
- **For a single standalone input with no submission flow** - a [Text Field](/components/text-field) or other field component works without a Form wrapper.
- **For read-only data display** - use [Summary List](/components/summary-list) instead.
- **For a list of read-only key-value pairs** - use [Summary List](/components/summary-list) instead.
## Anatomy
1. **Required fields notice** ("Fields marked * are required", shown when the `markRequiredFormFields` theme option is enabled and `hasMandatoryFields` is true)
2. **Error summary** (lists all field-level validation errors with links to each field; always rendered, visible only when `errors` is non-empty)
3. **General error** (full-width error notification for non-field errors such as API failures; always rendered, visible only when `generalError` is set)
4. **Form fields** (the `FormFields` container, which spaces direct children vertically)
5. **Form actions** (the `FormActions` container, which places buttons in a horizontal row)
## Basic usage
```tsx
```
## Variants and features
### Error summary
Pass an array of `{ id, message }` objects to the `errors` prop. The `ErrorSummaryNotification` is always present in the DOM. When `errors` is non-empty it becomes visible and keyboard focus moves to it automatically.
Each `id` in the errors array must match the `id` of the corresponding field so that the error summary links correctly to the field. Nebula field components generate their own id, so pass the same `name` value to both the field and the error entry.
```tsx
const Example = () => {
const [errors, setErrors] = useState([]);
const onSubmit = (event) => {
event.preventDefault();
setErrors([
{ id: 'summary-first-name', message: 'Enter your first name' },
{ id: 'summary-last-name', message: 'Enter your last name' },
]);
};
return (
);
}
```
### General error
Use `generalError` for errors that are not tied to a specific field, such as an API timeout or a server error. Pass either `title`, `message`, or both. When `generalError` is set, a full-width error `Notification` appears below the error summary and focus moves to it.
```tsx
const Example = () => {
const [generalError, setGeneralError] = useState(undefined);
const onSubmit = (event) => {
event.preventDefault();
setGeneralError({
title: 'We could not save your details',
message: 'The service did not respond. Try again in a few minutes.',
});
};
return (
);
}
```
### Required fields notice
When the `markRequiredFormFields` theme option is set to `true`, and the form contains at least one required field, the text "Fields marked * are required" appears at the top of the form. Set `hasMandatoryFields={false}` on forms where no fields are required to suppress this notice.
```tsx
const customTheme = createTheme({
config: {
markRequiredFormFields: true,
},
});
```
### FormFields
`FormFields` wraps form inputs and applies consistent vertical spacing between them using the `betweenFormFields` spacing token. Use it as the direct parent of all field components inside a form.
Direct children are spaced using the token, including non-field elements:
```tsx
Example showing that a paragraph within a div receives the correct spacing.
```
A `P` placed directly inside `FormFields` brings its own margins, which conflict with the token spacing. Wrap it in a `div` instead, as above:
```tsx
Example showing that a paragraph placed directly conflicts with intended spacing.
```
### FormActions
`FormActions` renders a row of buttons at the bottom of the form, using the `betweenButtons` spacing token. It applies a top margin of `space[6]` to separate it from the fields above.
The optional `message` prop renders a supplementary message above the button row, prefixed with an info icon. Use this for clarifying information that applies to the whole submission, such as a privacy notice or a note about processing time. Do not use it for validation errors.
```tsx
Once accepted, it can take up to 30 seconds to update this account>}>
Submit
Clear
```
### Custom fields and useFormFieldError
Nebula's own field components read their error from the form context using the `useFormFieldError` hook. Pass the field's `name` to the hook and it returns the matching error message string, or `undefined` if there is no error.
Note: `useFormFieldError` is not exported from the Nebula public API. It is documented here to explain how the field components pick up errors from a surrounding `Form`, not as an API for application code. In application code, pass the `errors` array to `Form` and let the Nebula field components resolve their own errors by `name`, as shown in [Error summary](#error-summary).
```tsx
// Internal to Nebula - not importable from '@kaluza-platform/nebula'.
const errorMessage = useFormFieldError(name);
```
### Common form features
Form fields share common behaviour for validation, hint text, required/optional marking, and field width. See the [forms pattern guide](/patterns/forms) for guidance on these topics.
## Rules and guidance
### Usage
- If markRequiredFormFields theme option is true: **Form** MUST set hasMandatoryFields={false} when no fields in the form are required. [id: form.mandatory-fields-suppress-when-none-required; testability: manual]
- **Form** SHOULD be used whenever collecting multiple pieces of user input in a single submission flow. Rationale: Form provides error context, error summary, and general error handling that field components rely on. [id: form.use-for-multi-field-submission; testability: context]
- **Form** MAY be omitted when a page contains only a single standalone input with no submission flow. Rationale: Field components function independently; Form is not a required wrapper for a single field. [id: form.not-required-for-single-field; testability: context]
### Placement
- **FormActions** MUST be the last child of Form or FormFields. Rationale: Submit and navigation actions follow the fields they act on. Placing actions before fields creates an illogical tab order. [id: form.form-actions-last; testability: manual]
### Content
- If markRequiredFormFields theme option is true: **Required fields notice** MUST only appear when the form contains at least one required field. Rationale: Showing the notice on a form with no required fields misleads users. [id: form.mandatory-fields-notice-accurate; testability: manual]
- **generalError** MUST include at least one of title or message. Rationale: An empty object renders a visible but blank notification. [id: form.general-error-title-or-message; testability: auto]
- **FormActions message** MUST NOT be used to display validation errors or field-level guidance. Rationale: The message is rendered with an info icon and no error semantics. Validation errors belong in the errors prop or generalError. [id: form.form-actions-message-not-for-errors; testability: context]
- **FormActions message** SHOULD contain only supplementary information that applies to the whole submission, such as a privacy notice or processing time note. [id: form.form-actions-message-supplementary; testability: context]
### Interaction
- **Form** WILL set noValidate on the form element. Browser native validation is disabled automatically. [id: form.will-no-browser-validation; testability: auto]
- **ErrorSummaryNotification** WILL receive keyboard focus automatically when it becomes visible. Do not suppress or override this behaviour. Rationale: Meets WCAG 2.2 SC 3.3.1 and 4.1.3. [id: form.error-summary-focus; testability: manual]
- **General error Notification** WILL receive keyboard focus automatically when generalError is set. Do not suppress or override this behaviour. Rationale: Handled via a useEffect in Form. [id: form.general-error-focus; testability: manual]
- **Form onSubmit** SHOULD be the point at which validation runs and the errors prop is updated. Rationale: Validating on submit before updating errors ensures the error summary and field errors appear together, giving users a complete picture. [id: form.submit-triggers-validation; testability: context]
### Validation
- **errors array** MUST use id values that match the id attribute rendered on the corresponding field element. Rationale: The error summary generates anchor links to each field using the id. A mismatch breaks the link and fails WCAG 2.2 SC 3.3.1. [id: form.error-id-matches-field-id; testability: auto]
### Composition
- **ErrorSummaryNotification** MUST always be present in the DOM, not conditionally mounted. Rationale: Mounting the error summary only on error causes screen readers to miss focus movement. Always render it and let it show or hide based on the errors prop. [id: form.errors-always-rendered; testability: auto]
- **general error Notification** MUST always be present in the DOM, not conditionally mounted. Rationale: Conditional mounting disrupts screen reader focus. Pass generalError to show it and undefined to hide it. [id: form.general-error-always-rendered; testability: auto]
- **FormFields** SHOULD be the direct parent of all field components inside a Form. Rationale: FormFields applies consistent betweenFormFields spacing. Nesting fields outside FormFields produces inconsistent vertical rhythm. [id: form.form-fields-direct-parent; testability: manual]
- **useFormFieldError** SHOULD be used by custom field components to read their error from form context rather than passing error strings as props manually. Rationale: Keeps error state in a single place and ensures custom fields participate in the same error summary flow as Nebula field components. [id: form.custom-field-hook; testability: context]
## Accessibility
### In brief
Form is a native `form` with the browser's own validation turned off. It lists field errors in a summary at the top, shows an error not tied to a field, and moves focus to either when it appears.
### What Nebula provides
- A native `form` with `noValidate`, so the errors a user sees are Nebula's messages and not the browser's.
- An error summary of links, one per error, each going to the field whose `id` matches. It takes focus when errors arrive.
- An error that belongs to no field, from `generalError`, takes focus when it is set.
- Nebula fields inside the form show the error whose `id` matches their `name`. When the theme turns on `markRequiredFormFields`, the form shows "Fields marked \* are required" above the fields.
### Keyboard behaviour
The form is not a tab stop. When errors arrive, focus moves to the error summary, and when a `generalError` is set, it moves to that notification.
### Accessible design
#### Say which fields are required before the first field
Nebula shows "Fields marked \* are required" when the theme turns it on. Fields outside a `Form` do not get it, so write that exact sentence at the top yourself. The asterisk on each field is hidden from screen readers, which hear that the field is required instead.
WCAG: [3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html)
#### Write each error so it makes sense as a link on its own
The summary shows every message as link text, out of context, next to the others. Say what is wrong and how to fix it: "Enter a first name", not "Required".
WCAG: [3.3.1 Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html) [3.3.3 Error Suggestion](https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html) [2.4.4 Link Purpose (In Context)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html)
### Accessible development
#### Always give each error an `id` that matches its field's `id`
The summary link goes to `#id`, so a mismatch leaves a link that goes nowhere. The field's `name` has to match the same entry for the field to show its own error.
WCAG: [3.3.1 Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html)
#### Keep the `errors` array the same between renders unless the errors change
Each new array moves focus to the error summary again, so an array written inline in the JSX takes focus from the user on every render.
#### If a page has more than one form, give each an accessible name
A `form` is exposed as a landmark only when it has a name, and a page with several forms needs to tell them apart. `Form` passes `aria-label` and `aria-labelledby` through to the element.
WCAG: [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html)
### Accessibility testing status
Reviewed on 9 October 2026 against v17.0.1.
| Test | Status | Tested with |
| --- | --- | --- |
| Default state | Tested | axe-core via the Storybook test runner (CI) |
| Advanced states | Partially tested | axe-core via the Storybook test runner (CI) |
| Keyboard navigation | Partially tested | keyboard (reported by the owner) |
| Screen reader | Partially tested | VoiceOver with Chrome (reported by the owner) |
What each test covers:
- Default state: The component as it first appears, before anything is clicked or changed.
- Advanced states: Every other state and variant: error, open, selected, each size and style, and hovering.
- Keyboard navigation: Whether every function can be reached and used with the keyboard alone, with a visible focus ring.
- Screen reader: Whether a screen reader announces what is on screen correctly: the name, the role and any state.
What these statuses mean:
- Tested: Every check in this row passed in an automated run that repeats on every pull request.
- Manually tested: Every check in this row was run once, by a person or with a tool they started, and all of them passed. It is not re-run on every change.
- Partially tested: Some checks in this row were run once and passed; the rest are still to do.
- Issue found: A check in this row failed. The ticket is shown beside the status.
- Not tested: No check in this row has been run yet.
- Not applicable: This row does not apply to the component, for the reason given beside it.
The table records which checks were run on this component on its own. It is not a statement of WCAG conformance, and it does not cover how the component is used.
## Properties
### Form
Also accepts all props from `HTMLAttributes`.
- `hasMandatoryFields` (type `boolean`, optional, default `true`). Shows a "Fields marked * are required" message above the form. Only has an effect when the theme's `markRequiredFormFields` option is on.
- `errors` (type `ErrorSummaryList`, optional, default `[]`). Field errors. Shown in a summary at the top of the form, and passed to fields (matched by `name`) so they show their own error message.
- `generalError` (type `GeneralError`, optional). An error not tied to a single field, shown in an error notification at the top of the form and given focus when it appears. Provide a `title`, a `message`, or both.
### FormActions
Also accepts all props from `HTMLAttributes`.
- `message` (type `ReactNode`, optional). Text shown above the buttons, for example to explain what will happen on submit.
### FormFields
Also accepts all props from `HTMLAttributes`.
## Styles
Reference only; you do not need this to use the component. This is the CSS its styled elements use, with theme tokens shown by name. `${...}` is an interpolation (a theme token, a mixin or another value from the code). A `&[prop]` or `@when ` block holds CSS that applies only under that condition; these are notation, not valid CSS.
### mandatory-fields-heading
```css
font-weight: ${fontWeights.normal};
font-family: ${fonts.body};
color: ${colors.body};
margin-bottom: ${space[6]};
font-size: ${responsiveFontSizes.body[0]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
}
span {
color: ${colors.error};
}
```
### message-content
```css
font-family: ${fonts.body};
line-height: ${lineHeights.body};
font-weight: ${fontWeights.normal};
color: ${colors.body};
font-size: ${responsiveFontSizes.body[0]};
display: flex;
gap: ${space[1]};
> *:first-child {
flex-shrink: 0;
}
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
}
```