On this page
Subcomponents
Subcomponents are low-level primitives for building custom form fields and inputs with consistent Nebula styling.
- Overview
- Rules and guidance
- Properties
- Styles
- Accessibility
When to use
- When building a custom form field that has no Nebula field equivalent
- When you need direct access to an input element for integration with a third-party form library that requires a ref
- When a higher-level Nebula field component (such as TextField or SelectField) does not support the specific layout or behaviour your context requires
When not to use
- When a full field component exists - if TextField, SelectField, TextareaField, CheckboxField, RadioField, DateField, DatepickerField, CurrencyField, or ComboboxField fits your use case, use it. The field components handle label association, hint text, error text, required/optional marking, and aria attributes automatically.
- To assemble a field without using Field - if you are composing an input with a label, use the Field wrapper rather than assembling label and error elements manually. Manual assembly is likely to miss aria associations.
Anatomy
- Field wrapper (handles label, optional/required marking, hint, error text, and aria attribute injection)
- Input (the focusable form control rendered inside the field wrapper)
Basic usage
Use Field to wrap any *Input component. Field injects id, name, aria-invalid, aria-required, and aria-describedby onto its direct child automatically.
Edit live code
<Field id="name" label="Name" hint="Used for contacting you" error="Enter a name" characters={10} > <TextInput /> </Field>
Variants and features
Field
Field is a layout and accessibility wrapper for form inputs. It renders a label, optional hint text, and error text, and injects the appropriate aria attributes onto its child input.
Props:
- label: the visible label text (required)
- id: used to associate the label with the input (required)
- hint: descriptive text shown below the label
- error: validation message shown below the input; overrides any error supplied by a parent Form
- optional: when true, appends "(optional)" to the label (or suppresses the required asterisk, depending on theme configuration)
- fullWidth: controls how the input width responds to the viewport: 'small' (full width on small screens, character-width on larger), 'always', or 'never'
- characters: approximate visible character count used to calculate the input width on larger viewports (default: 25)
Edit live code
<Stack spaceBetween={4}> <Field id="field-hint" label="Account name" hint="As it appears on your bill"> <TextInput /> </Field> <Field id="field-error" label="Account number" error="Enter an account number"> <NumberInput /> </Field> <Field id="field-optional" label="Reference" optional characters={12}> <TextInput /> </Field> </Stack>
TextInput
A standard single-line text input. Accepts iconLeft and iconRight props to place icons inside the input.
Edit live code
<Field id="search" label="Search"> <TextInput iconLeft="search" /> </Field>
NumberInput
A text input configured for numeric entry. Sets inputMode="numeric", pattern="[0-9]*", and disables autocapitalise, autocorrect, and spellcheck. Use for whole numbers. For decimal amounts, use CurrencyInput or set inputMode="decimal" via props.
Edit live code
<Field id="meter-read" label="Meter reading"> <NumberInput /> </Field>
TextareaInput
A multi-line text input. Defaults to 3 rows with vertical resizing disabled. Pass resizable to allow the user to resize.
Edit live code
<Field id="notes" label="Notes"> <TextareaInput rows={5} resizable /> </Field>
SelectInput
A styled native <select> element with a custom chevron icon. Accepts an options array of { value, label } objects, or standard <option> children.
Edit live code
<Field id="tariff" label="Tariff"> <SelectInput options={[{ value: 'fixed', label: 'Fixed' }, { value: 'variable', label: 'Variable' }]} /> </Field>
CurrencyInput
A number input with a currency icon on the left and a visually hidden currency label for screen readers. Defaults to GBP. Accepts currency as 'GBP', 'EUR', 'AUD', or 'USD'.
For most currency entry, prefer the CurrencyField component, which wraps Field and CurrencyInput together.
Edit live code
<Field id="amount" label="Amount"> <CurrencyInput currency="GBP" /> </Field>
EmailInput, TelInput, PasswordInput
Variants of the base input pre-configured for their respective input types:
- EmailInput: sets type="email" and disables autocapitalise/autocorrect
- TelInput: sets type="tel"
- PasswordInput: sets type="password" and disables autocapitalise, autocorrect, and spellcheck
Edit live code
<Stack spaceBetween={4}> <Field id="sub-email" label="Email address"> <EmailInput /> </Field> <Field id="sub-tel" label="Mobile number"> <TelInput /> </Field> <Field id="sub-password" label="Password" hint="At least 12 characters"> <PasswordInput /> </Field> </Stack>
usage
- Subcomponents should not be used when a full Nebula field component (TextField, SelectField, TextareaField, etc.) fits the use caseFull field components handle label association, aria attributes, error text, and required/optional marking automatically. Using raw subcomponents requires re-implementing all of this correctly.Copy rule ID for subcomponents.prefer-field-component
- Field fullWidth should be set to 'always' only when the input genuinely benefits from the full available widthFields default to character-width sizing on larger viewports, which gives users a cue about the expected length of their input. Full-width fields remove this cue.Copy rule ID for subcomponents.field-full-width
- TextInput iconLeft and iconRight must not be used purely for decoration without communicating meaningIcons inside inputs add visual weight and affect padding. Use them only when the icon communicates something about the input's function or expected content.Copy rule ID for subcomponents.icon-left-icon-right
content
- Field must receive a label prop with a visible, descriptive text labelCopy rule ID for subcomponents.field-label-required
- CurrencyInput must not have the currency prop omitted when the currency is not GBPCurrencyInput uses the currency prop to render a visually hidden label for screen readers. The default is GBP; using a non-GBP currency without setting the prop will announce the wrong currency to screen reader users.Copy rule ID for subcomponents.currency-input-currency-label
interaction
- Field will inject id, aria-invalid, aria-required, and aria-describedby onto its direct child input via cloneElement. The consumer does not need to set these props manually when using FieldCopy rule ID for subcomponents.field-injects-aria-props
- NumberInput must not be replaced with a native input[type="number"]NumberInput uses type="text" with inputMode="numeric" and pattern="[0-9]*" to avoid the accessibility and UX issues associated with type="number" (scroll wheel changes, inconsistent browser behaviour, screen reader announcements).Copy rule ID for subcomponents.number-input-not-type-number
- PasswordInput must not have its type prop overriddenPasswordInput hard-codes type="password". Overriding this via rest props may not work as expected and defeats the component's purpose.Copy rule ID for subcomponents.password-input-no-override-type
validation
- Field error should be passed via the error prop on Field rather than rendered as a separate elementField renders ErrorText with the correct id and aria-describedby association. A manually rendered error element will not be associated with the input.Copy rule ID for subcomponents.error-via-field
composition
- Input subcomponents must be wrapped in Field when rendered in a formField injects id, aria-invalid, aria-required, and aria-describedby onto its child. Without Field, these associations must be set manually and are likely to be missed.Exception: the input is part of a composite control with its own explicit aria labellingCopy rule ID for subcomponents.field-wrapper-required
- Field must receive an id prop that uniquely identifies the input on the pageField uses the id to create the htmlFor association between the label and the input, and to derive the error text id.Copy rule ID for subcomponents.field-id-required
- Field must have exactly one direct child that is a valid React element accepting aria and id propsField uses cloneElement on its direct child to inject accessibility props. Multiple children or non-element children will not receive these props.Copy rule ID for subcomponents.field-child-direct
- SelectInput must receive either an options prop or option children, but not bothIf both are provided, both will render, producing duplicate options.Copy rule ID for subcomponents.select-options-prop-or-children
Field
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
label | string | — | Yes | Visible label for the field. It is associated with the input, so it forms the input's accessible name. |
name | string | — | No | The `name` attribute passed to the input. Also used to look up the field's error from a surrounding `Form`. |
optional | boolean | false | No | Marks the field as not required. This appends "(optional)" to the label (or, when the theme marks required fields, hides the required asterisk) and sets `aria-required` on the input only when the field is not optional. |
hint | ReactNode | — | No | Helper text shown below the label. It is part of the label element, so it is read as part of the input's accessible name. |
error | ReactNode | — | No | Error message shown below the input. When set, the input gets `aria-invalid` and an error border, and the message is linked to it with `aria-describedby`. Takes precedence over an error provided by a surrounding `Form`. |
fullWidth | always never small | 'small' | No | Controls when the field stretches to the full width of its container. `always` is always full width, `never` always uses the width set by `characters`, and `small` is full width on small screens only. |
characters | number | 25 | No | Width of the input, as an approximate number of characters (the width is set to `0.8em` per character, rounded up). Does not limit how much can be typed. |
affixWidth | number | 0 | No | Extra width in pixels added to the width calculated from `characters`, to make room for icons or buttons inside the input. |
id | string | — | Yes | Unique id for the input, used to link the label, hint and error to it. Also used as the base for related ids such as `{id}-error`. |
characterLimit | number | — | No | Maximum number of characters, shown as a live "count/limit" counter below the input. The limit is advisory: it is not enforced on the input, but the counter turns to the error colour and screen readers are told how many characters remain or are over. |
children | string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | ReactFragment | ReactPortal | — | No | — |
Input
Also accepts all props from HTMLAttributes, InputHTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
iconLeft | IconName | (string & {}) | — | No | Name of an icon to show inside the left of the input. The input padding increases to make room for it. |
iconRight | IconName | (string & {}) | — | No | Name of an icon to show inside the right of the input. The input padding increases to make room for it. |
TextInput
Also accepts all props from HTMLAttributes, InputHTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
iconLeft | IconName | (string & {}) | — | No | Name of an icon to show inside the left of the input. The input padding increases to make room for it. |
iconRight | IconName | (string & {}) | — | No | Name of an icon to show inside the right of the input. The input padding increases to make room for it. |
CurrencyInput
Also accepts all props from HTMLAttributes, InputHTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
currency | GBP EUR AUD USD | 'GBP' | No | Currency whose symbol is shown in the input. `GBP` shows a pound sign, `EUR` a euro sign, and `AUD` and `USD` both a dollar sign. The currency name is also announced to screen readers. |
iconAlignment | left right | 'left' | No | Which side of the input the currency symbol appears on. |
SelectInput
Also accepts all props from HTMLAttributes, SelectHTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
options | SelectOption[] | — | No | Options to render in the select, each with a `value` and a display `label`. They are rendered before any `children`. |
onValueChange | (newValue: string | undefined) => void | — | No | Called with the newly selected option's value when the selection changes. Runs in addition to any `onChange` handler. |
TextareaInput
Also accepts all props from HTMLAttributes, TextareaHTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
resizable | boolean | false | No | Allows the user to resize the textarea vertically by dragging its corner. When false, resizing is disabled. |
Styles
count
Defined in Field/CharacterCount.tsx
font-family: ${fonts.body};
font-weight: ${fontWeights.normal};
line-height: ${lineHeights.small};
font-size: ${responsiveFontSizes.small[0]};
color: ${$exceeded ? colors.error : colors.body};
white-space: nowrap;
flex-shrink: 0;
margin-left: auto;
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.small[1]};
}
under-input-row
Defined in Field/Field.tsx
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
gap: ${space[1]};
margin-top: ${space[1]};
width: 100%;
asterisk
Defined in Field/RequiredFieldIndicator.tsx
color: ${colors.error};
font-family: ${fonts.body};
font-weight: ${fontWeights.normal};
font-size: ${responsiveFontSizes.body[0]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
}
input
Defined in Input/Input/Input.tsx
font-family: ${fonts.body};
line-height: ${lineHeights.body};
font-weight: ${fontWeights.normal};
height: ${space[9]};
color: ${colors.body};
border: ${borderWidths.standard} solid ${colors.border};
background: ${colors.canvas};
border-radius: ${radii.standard};
padding: 0.6rem ${space[2]};
font-size: ${responsiveFontSizes.body[0]};
width: 100%;
scroll-margin-top: 8rem;
&[iconLeft] {
padding-left: ${space[9]};
}
&[iconRight] {
padding-right: ${space[9]};
}
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
}
&:focus-visible {
${focusRing}
}
&::placeholder {
color: ${colors.body};
opacity: 0.5;
}
&[aria-invalid] {
border-width: ${borderWidths.heavy};
border-color: ${colors.error};
}
icon
Defined in Input/Input/Input.tsx
position: absolute;
width: ${space[4]};
top: ${(space[9] - space[4]) / 2};
@when side === 'left' {
left: ${(space[9] - space[4]) / 2};
}
@when side === 'right' {
right: ${(space[9] - space[4]) / 2};
}
path {
fill: ${colors.body};
}
&[data-error='true'] {
path {
fill: ${colors.error};
}
}
styled-icon
Defined in Input/SelectInput/SelectInput.tsx
position: absolute;
top: 1rem;
right: 1rem;
width: ${space[4]};
path {
fill: ${colors.body};
}
&[data-error='true'] {
path {
fill: ${colors.error};
}
}
textarea
Defined in Input/TextareaInput/TextareaInput.tsx
min-height: ${space[12]};
height: auto;
max-width: 100%;
display: block;
resize: ${resizable ? 'vertical' : 'none'};
In brief
Field and the inputs are the building blocks of Nebula's form fields. The inputs are native input, select and textarea elements, and Field ties the label, hint and error to the one it wraps.
What Nebula provides
- Field renders a label linked to the input by id, with the hint inside it, so the hint is read as part of the name.
- With an error, Field sets aria-invalid on the input and links the message to it with aria-describedby.
- Field sets aria-required unless the field is optional, and marks it with "(optional)" in the label or a required asterisk hidden from screen readers.
- With characterLimit, the visual counter is hidden from screen readers and a polite live region announces how many characters remain.
Keyboard behaviour
Each input is a native form control and one tab stop, and takes the keys of the native element.
Interactions
- Tab - Moves focus into the input, and out of it again
Accessible design
Don't use a placeholder in place of a label
A placeholder disappears when the user types, and Nebula draws it at half opacity. Field always asks for a visible label.
WCAG: 3.3.2 Labels or Instructions
Keep the hint short
The hint is part of the label, so a screen reader reads it in full every time the input takes focus.
WCAG: 3.3.2 Labels or Instructions
Write an error that says what to do
Name the field's problem and the way to fix it, for example "Enter a name" and not "Invalid".
WCAG: 3.3.1 Error Identification 3.3.3 Error Suggestion
Name the currency in the label of a currency input
CurrencyInput gives the currency only as a hidden description, which a screen reader can skip or read late. Write it into the label, for example "Monthly payment in pounds".
WCAG: 3.3.2 Labels or Instructions
Accessible development
Always pass a unique id to Field
The id links the label, the hint and the error to the input. A repeated id points them at the wrong one.
WCAG: 1.3.1 Info and Relationships
If you use an input without Field, give it a name
A bare input has no label of its own. Pass a visible label through Field, or aria-label or aria-labelledby when the design has no visible label.
WCAG: 4.1.2 Name, Role, Value
Don't set aria-describedby, aria-invalid or id on an input inside Field
Field sets them on its child and replaces any value passed, so the description or the invalid state you set is lost. Pass the extra text through hint or error.
WCAG: 1.3.1 Info and Relationships
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 these tests and statuses mean
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.
The tests
- 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.
The statuses
- 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.