# Subcomponents
Subcomponents are low-level primitives for building custom form fields and inputs with consistent Nebula styling.
Section: components
Docs: https://nebula.kaluza.com/components/subcomponents
---
## 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](/components/text-field) or [SelectField](/components/select-field)) does not support the specific layout or behaviour your context requires
## When not to use
- **When a full field component exists** - if [TextField](/components/text-field), [SelectField](/components/select-field), [TextareaField](/components/textarea-field), [CheckboxField](/components/checkbox), [RadioField](/components/radio), [DateField](/components/date-field), [DatepickerField](/components/datepicker-field), [CurrencyField](/components/currency-field), or [ComboboxField](/components/combobox-field) 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
1. **Field wrapper** (handles label, optional/required marking, hint, error text, and aria attribute injection)
2. **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.
```tsx
```
## 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)
```tsx
```
### TextInput
A standard single-line text input. Accepts `iconLeft` and `iconRight` props to place icons inside the input.
```tsx
```
### 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](#currencyinput) or set `inputMode="decimal"` via props.
```tsx
```
### TextareaInput
A multi-line text input. Defaults to 3 rows with vertical resizing disabled. Pass `resizable` to allow the user to resize.
```tsx
```
### SelectInput
A styled native `` element with a custom chevron icon. Accepts an `options` array of `{ value, label }` objects, or standard `` children.
```tsx
```
### 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](/components/currency-field) component, which wraps `Field` and `CurrencyInput` together.
```tsx
```
### 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
```tsx
```
## Rules and guidance
### Usage
- **Subcomponents** SHOULD NOT be used when a full Nebula field component (TextField, SelectField, TextareaField, etc.) fits the use case. Rationale: Full field components handle label association, aria attributes, error text, and required/optional marking automatically. Using raw subcomponents requires re-implementing all of this correctly. [id: subcomponents.prefer-field-component; testability: manual]
- **Field fullWidth** SHOULD be set to 'always' only when the input genuinely benefits from the full available width. Rationale: Fields 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. [id: subcomponents.field-full-width; testability: manual]
- **TextInput iconLeft and iconRight** MUST NOT be used purely for decoration without communicating meaning. Rationale: Icons inside inputs add visual weight and affect padding. Use them only when the icon communicates something about the input's function or expected content. [id: subcomponents.icon-left-icon-right; testability: manual]
### Content
- **Field** MUST receive a label prop with a visible, descriptive text label. [id: subcomponents.field-label-required; testability: auto]
- **CurrencyInput** MUST NOT have the currency prop omitted when the currency is not GBP. Rationale: CurrencyInput 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. [id: subcomponents.currency-input-currency-label; testability: manual]
### 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 Field. [id: subcomponents.field-injects-aria-props; testability: auto]
- **NumberInput** MUST NOT be replaced with a native input[type="number"]. Rationale: 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). [id: subcomponents.number-input-not-type-number; testability: auto]
- **PasswordInput** MUST NOT have its type prop overridden. Rationale: PasswordInput hard-codes type="password". Overriding this via rest props may not work as expected and defeats the component's purpose. [id: subcomponents.password-input-no-override-type; testability: auto]
### Validation
- **Field error** SHOULD be passed via the error prop on Field rather than rendered as a separate element. Rationale: Field renders ErrorText with the correct id and aria-describedby association. A manually rendered error element will not be associated with the input. [id: subcomponents.error-via-field; testability: auto]
### Composition
- **Input subcomponents** MUST be wrapped in Field when rendered in a form. Rationale: Field 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 labelling. [id: subcomponents.field-wrapper-required; testability: auto]
- **Field** MUST receive an id prop that uniquely identifies the input on the page. Rationale: Field uses the id to create the htmlFor association between the label and the input, and to derive the error text id. [id: subcomponents.field-id-required; testability: auto]
- **Field** MUST have exactly one direct child that is a valid React element accepting aria and id props. Rationale: Field uses cloneElement on its direct child to inject accessibility props. Multiple children or non-element children will not receive these props. [id: subcomponents.field-child-direct; testability: auto]
- **SelectInput** MUST receive either an options prop or option children, but not both. Rationale: If both are provided, both will render, producing duplicate options. [id: subcomponents.select-options-prop-or-children; testability: auto]
## Accessibility
### 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](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html)
#### 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](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html)
#### 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](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)
#### 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](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html)
### 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](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html)
#### 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](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html)
#### 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](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
### Field
- `label` (type `string`, required). Visible label for the field. It is associated with the input, so it forms the input's accessible name.
- `name` (type `string`, optional). The `name` attribute passed to the input. Also used to look up the field's error from a surrounding `Form`.
- `optional` (type `boolean`, optional, default `false`). 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` (type `ReactNode`, optional). 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` (type `ReactNode`, optional). 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` (type `'always' | 'never' | 'small'`, optional, default `'small'`). 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` (type `number`, optional, default `25`). 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` (type `number`, optional, default `0`). Extra width in pixels added to the width calculated from `characters`, to make room for icons or buttons inside the input.
- `id` (type `string`, required). 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` (type `number`, optional). 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` (type `string | number | boolean | ReactElement> | ReactFragment | ReactPortal`, optional).
### Input
Also accepts all props from `HTMLAttributes`, `InputHTMLAttributes`.
- `iconLeft` (type `IconName | (string & {})`, optional). Name of an icon to show inside the left of the input. The input padding increases to make room for it.
- `iconRight` (type `IconName | (string & {})`, optional). 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`.
- `iconLeft` (type `IconName | (string & {})`, optional). Name of an icon to show inside the left of the input. The input padding increases to make room for it.
- `iconRight` (type `IconName | (string & {})`, optional). 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`.
- `currency` (type `'GBP' | 'EUR' | 'AUD' | 'USD'`, optional, default `'GBP'`). 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` (type `'left' | 'right'`, optional, default `'left'`). Which side of the input the currency symbol appears on.
### SelectInput
Also accepts all props from `HTMLAttributes`, `SelectHTMLAttributes`.
- `options` (type `SelectOption[]`, optional). Options to render in the select, each with a `value` and a display `label`. They are rendered before any `children`.
- `onValueChange` (type `(newValue: string | undefined) => void`, optional). 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`.
- `resizable` (type `boolean`, optional, default `false`). Allows the user to resize the textarea vertically by dragging its corner. When false, resizing is disabled.
## 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.
### count
```css
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
```css
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
gap: ${space[1]};
margin-top: ${space[1]};
width: 100%;
```
### under-input-error
```css
flex: 1 1 auto;
min-width: 0;
```
### asterisk
```css
color: ${colors.error};
font-family: ${fonts.body};
font-weight: ${fontWeights.normal};
font-size: ${responsiveFontSizes.body[0]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
}
```
### input
```css
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};
}
```
### wrapper
```css
position: relative;
```
### icon
```css
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};
}
}
```
### select-input-wrapper
```css
position: relative;
```
### styled-icon
```css
position: absolute;
top: 1rem;
right: 1rem;
width: ${space[4]};
path {
fill: ${colors.body};
}
&[data-error='true'] {
path {
fill: ${colors.error};
}
}
```
### select
```css
appearance: none;
padding-right: ${space[9]};
```
### textarea
```css
min-height: ${space[12]};
height: auto;
max-width: 100%;
display: block;
resize: ${resizable ? 'vertical' : 'none'};
```