` block holds CSS that applies only under that condition; these are notation, not valid CSS.
### tab
```css
font-size: ${responsiveFontSizes.body[0]};
font-family: ${fonts.display};
font-weight: ${active ? fontWeights.bold : fontWeights.normal};
line-height: ${lineHeights.body};
padding: ${space[3]} ${space[4]};
color: ${colors.heading};
flex-grow: 0;
text-align: center;
background-color: ${colors.canvas};
position: relative;
overflow: hidden;
cursor: pointer;
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
}
&:first-child {
border-top-left-radius: ${radii.standard};
}
&:before {
content: '';
position: absolute;
background-color: ${active ? colors.primary : 'transparent'};
height: 0.3rem;
bottom: 0;
left: 0;
right: 0;
}
&:focus-visible {
${focusRing}
position: relative;
z-index: 10;
}
&:hover {
background-color: ${active ? 'transparent' : colors.canvasMuted};
}
```
### tab-list
```css
display: inline-flex;
margin: 0;
```
### tab-panel
```css
padding: ${space[4]};
${mediaQueries.smallAndUp} {
padding: ${space[6]};
}
display: ${hidden ? 'none' : 'block'};
border-top-width: ${borderWidths.standard};
border-style: solid;
border-color: ${colors.borderMuted};
```
### tabs
```css
padding: 0;
```
---
# Text
Styled inline and block text elements for body content
Section: components
Docs: https://nebula.kaluza.com/components/text
---
## When to use
- For body paragraphs and running prose
- For supplementary text at a smaller size, such as captions or legal disclaimers, using `Small`
- For inline emphasis within a sentence using `Strong` (bold) or `Em` (italic)
- For strikethrough text when showing a removed or superseded value using `LineThrough`
- For superscript and subscript notation using `Sup` and `Sub`
## When not to use
- **For page or section headings** - use [Heading](/components/heading) (`Heading1` through `Heading4`) instead.
- **For list items** - use [List](/components/list) (`Ul`, `Ol`, `Li`) instead.
- **For term-definition pairs** - use [List](/components/list) (`Dl`, `Dt`, `Dd`) or [Summary List](/components/summary-list) instead.
## Anatomy
1. **Paragraph** (`P`, the primary block-level text element)
2. **Small text** (`Small`, reduced size, used inline or as a modifier on `P`)
3. **Inline emphasis** (`Strong`, `Em`, `LineThrough`, used inside other text elements)
4. **Superscript / Subscript** (`Sup`, `Sub`, used inline for notation)
## Basic usage
```tsx
<>
An example of body text.
An example of emphasis
An example of strong text .
An example with line through .
An example of small text.
An example with superscript1 and subscript2 .
>
```
## Variants and features
### Paragraph
`P` is the standard body text element. It renders a `p` tag with responsive font size and appropriate vertical margins. Use it for any block of prose content.
```tsx
<>
Your direct debit is collected on the 1st of each month.
If you want to change the amount, you can do this in your account settings.
>
```
### Small text
`Small` reduces the font size to the `small` scale from the design token system. It renders a `small` element and is typically used inline within a `P` or on its own for captions, legal text, or secondary information.
```tsx
Your estimated bill is £142. Based on average household usage.
```
### Bold and semi-bold
`Strong` renders a `strong` element with `fontWeights.bold`. Use it to emphasise a word or phrase that is genuinely important within a sentence.
`SemiStrong` renders a `strong` element with `fontWeights.semiBold`. Use it for moderate emphasis, such as term labels within a sentence, where full bold would be visually heavy.
Note: `SemiStrong` is not exported from the Nebula public API. Use `Strong` for emphasis in application code.
```tsx
Your next payment of £142.00 is due on 1 February 2025.
```
### Italic
`Em` renders an `em` element with italic styling. Use it for titles of works, technical terms introduced for the first time, or other cases where italic has semantic meaning. Do not use it for decorative effect.
```tsx
This tariff is known as Flex 12 .
```
### Strikethrough
`LineThrough` renders a `span` with `text-decoration: line-through`. Use it to show that a value has been removed or replaced, such as an original price alongside a discounted price.
```tsx
£180.00 £142.00 per year
```
### Superscript and subscript
`Sup` and `Sub` render `sup` and `sub` elements at the small font size. Use for footnote markers, mathematical notation, or chemical formulae.
```tsx
<>
Your tariff includes a standing charge1 .
Emissions are measured in CO2 e.
>
```
## Rules and guidance
### Usage
- **Body text** SHOULD use Nebula Text components (P, Small, Strong, Em, etc.) rather than raw HTML elements. Rationale: Nebula text components apply responsive font sizes, spacing, and color from the design token system. Raw elements bypass these. [id: text.no-raw-elements; testability: auto]
- **P** SHOULD be used for any block of prose content that is not a heading or list. [id: text.p-for-prose; testability: manual]
- **P** MUST NOT be used to visually style text to look like a heading. Rationale: Applying heading-like styling to a P element breaks document structure for screen reader users. Use a Heading component instead. [id: text.no-heading-from-text; testability: manual]
- **Small** SHOULD be used for supplementary text such as captions, legal disclaimers, or secondary labels. Rationale: Small text is harder to read at small viewport sizes. Reserve it for genuinely secondary content. [id: text.small-for-secondary; testability: manual]
- **LineThrough** SHOULD be accompanied by surrounding text or a visually hidden label that conveys the same meaning for screen reader users. Rationale: The CSS text-decoration line-through value is not announced by all screen readers. [id: text.linethrough-accessible-context; testability: manual]
- **P** SHOULD NOT have its color overridden outside the theme. Rationale: P color is set to colors.body. Overriding it may break contrast ratios against background tokens. [id: text.color-not-overridden; testability: manual]
### Content
- **Strong** SHOULD be used to emphasise genuinely important information, not for decorative styling. Rationale: Strong maps to the strong HTML element, which has semantic weight. Overuse dilutes its meaning. [id: text.strong-for-meaning; testability: manual]
- **Em** SHOULD be used when italic has semantic meaning (titles, technical terms, stress emphasis), not for decorative effect. Rationale: Em maps to the em HTML element, which has semantic meaning for screen readers. Decorative use of italic is misleading. [id: text.em-for-semantic-emphasis; testability: manual]
- **LineThrough** SHOULD be used only to indicate that a value has been removed or superseded, not for decoration. Rationale: Screen readers may not convey strikethrough styling. A visually hidden label may be needed to communicate the meaning to screen reader users. [id: text.linethrough-for-removed-value; testability: manual]
### Composition
- **Strong, Em, Small, LineThrough, Sup, Sub** SHOULD be used inside a block-level element such as P rather than as standalone block elements. Rationale: These are inline elements. Using them as standalone blocks may produce unexpected layout and is semantically incorrect. [id: text.inline-elements-inside-block; testability: auto]
## Accessibility
### In brief
Text is made of native text elements: `p`, `small`, `strong`, `em`, `sup` and `sub`. The words are what a screen reader reads, so the meaning has to be in them. `LineThrough` is a `span` with a line through it, which a screen reader does not announce.
### What Nebula provides
- `P`, `Small`, `Strong`, `SemiStrong`, `Em`, `Sup` and `Sub` render native elements (`SemiStrong` is a `strong` with a lighter weight).
- The font sizes are smaller below the `small` breakpoint.
- Text is never a tab stop.
### Accessible design
#### Put the meaning in the words, not in the style
Italics, a line through the text and a smaller size are only visual. A screen reader user does not hear a struck-through price as removed, so write "Was £10, now £8" and not a crossed-out £10 beside "£8".
WCAG: [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html)
#### Check the colour contrast at the mobile font size as well
The text is smaller below the `small` breakpoint, and the contrast ratio a text needs depends on its size.
WCAG: [1.4.3 Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
### Accessible development
#### Use `Strong` and `Em` only for importance and stress
A `strong` or `em` element says the text is important or stressed. For appearance alone, use a style on a plain element, and use `Sup` and `Sub` only for real superscript and subscript.
WCAG: [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html)
#### Use one `P` for each paragraph
Line breaks inside one paragraph, or a `div` of loose text, give a screen reader user no paragraphs to move between.
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 | Manually 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
### P
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
### Strong
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
### Em
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
### Small
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
### Sup
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
### Sub
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
### LineThrough
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
## 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.
### p
```css
font-family: ${fonts.body};
line-height: ${lineHeights.body};
font-weight: ${fontWeights.normal};
color: ${colors.body};
font-size: ${responsiveFontSizes.body[0]};
margin-top: ${space[1]};
margin-bottom: ${space[1]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
margin-top: ${space[2]};
margin-bottom: ${space[2]};
}
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
```
### small
```css
font-size: ${responsiveFontSizes.small[0]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.small[1]};
}
```
### strong
```css
font-weight: ${fontWeights.bold};
```
### semi-strong
```css
font-weight: ${fontWeights.semiBold};
```
### em
```css
font-style: italic;
```
### line-through
```css
text-decoration: line-through;
```
### sup
```css
vertical-align: super;
font-size: ${responsiveFontSizes.small[0]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.small[1]};
}
```
### sub
```css
vertical-align: sub;
font-size: ${responsiveFontSizes.small[0]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.small[1]};
}
```
---
# Text Field
A form field for accepting text inputs
Section: components
Docs: https://nebula.kaluza.com/components/text-field
---
## When to use
- When the user needs to input specific information that cannot be predicted from a predefined list, for instance their name
- When the input is memorable information that will be quicker to type by hand than interact with a more structured input
- When it is essential to avoid bias - predefined lists can force users to pick a choice they don't actually agree with
## When not to use
- **When only an item from a predefined list will be accepted** - this is likely to result in an error. Use [Radio](/components/radio-field), [Checkbox](/components/checkbox-field), or [Select Field](/components/select-field) instead.
- **For large text inputs** - users will not be able to see what they have entered easily. Use [TextArea Field](/components/textarea-field) instead.
- **To indicate that a field is unavailable** - the disabled state is not supported. Use [Summary List](/components/summary-list) to show values that cannot be edited, or conditionally hide the field if it is not relevant in the current context.
## Anatomy
1. **Label**
2. **Hint**
3. **Input**
4. **Left icon** - optional
5. **Right icon** - optional
6. **Error message**
7. **Required/Optional indicator**
## Basic usage
```tsx
```
## Variants and features
### Input types
TextField has four specialised variants for specific input types. These are separate components that share the TextField API.
- **[NumberField](/components/subcomponents)** - for numeric input
- **[EmailField](/components/subcomponents)** - for email addresses
- **[TelField](/components/subcomponents)** - for telephone numbers
- **[PasswordField](/components/subcomponents)** - for passwords. Characters are masked as the user types. Always provide hint text listing any requirements for the password format, such as the types of characters allowed.
```tsx
```
### Left and right icons
Use the `iconLeft` or `iconRight` props to display an icon inside the input.
```tsx
```
### Common form features
The following features are shared across all Nebula form fields. See [Form guidance](/patterns/forms) for full detail.
**Validation and errors** - when a field is invalid, an error message appears below the input and the input border changes to indicate the error state.
**Hint text** - use the `hint` prop to provide additional context to help the user complete the field correctly. Keep hint text brief and specific to the field.
**Required and optional** - fields are required by default. Use the `optional` prop to mark a field as optional. The required or optional state is indicated visually as part of the label.
**Field width** - use the `characters` prop to set the input width to match the expected length of the content. Making inputs the right size for what they collect helps the user understand what to enter. TextField is responsive by default and displays full width below the smallest breakpoint - override with `fullWidth="always"` or `fullWidth="never"`.
```tsx
```
## Grouping
Text fields are typically part of a larger form. See the [Form component](/components/form) and [form guidelines](/patterns/forms) for guidance on grouping and layout.
## Rules and guidance
### Usage
- **TextField** MUST NOT be passed the disabled prop. Rationale: The prop passes through to the native input with no Nebula styling and removes the field from the tab order, making it invisible to keyboard users with no accessible indication of why. [id: textfield.no-disabled; testability: auto]
- **TextField** SHOULD NOT be used for text inputs that would cover multiple lines. Rationale: Users may not be able to see their entire input. Use TextAreaField instead. [id: textfield.no-multiline; testability: manual]
- **TextField** SHOULD use a specialised variant when the input type is known. Rationale: NumberField, EmailField, TelField, and PasswordField each set the correct input type, keyboard, and validation behaviour for their respective data types. [id: textfield.use-specialised-variant; testability: manual]
- **TextField** SHOULD use the characters prop to set input width to match the expected length of the content. Rationale: Appropriately sized inputs help users understand what data is expected. [id: textfield.character-width; testability: manual]
- **TextField icon** SHOULD be used to add context to the input, not as decoration. [id: textfield.icon-not-decorative; testability: manual]
- **TextField** SHOULD be replaced with plain text. For example in a SummaryList. When displaying a value the user cannot edit. Rationale: A field that looks interactive but cannot be changed is confusing. Plain text communicates the value without implying editability. [id: textfield.non-editable-as-text; testability: manual]
### Content
- **PasswordField hint text** SHOULD list any requirements for the password format, such as character types allowed. Rationale: Setting clear expectations upfront helps users avoid errors when entering a password. [id: textfield.hint-format-requirements; testability: manual]
## Accessibility
### In brief
Text Field is a native text input with a visible label. A screen reader announces the label (hint included), then the field, and reads an error as its description. Number Field, Tel Field, Email Field and Password Field are the same field with another input type.
### What Nebula provides
- A real `label` linked to the input by `id`, so clicking the label focuses the input and gives a larger target.
- `aria-required` on the input unless the field is `optional`, which adds "(optional)" to the label. A required asterisk, when the theme shows one, is hidden from screen readers.
- An `error` sets `aria-invalid` on the input and links the message to it with `aria-describedby`. The message has an icon as well as colour.
- With `characterLimit`, the limit is the input's description and a live region announces the characters remaining once typing pauses.
### Keyboard behaviour
The input is a native `input`, one tab stop reached with `Tab`, and it shows a focus ring when focus comes from the keyboard.
#### Interactions
- **Tab** - Moves focus to the input
### Accessible design
#### Never use the placeholder as the label or the hint
A placeholder disappears as soon as the user types, and Nebula shows it at half opacity. Put the question in the label and any extra guidance in the hint.
WCAG: [3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html)
#### Keep the hint short
The hint sits inside the label element, so a screen reader reads it as part of the field's name. Put what the user needs first in the label and keep the hint to one short line.
WCAG: [3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html)
#### If several fields answer one question, group them under a shared label
Wrap them in a `fieldset` with a `legend`, as the [Forms pattern](/patterns/forms/) describes, so a screen reader user hears what the group is for. A single field needs no group.
WCAG: [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html)
#### Write the error so it says what to change
"Enter a name" tells the user what to do and "Invalid" does not. The message is read with the field, so it has to make sense on its own.
WCAG: [3.3.3 Error Suggestion](https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html)
### Accessible development
#### Always pass errors through `error`, or through a `Form` with the field `name`
This is what sets `aria-invalid` and links the message to the input. Text placed beside the field is not announced with it.
WCAG: [3.3.1 Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html)
#### If the field collects the user's own details, pass the matching `autoComplete` token
Use a token such as `email` or `tel`, so the browser and assistive technology can fill the field. A value passed here overrides the `off` default that the `disableAutocomplete` theme flag sets.
WCAG: [1.3.5 Identify Input Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.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 | Manually 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
### TextField
Also accepts all props from `HTMLAttributes`, `InputHTMLAttributes`.
- `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`.
- `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`.
- `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`.
- `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.
- `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.
- `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.
- `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.
- `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.
- `autoComplete` (type `string`, optional). Hint to the browser's autofill, for example `email`. When the `disableAutocomplete` theme config flag is enabled this defaults to `off`; a value passed here overrides that.
---
# Text Link
An anchor link for navigating to pages or anchor points within prose
Section: components
Docs: https://nebula.kaluza.com/components/text-link
---
## When to use
- For navigation links embedded within body text, such as references, further reading, or related pages
- For standalone navigation links where a full button-style target is not appropriate
- When linking to an anchor point within the same page
## When not to use
- **To trigger an action such as submitting a form or opening a modal** - links navigate; buttons act. Use [CTA Button](/components/cta-button) instead.
- **As a primary or secondary call to action** - use [CTA Link](/components/cta-link) instead, which has the visual prominence of a button while remaining an anchor element.
- **When the link label would be non-descriptive** - do not use "click here", "read more", or "find out more" as link text. Rewrite the surrounding sentence so the link label describes the destination.
## Anatomy
1. **Link text** (the visible, underlined label)
2. **External indicator** (an icon and visually hidden text appended when `opensInNewWindow` is true)
## Basic usage
```tsx
How to be a conscious consumer
```
## Variants and features
### Inline links
`TextLink` is designed to sit within prose. Place it inside a [Text](/components/text) (`P`) element so it inherits body font size and color context correctly.
The underline is always visible on `TextLink`. This is intentional: removing underlines from inline links reduces their discoverability for users with colour vision deficiencies.
```tsx
Visit Friends of the Earth for more information.
```
### Opening in a new window
Use `opensInNewWindow` when the link genuinely needs to open in a new browser tab, such as a link to an external site or a document that the user needs to reference while staying on the current page. Do not use it by default. Opening links in new windows removes the user's ability to navigate back and is unexpected behaviour for most links.
When `opensInNewWindow` is true, the component automatically appends a new-window icon and a visually hidden " (opens in new window)" text disclosure so screen reader users are informed before activating the link.
```tsx
Ofgem price cap information
```
### External links
For links to external domains, `TextLink` automatically adds `rel="noopener"` to prevent the linked page from accessing the opener's `window` object. This happens based on whether the `href` is detected as an external link. No extra configuration is needed.
### React Router integration
Pass the React Router `Link` component via the `as` prop to use client-side navigation while keeping the visual style of `TextLink`.
This example is not live, because the docs site does not use React Router.
```tsx
import { Link } from 'react-router-dom';
View account
```
## Rules and guidance
### Usage
- **TextLink** MUST have an href prop set to a valid URL or anchor reference. Rationale: TextLink renders an anchor element. An anchor without href is not keyboard-focusable and is not treated as a link by browsers or assistive technology. [id: textlink.href-required; testability: auto]
- **TextLink** MUST NOT be used to trigger in-page actions such as submitting a form, opening a modal, or toggling state. Rationale: Links navigate; buttons act. Using a link for an action breaks the user's mental model and can break keyboard and screen reader interaction. Use CTAButton instead. [id: textlink.navigation-not-action; testability: manual]
- **TextLink** MUST NOT have its underline removed. Rationale: Underline is the primary visual differentiator for inline links, especially for users who cannot distinguish by colour alone. Removing it fails WCAG 2.2 SC 1.4.1. [id: textlink.underline-always-visible; testability: auto]
- **opensInNewWindow** SHOULD NOT be set to true by default on all external links. Rationale: Opening links in new tabs removes the user's ability to navigate back and is unexpected unless there is a clear reason (e.g. the user needs to reference a document while completing a task). [id: textlink.opens-in-new-window-sparingly; testability: manual]
- **TextLink** SHOULD NOT be used as a primary or secondary call to action. Rationale: CTALink provides button-style prominence for call-to-action navigation. TextLink is for inline or supplementary links. [id: textlink.use-cta-link-for-cta; testability: manual]
### Placement
- **TextLink** SHOULD be placed inside a P element when used as an inline prose link. Rationale: TextLink inherits font size from its parent. Placing it outside a P can result in mismatched sizing. [id: textlink.inside-prose; testability: manual]
### Content
- **Link text** MUST describe the destination or purpose of the link, not just invite interaction. Rationale: Non-descriptive labels such as "click here" or "read more" are meaningless to screen reader users who navigate by link text. [id: textlink.descriptive-label; testability: manual]
- **Link text** SHOULD be unique on the page when links navigate to different destinations. Rationale: Screen reader users may browse a list of all links on the page. Identical labels for different destinations are ambiguous. [id: textlink.unique-labels; testability: manual]
### Interaction
- **TextLink** WILL add rel="noopener" to any link whose href is an external URL. The component detects external links automatically and appends noopener to the rel attribute. [id: textlink.external-link-noopener; testability: auto]
- **TextLink** WILL append a visually hidden " (opens in new window)" text string and a new-window icon when opensInNewWindow is true. The component sets target="_blank" and renders both additions automatically. [id: textlink.new-window-disclosure-automatic; testability: auto]
- **TextLink** WILL display a visible focus ring when focused via keyboard. The component applies the Nebula focusRing style via :focus-visible automatically. [id: textlink.focus-ring; testability: manual]
- **TextLink** MUST use the opensInNewWindow prop rather than manually setting target="_blank" when a link should open in a new tab. Rationale: The opensInNewWindow prop automatically appends a visually hidden " (opens in new window)" disclosure and the new-window icon. Setting target="_blank" manually omits this disclosure. [id: textlink.opens-in-new-window-disclosure; testability: auto]
## Accessibility
### In brief
Text Link is a native `a` element. A screen reader announces it as a link with its text, and a keyboard user reaches it with `Tab` and follows it with `Enter`.
### What Nebula provides
- The element is an `a`, and it stays an `a` when rendered as a React Router `Link` through `as`.
- The link text is always underlined and takes its colour from the theme's link colour.
- A visible focus ring shows when the link is focused with the keyboard.
- With `opensInNewWindow`, the link opens in a new tab, shows an icon hidden from screen readers and adds the hidden text "(opens in new window)" to the link.
### Keyboard behaviour
A link is one tab stop. Reach it with `Tab` and follow it with `Enter`.
#### Interactions
- **Tab** - Moves focus to the link
- **Enter** - Follows the link
### Accessible design
#### Write link text that makes sense out of context
A screen reader user can list every link on a page, so "Read more" says nothing there. Name the destination, for example "Read about tracking carbon".
WCAG: [2.4.4 Link Purpose (In Context)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html)
#### Don't remove the underline or change the link colour
The underline is what tells the link from the text around it for someone who cannot tell the colours apart, and the theme colour is the one that meets the contrast ratio on the page.
WCAG: [1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) [1.4.3 Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
#### Use a link to go somewhere and a button to do something
A link is announced as a link and a button as a button, and a user expects the first to navigate and the second to act.
WCAG: [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html)
### Accessible development
#### Always pass an `href`, or `to` when rendering a router `Link`
An anchor without a destination is not a link: it is not a tab stop and a screen reader does not announce it as one.
WCAG: [2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)
#### If a link opens in a new window, pass `opensInNewWindow`
The prop adds the icon and the hidden warning. Setting `target="_blank"` yourself opens the window but adds neither, so a screen reader user is not told.
### 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
### TextLink
- `opensInNewWindow` (type `boolean`, optional, default `false`). Opens the link in a new tab (sets `target="_blank"`), shows a new-window icon and adds a visually hidden "(opens in new window)" message for screen readers.
## 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.
### a
```css
text-decoration: none;
font-weight: inherit;
color: ${colors.link};
&:hover {
color: ${colors.primaryAlt};
}
&:focus-visible {
${focusRing}
}
```
### span
```css
text-decoration: underline;
```
### icon
```css
display: inline-flex;
vertical-align: middle;
```
---
# Textarea Field
A form field for collecting multi-line free-form text
Section: components
Docs: https://nebula.kaluza.com/components/textarea-field
---
## When to use
- When the user needs to enter free-form text that is likely to span multiple lines, such as a description, notes, or an explanation
- When the length of the response is unpredictable and cannot be constrained to a single line
- When the content the user writes may benefit from being visible in full while they compose it
## When not to use
- **For single-line inputs such as names, phone numbers, or reference codes** - use [Text Field](/components/text-field) instead, which signals to the user that a short response is expected.
- **For structured data that can be broken into discrete fields** - an address, for example, is better collected with multiple [Text Fields](/components/text-field) than a single textarea.
- **When only a small number of characters are expected** - the visual size of the textarea communicates how much text is expected. A large textarea for a short answer creates a mismatch that confuses users.
- **To indicate that a field is unavailable** - the disabled state is not supported. Use [Summary List](/components/summary-list) to show values that cannot be edited, or conditionally hide the field if it is not relevant in the current context.
## Anatomy
1. **Label**
2. **Hint**
3. **Textarea input**
4. **Error message**
5. **Required/Optional indicator**
## Basic usage
```tsx
```
## Variants and features
### Controlling height with rows
Use the `rows` prop to set the initial visible height of the textarea. The default is three rows. Set rows to match the expected volume of text. A field for a short description might need three rows; one for a detailed complaint or explanation might need eight or more.
```tsx
```
### Resizable
Set `resizable` to allow users to drag the bottom of the textarea to increase its height. Use this when the amount of text the user might enter is unpredictable. The textarea can only be resized vertically.
```tsx
```
### Character count
Use the `characterLimit` prop to show a live "count/limit" counter below the input. The limit is advisory: it does not stop the user typing past it, but the counter turns to the error colour and screen readers are told how many characters remain or are over.
To set the width of the textarea rather than a counter, use `characters` instead.
```tsx
```
### Common form features
For validation, hint text, required/optional labelling, and field width, see the [Form guidance](/patterns/forms). These features are shared across all Nebula form fields.
## Grouping
Textarea fields are typically part of a larger form. See the [Form component](/components/form) and [form guidelines](/patterns/forms) for guidance on grouping and layout.
## Rules and guidance
### Usage
- **TextareaField** MUST NOT be passed the disabled prop. Rationale: The prop passes through to the native textarea with no Nebula styling and removes the field from the tab order, making it invisible to keyboard users with no accessible indication of why. [id: textareafield.no-disabled; testability: auto]
- **TextareaField** SHOULD be used when the user's input is expected to span multiple lines or be of unpredictable length. [id: textareafield.use-for-multiline; testability: context]
- **TextareaField** SHOULD be replaced with plain text. For example in a SummaryList. When displaying a value the user cannot edit. Rationale: A field that looks interactive but cannot be changed is confusing. Plain text communicates the value without implying editability. [id: textareafield.non-editable-as-text; testability: manual]
- **TextareaField** SHOULD NOT be used for inputs that will contain a single short value such as a name or reference number. Rationale: Single-line inputs signal to the user that a short response is expected. Use TextField instead. [id: textareafield.not-for-single-line; testability: context]
- **TextareaField** SHOULD NOT be used to collect structured data that could be broken into discrete fields. Rationale: Multiple focused fields are easier to validate and less error-prone than parsing free text. [id: textareafield.not-for-structured-data; testability: context]
### Placement
- **rows prop** SHOULD be set to reflect the expected volume of text the user will enter. Rationale: A textarea that is too small for the expected content forces the user to scroll within it; one that is too large implies more text is expected than is needed. [id: textareafield.rows-matches-expected-content; testability: manual]
- **rows prop** SHOULD NOT be set below three for inputs where the user may enter more than a short phrase. [id: textareafield.rows-minimum; testability: auto]
### Content
- **Label** MUST be present and describe what the user should enter. [id: textareafield.label-required; testability: manual]
- If maxLength attribute is used: **Hint** MUST communicate the character limit when maxLength is set on the textarea. Rationale: Users need to know the limit before they start typing to avoid having their text truncated. [id: textareafield.hint-for-maxlength; testability: manual]
- **Hint** SHOULD explain the expected format or content when it may not be obvious from the label alone. [id: textareafield.hint-guidance; testability: manual]
### Interaction
- **TextareaField** WILL restrict resize to vertical only when resizable is true. The component sets resize: vertical via CSS and cannot be overridden via the prop. [id: textareafield.resize-vertical-only; testability: auto]
- **resizable prop** SHOULD be set to true when the amount of text the user might enter is unpredictable. Rationale: Allows users to expand the field to suit their needs without scrolling within a fixed-height input. [id: textareafield.resizable-for-unpredictable-length; testability: context]
### Validation
- **Error message** MUST describe what the user needs to do to correct their input, not just that an error occurred. [id: textareafield.error-describes-correction; testability: manual]
- If TextareaField is used inside a Form component: **Error message** SHOULD be supplied via the Form component's errors prop and name prop rather than the error prop directly. Rationale: Form context error handling ensures consistent error routing across the form without manual wiring. [id: textareafield.error-from-form-context; testability: auto]
- **TextareaField** SHOULD be validated on form submission rather than while the user is typing. Rationale: In-flight validation interrupts the user before they have finished composing their response. [id: textareafield.validate-on-submit; testability: manual]
## Accessibility
### In brief
Textarea Field is a native multi-line text input with a visible label. A screen reader announces the label (hint included), then the field, and reads an error as its description.
### What Nebula provides
- A real `label` linked to the textarea by `id`, so clicking the label focuses it and gives a larger target.
- `aria-required` on the textarea unless the field is `optional`, which adds "(optional)" to the label.
- An `error` sets `aria-invalid` on the textarea and links the message to it with `aria-describedby`. The message has an icon as well as colour.
- With `characterLimit`, the limit is the textarea's description and a live region announces the characters remaining once typing pauses.
### Keyboard behaviour
The textarea is a native `textarea`, one tab stop reached with `Tab`, and it shows a focus ring when focus comes from the keyboard. The resize handle, when `resizable` is set, is the browser's own and works with a pointer only.
#### Interactions
- **Tab** - Moves focus to the textarea
### Accessible design
#### Never use the placeholder as the label or the hint
A placeholder disappears as soon as the user types, and Nebula shows it at half opacity. Put the question in the label and any extra guidance in the hint.
WCAG: [3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html)
#### Keep the hint short
The hint sits inside the label element, so a screen reader reads it as part of the field's name. Put what the user needs first in the label and keep the hint to one short line.
WCAG: [3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html)
#### Write the error so it says what to change
"Comment must be 50 characters or fewer" tells the user what to do and "Invalid" does not. The message is read with the field, so it has to make sense on its own.
WCAG: [3.3.3 Error Suggestion](https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html)
### Accessible development
#### Always pass errors through `error`, or through a `Form` with the field `name`
This is what sets `aria-invalid` and links the message to the textarea. Text placed beside the field is not announced with it.
WCAG: [3.3.1 Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html)
#### If a `characterLimit` is set, validate it on submit
The limit is advisory: typing past it is never blocked and the counter only changes colour and announces the excess. Show an `error` when the form is submitted over the limit, so the problem is stated in text.
WCAG: [3.3.1 Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.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 | Manually 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
### TextareaField
Also accepts all props from `HTMLAttributes`, `TextareaHTMLAttributes`.
- `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`.
- `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`.
- `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`.
- `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.
- `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.
- `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.
- `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.
- `resizable` (type `boolean`, optional, default `false`). Allows the user to resize the textarea vertically by dragging its corner. When false, resizing is disabled.
- `autoComplete` (type `string`, optional). Hint to the browser's autofill, for example `email`. When the `disableAutocomplete` theme config flag is enabled this defaults to `off`; a value passed here overrides that.
- `rows` (type `number`, optional, default `3`). Number of visible lines of text.
---
# Timeline
Show a series of events or steps in chronological order.
Section: components
Docs: https://nebula.kaluza.com/components/timeline
---
## When to use
- To display a sequence of events in chronological order, for example a customer's account activity or the steps in a completed process
- To show the progress of a multi-step task where each step is discrete and has already happened
- When the time or sequence relationship between events is meaningful to the user
## When not to use
- **For future steps in an incomplete process** - if the user has not yet completed the steps, use a progress indicator or stepper pattern instead.
- **For navigation** - a Timeline is a display component, not a navigation component. Use [Tabs](/components/tabs) or page structure for navigable sections.
- **For a simple list without meaningful sequence** - use a [List](/components/list) instead.
## Anatomy
1. **Separator**
2. **Node**
3. **Content**
## Basic usage
```tsx
12 Jul 2023 You told us that you're moving out
20 Jul 2023 Your moving day
20 Jul 2023 Submit your final meter readings
```
## Variants and features
### Custom node
By default each item uses a `TimelineDot`, a solid circle in `colors.border`. Replace it with any element using the `node` prop.
Custom nodes must have a background colour and `position: relative` so they appear in front of the separator line. All nodes in a timeline must be the same width to keep the content column visually aligned.
```tsx
}>
Electricity supply connected
}>
Gas supply connected
```
### Custom separator
By default each item uses a `TimelineSeparator`, a vertical line in `colors.borderMuted`. The separator on the last item is hidden automatically. Replace it using the `separator` prop.
When customising a separator, extend the default `TimelineSeparator` with a style override rather than replacing it entirely. This preserves the base positioning and last-item hiding behaviour.
The separator is a filled block, so override `background` rather than a border. Here the completed steps use a stronger colour than the default `colors.borderMuted`:
```tsx
}>
Move-out date confirmed
}>
Final meter readings submitted
Final bill issued
```
## Rules and guidance
### Usage
- **Timeline** MUST NOT be used for navigation. Rationale: Timeline is a read-only display component. It has no interactive affordances. Use Tabs, links, or page structure for navigable sections. [id: timeline.not-for-navigation; testability: manual]
- **Timeline** SHOULD only be used for content where the sequence or time relationship between items is meaningful. Rationale: The visual structure of the Timeline, connected nodes on a vertical axis, implies a meaningful order. Using it for unordered content misleads the user into inferring sequence or causality that does not exist. [id: timeline.chronological-only; testability: manual]
### Composition
- **Timeline** WILL hide the separator after the last TimelineItem automatically via CSS. Do not manually force it visible. Rationale: Forcing the last separator visible creates a dangling line that implies a continuation that does not exist. [id: timeline.separator-hidden-automatically; testability: auto]
- **Timeline** MUST use nodes of the same width across all TimelineItems. Rationale: The content column alignment depends on the node column being a consistent width. Mixed node widths shift the content column unpredictably across items. [id: timeline.consistent-node-width; testability: manual]
- If node prop is a custom element: **TimelineItem node** MUST have a background colour and position relative when a custom node is used. Rationale: The separator line is positioned behind the node using z-index layering. Without a background, the line shows through the custom node. Without position relative, the node renders behind the separator. [id: timeline.custom-node-background; testability: manual]
- If separator prop is a custom element: **TimelineItem separator** SHOULD extend the default TimelineSeparator rather than replacing it with an unrelated element. Rationale: TimelineSeparator includes base CSS for positioning and automatic hiding on the last item. Replacing it entirely requires reimplementing this behaviour manually. [id: timeline.custom-separator-extend; testability: manual]
## Accessibility
### In brief
Timeline is an ordered list. A screen reader announces the items in markup order, as list items. The dots and separators are empty elements and add nothing to what is announced.
### What Nebula provides
- The `Timeline` is an `ol` and each `TimelineItem` an `li`, so the order and the number of items are in the markup.
- The dot and the separator hold no text, so a screen reader reads only the item content.
### Accessible design
#### Write the date or time in the text of each item
The line and the dots show the order, but a screen reader only hears the content. "12 Jul 2023, You told us that you are moving out" says when it happened.
WCAG: [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html)
#### If a custom node carries meaning, say it in the item text too
An Icon used as a node is hidden from screen readers. An electricity icon beside "Electricity supply connected" is decoration, not the only copy.
WCAG: [1.1.1 Non-text Content](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html) [1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html)
### Accessible development
#### Put the items in chronological order in the markup
A screen reader reads the items in the order they are written. Reordering them with CSS changes what a sighted user sees and not what is announced.
WCAG: [1.3.2 Meaningful Sequence](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.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 | Tested | axe-core via the Storybook test runner (CI) |
| Keyboard navigation | Manually 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
### Timeline
Also accepts all props from `HTMLAttributes`, `OlHTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
### TimelineItem
- `separator` (type `ReactNode`, optional, default ` `). Connecting line drawn from this item's node to the next. The default separator is hidden on the last item; a custom one is not.
- `node` (type `ReactNode`, optional, default ` `). Marker shown at the start of the item, for example an icon in place of the default dot.
- `children` (type `string | number | boolean | ReactElement> | ReactFragment | ReactPortal`, optional).
### TimelineDot
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
### TimelineSeparator
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
## 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.
### timeline
```css
padding: 0;
```
### timeline-list-item
```css
display: flex;
position: relative;
```
### timeline-separator
```css
position: absolute;
left: 50%;
top: 0;
width: ${borderWidths.heavy};
transform: translateX(-50%);
background: ${colors.borderMuted};
height: 100%;
display: block;
margin-top: ${space[1]};
${TimelineListItem}:last-child & {
display: none;
}
```
### timeline-node
```css
position: relative;
margin-right: ${space[2]};
padding-bottom: ${space[3]};
${mediaQueries.smallAndUp} {
margin-right: ${space[3]};
padding-bottom: ${space[4]};
}
${TimelineListItem}:last-child & {
padding-bottom: 0;
}
```
### timeline-dot
```css
position: relative;
width: ${space[5]};
height: ${space[5]};
background: ${colors.border};
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
${mediaQueries.smallAndUp} {
width: ${space[6]};
height: ${space[6]};
}
```
### timeline-item-content
```css
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
padding-bottom: ${space[3]};
${mediaQueries.smallAndUp} {
padding-bottom: ${space[4]};
}
${TimelineListItem}:last-child & {
padding-bottom: 0;
}
```
---
# Tooltip
Show supplementary context for an interactive element on hover or focus.
Section: components
Docs: https://nebula.kaluza.com/components/tooltip
---
## When to use
- To provide supplementary, non-critical context for an interactive element, for example explaining what a button or field does when the label alone is ambiguous
- To define a technical term or piece of industry jargon inline. Use `DefinitionTooltip` for this case.
- To add supplementary context beside a label or heading. Use `IconTooltip` for this case, which renders its own info icon.
- To label an icon-only control where a visible text label is not possible. Use `TooltipTrigger` with `relationship="label"` for this case, so the tooltip becomes the control's accessible name.
## When not to use
- **For critical information** - tooltips are not reliably accessible on touch devices and may be missed by keyboard users who do not explore every element. If users need the information to complete a task, show it in the interface.
- **To compensate for a poor label** - if the trigger label needs a tooltip to be understood, improve the label instead.
- **For interactive content** - tooltips cannot contain links, buttons, or other interactive elements. They do not receive focus and are not navigable.
- **For long or complex content** - a tooltip should be short enough to read at a glance. For longer explanations, use a [Disclosure](/components/disclosure) instead.
- **As the only place information is documented** - tooltips are not always reachable. Any information in a tooltip should also be available through another route.
- **For form field context** - if you need to explain what to enter in a form field, use the hint text prop on the field component instead. Hint text is always visible and does not require discovery.
## Anatomy
1. **Trigger**
2. **Tooltip**
## Basic usage
Wrap any focusable element with `TooltipTrigger` and pass the tooltip content as the `tooltip` prop.
```tsx
Submit reading
```
## Variants and features
### TooltipTrigger
`TooltipTrigger` wraps any single focusable element and manages tooltip state. The child element is cloned with the necessary ARIA and event handler props. Any element that is not natively focusable (such as a div or span) will be given `tabIndex=0` automatically.
A natively focusable child is used as-is:
```tsx
Submit reading
```
A child that is not natively focusable is given `tabIndex=0`, so it can still be reached by keyboard:
```tsx
1,204 kWh
```
### DefinitionTooltip
Use `DefinitionTooltip` when you need to define a term or phrase inline within body text. The trigger renders as the surrounding text with a dotted underline in `colors.info`, signalling that additional context is available.
```tsx
This is a paragraph with a tooltip
```
### IconTooltip
Use `IconTooltip` to add a supplementary information tooltip next to a label or heading. It renders a hardcoded info icon button. The icon and button cannot be customised. If you need a different trigger, use `TooltipTrigger` directly.
```tsx
Standing charge
```
### Placement
Tooltips default to appearing below the trigger. Use `placement` to change the direction. Collision detection runs automatically for `top` and `bottom` placements. The tooltip shifts horizontally if it would overflow the window or a boundary element.
```tsx
Top
Bottom
Left
Right
```
### Tooltip boundary
By default, collision detection uses the window width as the boundary. To constrain the tooltip within a specific container, for example a panel or modal, add the `data-nebula-tooltip-boundary` attribute to that ancestor element.
```tsx
Near the edge
```
### Accessible name vs description
The `relationship` prop controls how the tooltip content is announced by screen readers.
- **`description` (default):** the tooltip provides supplementary context. The trigger has its own visible label; the tooltip describes it. Screen readers announce both the label and the tooltip.
- **`label`:** the tooltip is the accessible name of the trigger. Use this when the trigger has no other visible label, for example an icon-only button. The tooltip content replaces the trigger's accessible name.
The trigger has its own visible label, so the tooltip describes it:
```tsx
Submit reading
```
The trigger has no visible label, so the tooltip becomes its accessible name:
```tsx
```
### Delay
Tooltips appear after a 100ms delay by default. Set `delay={true}` to use a 1500ms delay, appropriate when tooltips might be triggered accidentally during normal page interaction.
```tsx
Default delay
Long delay
```
## Rules and guidance
### Usage
- **Tooltip** MUST NOT contain information that is required to complete a task. Rationale: Tooltips are not reliably accessible on touch devices and may not be discovered by all keyboard users. If information is required, show it in the interface, in hint text, a label, or a Disclosure. [id: tooltip.no-critical-information; testability: context]
- **Tooltip** MUST NOT be the only place where information is documented. Rationale: Tooltips are not always reachable. Information that only exists in a tooltip is inaccessible to touch users and users who do not explore every element. [id: tooltip.not-only-source; testability: manual]
- **DefinitionTooltip** SHOULD be used for inline term definitions, not as a general-purpose tooltip trigger. Rationale: The dotted underline styling is a recognised convention for "this word has a definition". Using DefinitionTooltip for non-definitional content breaks this convention and confuses users who expect a definition. [id: tooltip.definition-tooltip-for-terms; testability: manual]
- **IconTooltip** SHOULD be used only to provide additional context for a labelled element, not as the sole source of important information. Rationale: IconTooltip renders an info icon with aria-label="Info". It is designed for optional supplementary context. Important information should be visible in the label or hint text. [id: tooltip.icon-tooltip-info-only; testability: manual]
- **Page or feature using tooltips** SHOULD NOT require tooltips on many elements to be usable. Rationale: When most elements need a tooltip to be understood, the interface itself needs work, not more tooltips. Over-reliance on tooltips burdens users who have to hover or focus every element to understand what they are looking at. [id: tooltip.excessive-use-signals-design-problem; testability: context]
### Content
- **Tooltip** SHOULD contain no more than two short sentences. Rationale: Tooltips are transient and cannot be scrolled. Long content is truncated or overflows its container. If more explanation is needed, use a Disclosure to reveal persistent in-context content. [id: tooltip.content-concise; testability: manual]
- **Tooltip** SHOULD NOT be used to explain an ambiguous trigger label instead of improving the label itself. Rationale: A tooltip that is needed to understand a label indicates the label is insufficient. Improving the label reduces cognitive load for all users, including those who cannot access the tooltip. [id: tooltip.no-label-improvement-substitute; testability: manual]
### Interaction
- **TooltipTrigger** WILL add tabIndex=0 to the child element when the child has no tabIndex set, so the trigger is keyboard-reachable by default. [id: tooltip.will-tabindex; testability: auto]
- **Tooltip** WILL adjust its horizontal offset to stay within the nearest ancestor that has the data-nebula-tooltip-boundary attribute. When no boundary ancestor is present, the viewport edge is used. [id: tooltip.will-boundary-reposition; testability: auto]
- **Tooltip** WILL open after a 100ms delay on hover or keyboard focus, and close when the trigger loses focus or the pointer leaves. When the delay prop is true, the hover delay is 1500ms. [id: tooltip.will-show-on-hover-and-focus; testability: auto]
- **TooltipTrigger child** MUST be a focusable element. Rationale: Tooltips are revealed on hover and keyboard focus. A trigger that is not focusable is inaccessible to keyboard users. TooltipTrigger automatically adds tabIndex=0 if the child has no tabIndex, but non-interactive elements should have a semantic role too. [id: tooltip.trigger-focusable; testability: auto]
- **TooltipTrigger relationship prop** MUST be set to "label" when the trigger has no other visible text label. Rationale: With relationship="description" (the default), screen readers announce the trigger's own label first, then the tooltip as supplementary context. If the trigger has no visible label, there is nothing for the screen reader to announce first. The tooltip must become the accessible name via aria-labelledby. [id: tooltip.relationship-label-for-icon-only; testability: manual]
- **TooltipTrigger relationship prop** SHOULD remain as "description" (the default) when the trigger has a visible text label. Rationale: Using relationship="label" on an element that already has a visible label causes the tooltip to replace rather than supplement the accessible name. Screen readers may announce the tooltip content instead of the visible label, which is confusing. [id: tooltip.relationship-description-for-labelled; testability: manual]
- **TooltipTrigger child** SHOULD have a minimum touch target size of 24x24 CSS pixels. Rationale: Tooltips revealed by hover are not accessible on touch-only devices. Where a tooltip trigger must function on touch, the touch target must be large enough to activate reliably. [id: tooltip.minimum-touch-target; testability: manual]
### Composition
- **TooltipTrigger children** MUST be a single React element, not a string, fragment, or array. Rationale: TooltipTrigger uses cloneElement to add ARIA and event handler props to the child. This only works on a single React element. Passing multiple children or a string will throw a runtime error. [id: tooltip.trigger-single-element; testability: auto]
- **Tooltip** MUST NOT contain interactive elements such as links, buttons, or form fields. Rationale: Tooltips do not receive keyboard focus and cannot be navigated into. Interactive content inside a tooltip is unreachable by keyboard and screen reader users. [id: tooltip.no-interactive-content; testability: manual]
## Accessibility
### In brief
Tooltip shows a short message when the trigger is hovered or focused with the keyboard. A screen reader announces the trigger, then the message as its description. The tooltip itself never takes focus.
### What Nebula provides
- The tooltip has `role="tooltip"`, and while it is open the trigger points to it with `aria-describedby`, or with `aria-labelledby` for the `label` relationship.
- It opens on hover and on keyboard focus of the trigger, and a closed tooltip is hidden from screen readers.
- `IconTooltip` and `DefinitionTooltip` triggers are native buttons with a visible focus ring.
### Keyboard behaviour
The trigger is a single tab stop, reached with `Tab`. The tooltip is not a tab stop.
#### Interactions
- **Tab** - Moves focus to the trigger and shows the tooltip
### Accessible design
#### Don't put interactive elements in a tooltip
The tooltip never takes focus, so a link or button inside it cannot be reached with the keyboard.
WCAG: [2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html)
#### Give a custom trigger a target of at least 24 by 24 CSS pixels
The tooltip is only as easy to open as its trigger is to hit, and a small custom trigger is hard to hover or tap for a user with limited dexterity.
WCAG: [2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)
### Accessible development
#### Wrap a control, not a bare `Icon`
`TooltipTrigger` makes its child focusable, but `Icon` is hidden from screen readers, so the focus stop is one a screen reader skips, and the tooltip hangs off it. Wrap a button, a link or another real control.
WCAG: [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html)
#### If the trigger has no name of its own, set `relationship="label"`
The tooltip is announced as a description by default, after the trigger's own name. A trigger with no visible text needs the tooltip as its name.
WCAG: [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.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
### Tooltip
- `isOpen` (type `boolean`, optional).
- `state` (type `TooltipTriggerState`, optional). Open state from `useTooltipTriggerState`, which `TooltipTrigger` supplies automatically.
- `placement` (type `'bottom' | 'top' | 'left' | 'right'`, optional, default `'bottom'`). Side of the trigger the tooltip appears on.
- `children` (type `ReactNode`, required). Content of the tooltip.
### DefinitionTooltip
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
- `tooltip` (type `ReactNode`, required). Content shown in the tooltip.
- `placement` (type `'bottom' | 'top' | 'left' | 'right'`, optional, default `'bottom'`). Side of the trigger the tooltip appears on.
- `isOpen` (type `boolean`, optional). Controls whether the tooltip is open, overriding hover and focus behaviour.
- `delay` (type `boolean`, optional). Waits 1.5 seconds before showing the tooltip on hover instead of 0.1 seconds.
- `relationship` (type `'label' | 'description'`, optional, default `'description'`). How the tooltip is announced: `description` adds it to the trigger's accessible description, while `label` makes it the trigger's accessible name.
- `children` (type `ReactNode`, required).
### IconTooltip
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
- `tooltip` (type `ReactNode`, required). Content shown in the tooltip.
- `placement` (type `'bottom' | 'top' | 'left' | 'right'`, optional, default `'bottom'`). Side of the trigger the tooltip appears on.
- `isOpen` (type `boolean`, optional). Controls whether the tooltip is open, overriding hover and focus behaviour.
- `delay` (type `boolean`, optional). Waits 1.5 seconds before showing the tooltip on hover instead of 0.1 seconds.
- `relationship` (type `'label' | 'description'`, optional, default `'description'`). How the tooltip is announced: `description` adds it to the trigger's accessible description, while `label` makes it the trigger's accessible name.
### TooltipTrigger
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
- `children` (type `ReactElement`, required). Single focusable element that triggers the tooltip. It is given a `tabIndex` of `0` unless it sets its own.
- `tooltip` (type `ReactNode`, required). Content shown in the tooltip.
- `placement` (type `'bottom' | 'top' | 'left' | 'right'`, optional, default `'bottom'`). Side of the trigger the tooltip appears on.
- `delay` (type `boolean`, optional). Waits 1.5 seconds before showing the tooltip on hover instead of 0.1 seconds.
- `relationship` (type `'label' | 'description'`, optional, default `'description'`). How the tooltip is announced: `description` adds it to the trigger's accessible description, while `label` makes it the trigger's accessible name.
- `isOpen` (type `boolean`, optional). Controls whether the tooltip is open, overriding hover and focus behaviour.
## 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.
### text-trigger
```css
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-color: ${colors.info};
text-underline-offset: 0.18em;
border-radius: ${radii.standard};
&:hover {
text-decoration-color: ${colors.infoAlt};
}
&:focus-visible {
${focusRing}
}
```
### trigger
```css
display: inline-block;
background: none;
color: ${colors.info};
border: none;
font: inherit;
cursor: pointer;
outline: inherit;
padding: ${space[1]};
margin: -${space[1]};
width: ${space[6]};
height: ${space[6]};
line-height: ${space[4]};
&:hover {
color: ${colors.infoAlt};
}
&:focus-visible svg {
border-radius: ${radii.standard};
${focusRing}
}
```
### tooltip
```css
position: absolute;
z-index: 50;
${usePlacementStyles(placement, isOpen)}
margin-left: ${offset}px;
max-width: 25rem;
text-align: left;
font-weight: ${fontWeights.normal};
line-height: ${lineHeights.small};
padding: ${space[2]};
border-radius: ${radii.standard};
background-color: ${colors.heading};
color: ${colors.primaryContrast};
vertical-align: middle;
width: max-content;
white-space: normal;
font-size: ${responsiveFontSizes.small[0]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.small[1]};
}
```
### trigger-wrapper
```css
position: relative;
display: inline-flex;
```
---
# Visually Hidden
VisuallyHidden renders content that is visible to screen readers but hidden from sighted users.
Section: components
Docs: https://nebula.kaluza.com/components/visually-hidden
---
## When to use
- To add screen-reader-only context to an interactive element that has no visible text label, such as an icon-only button
- To disambiguate repeated link or button labels, such as multiple "Find out more" links on the same page, by appending specific context
- To announce a currency denomination or unit alongside a numeric input, where the context is visually clear from the UI but absent from the input's accessible label
- To provide off-screen context that would be redundant or noisy if shown visually, but is necessary for screen reader users to understand the structure
## When not to use
- **To hide content from all users** - `VisuallyHidden` is not a general hide utility. Content that sighted users should not see should be conditionally rendered or removed from the DOM, not hidden with this component.
- **To hide content that visual users also need** - if the information is important, show it visually. Visually hidden content creates an inconsistency between visual and screen reader experiences.
- **As a substitute for a proper accessible name** - if a button or input can have a visible label, give it one. Visually hidden labels are a fallback, not a first choice.
- **For decorative content** - decorative images and icons should use `aria-hidden="true"`, not `VisuallyHidden`.
## Anatomy
1. **VisuallyHidden** (a span element positioned absolutely at 1x1px, with overflow hidden and white-space nowrap, so it does not affect page layout but remains in the accessibility tree)
## Basic usage
Add visually hidden text inside an interactive element to supplement its label.
```tsx
Find out more about tracking carbon
```
## Variants and features
### Supplementing repeated links
When the same link text appears multiple times on a page, screen reader users navigating by links hear a list of identical items with no useful distinction. Add visually hidden text to make each link unique.
Always include a leading space before the visually hidden text to prevent screen readers from concatenating the visible and hidden text without a pause.
```tsx
Find out more about the Economy 7 tariff
```
### Adding currency context to inputs
`CurrencyInput` uses `VisuallyHidden` internally to announce the currency denomination. This pattern shows how the component is used internally.
```tsx
<>
pounds
>
```
### Icon-only buttons
When a button contains only an icon, wrap the accessible label in `VisuallyHidden` so it is available to screen readers without affecting the visual layout.
```tsx
Delete meter reading
```
For a Nebula button, use [CTA Button](/components/cta-button#icon-only)'s `icon` prop instead - it handles the accessible name and tooltip for you.
## Rules and guidance
### Usage
- **VisuallyHidden** MUST NOT be used to hide content from all users. Rationale: VisuallyHidden content remains in the accessibility tree and is announced by screen readers. Use conditional rendering or display:none to remove content from all users. [id: visuallyhidden.not-general-hide; testability: manual]
- **VisuallyHidden** MUST NOT be used for decorative content that should be hidden from screen readers. Rationale: Decorative elements such as icons that duplicate visible text should use aria-hidden="true" instead, so screen readers do not announce them. [id: visuallyhidden.not-for-decorative; testability: manual]
- **VisuallyHidden** MUST be used to provide an accessible name when a button or link contains only an icon and no visible text. Rationale: Icon-only interactive elements have no accessible name without additional labelling. A missing accessible name is a WCAG 2.2 AA failure (SC 4.1.2). [id: visuallyhidden.icon-button; testability: auto]
- **VisuallyHidden** SHOULD NOT be used as a substitute for a visible label when a visible label is feasible. Rationale: Visible labels help all users, including those using voice control, and reduce the cognitive load of scanning a form or interface. Visually hidden labels are appropriate when a visible label would be redundant or disruptive to the visual design. [id: visuallyhidden.accessible-name-fallback; testability: manual]
### Placement
- **VisuallyHidden** WILL apply position:absolute, width:1px, height:1px, overflow:hidden, and clip automatically. The consumer does not need to implement the visually-hidden CSS pattern manually. [id: visuallyhidden.css-technique-applied; testability: auto]
- **VisuallyHidden** MUST NOT contain content that is expected to affect page layout. Rationale: VisuallyHidden uses position:absolute, width:1px, height:1px, and overflow:hidden. Content inside it is removed from layout flow. If layout impact is needed, the content should be visible. [id: visuallyhidden.not-layout-affecting; testability: manual]
### Content
- **VisuallyHidden text** MUST start with a space character when appended to visible text within the same element. Rationale: Without a leading space, some screen readers concatenate the visible and hidden text without a pause, producing run-together announcements such as "Find out moreabout the Economy 7 tariff". [id: visuallyhidden.leading-space; testability: manual]
- **VisuallyHidden text** SHOULD be concise and add only the context that is missing for screen reader users. Rationale: Verbose hidden text creates unnecessarily long announcements. Include only what a sighted user would infer from visual context. [id: visuallyhidden.concise; testability: manual]
### Composition
- **VisuallyHidden** MUST NOT contain interactive elements such as links, buttons, or inputs. Rationale: Interactive elements inside VisuallyHidden are keyboard-focusable but not visible, creating a confusing focus experience for sighted keyboard users. [id: visuallyhidden.no-interactive-children; testability: auto]
## Accessibility
### In brief
Visually Hidden is a `span` that is moved off the screen but stays in the page, so a screen reader reads its text and a sighted user does not see it. It does not take focus and adds no role.
### What Nebula provides
- The text is hidden by clipping it to a 1 pixel box, not with `display: none`, so it stays in the accessibility tree.
- Nebula uses it itself, for the "(opens in new window)" message in Text Link and the character limit message in Field.
### Accessible design
#### Don't use hidden text in place of a visible label
A sighted user, and a person using voice control, can only use what they see. Hidden text is for context the visible text cannot carry, such as which item a repeated "Edit" link belongs to.
WCAG: [2.5.3 Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html) [3.3.2 Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html)
#### Keep the hidden text short and add what the visible text lacks
Repeating the visible words makes a screen reader say them twice. Write only the extra context.
### Accessible development
#### Start the hidden text with a space when it follows visible text
The hidden text sits inline after the visible words, so the space keeps the two apart. Text Link does the same for its "(opens in new window)" message.
WCAG: [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html)
#### Never put a link, a button or an input inside Visually Hidden
The clipping hides the box, not the tab stop. A focusable element inside it can still be reached with `Tab` while nothing shows where focus is.
WCAG: [2.4.7 Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html)
### Accessibility testing status
Reviewed on 9 October 2026 against v17.0.1.
| Test | Status | Tested with |
| --- | --- | --- |
| Default state | Not tested | — |
| Advanced states | Not tested | — |
| Keyboard navigation | Manually 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
### VisuallyHidden
Also accepts all props from `HTMLAttributes`.
- `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`.
- `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it.
## 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.
### visually-hidden
```css
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
```
---
# Accessible content
Writing links, labels, alt text and instructions that work with assistive technology.
Section: content
Docs: https://nebula.kaluza.com/content/accessible-content
---
These guidelines apply to the words in Nebula components and the products built with them, and to how they read without the visuals around them.
Content must support assistive technologies and be perceivable, operable, understandable, and robust, regardless of how the interface is being accessed.
## Write as if visuals don't exist
Content must be self-sufficient. It should not rely on icons, colours, images, or layout position to convey meaning. Someone using a screen reader, or who can't see the interface clearly, must still understand it.
**Avoid directional and positional language.** Organise content chronologically (first, next, finally) not spatially (above, below, left, right).
| Avoid | Prefer |
|---|---|
| Above | First, previously |
| Below | Next, following |
| On the left | In the menu bar |
| At the bottom | Finally |
**Do:** Go to Billing history to review past payments.
**Don't:** See the table on the left for past payments.
**Do not reference visual appearance.** Use the control's label name, not its appearance or colour.
**Do:** Select Submit payment
**Do:** The account number is missing
**Don't:** Click the green button
**Don't:** The field with the red outline
**Do not rely on colour or icons alone** to communicate state. Always support colour with text or an additional indicator, so a field in an error state carries its error message as well as its outline.
**Prefer 'View', 'Show' or 'Go to'** over 'See' in a link or button label. Each of those names what will happen when your user selects it, where 'See' says only that something exists. 'See' is fine in running prose.
**Do:** View all results
**Don't:** See all results
## Links and buttons
Link and button text must be descriptive on its own. A screen reader user may encounter it without the surrounding context.
Every call to action on a page must be unique. Where the same label is unavoidable (for example, multiple `Edit` buttons in a list), append hidden distinguishing text so each reads as unique to assistive technology.
Provide keywords before or inside links rather than after them, so the user understands the destination before selecting it.
If a link opens in a new tab or downloads a file, indicate this in the link text or with an adjacent icon with appropriate alt text.
**Do:** View payment history
**Do:** Download payment summary (PDF)
**Don't:** Click here
**Don't:** Learn more
## Form labels
Form labels must be explicit and persistent. Do not use placeholder text as a label, it disappears on input and is not reliably read by assistive technologies.
## Alt text
Alt text should be:
- **Additive**, add context that would be lost without the image; do not repeat text already present nearby
- **Active voice**, written directly and concisely
- **Brief**, screen readers listen at fast speeds; be as short as possible while remaining meaningful
- **Consistent**, use the same alt text if the same image appears in multiple places
- **Empty (`alt=""`)**, for purely decorative images, dividers, empty-state illustrations, or images with adequate surrounding captions
Do not begin with "image of" or "picture of", screen readers already identify the element as an image.
Do not include punctuation like `!!` or emoji in alt text, screen readers announce these verbosely.
For interactive icons, describe the action the control performs, not the visual appearance of the icon.
**Do:** accessibilityLabel="Search"
**Do:** accessibilityLabel="Send message"
**Don't:** accessibilityLabel="Magnifying glass"
**Don't:** accessibilityLabel="Paper aeroplane icon"
Images with an adequate caption that already describes the content do not need separate alt text. If the image conveys additional information not in the caption, add that to the alt text rather than writing a full description.
## Related
- [Content guidelines](/content/basics)
- [Icon component](/components/icon)
- [Visually Hidden component](/components/visually-hidden)
---
# Content basics
Plain language, voice and tone, terminology, grammar and punctuation for text in Nebula components.
Section: content
Docs: https://nebula.kaluza.com/content/basics
---
These guidelines apply to all text written for Nebula components and the products built with them. Component-specific guidance lives with each component. In Kaluza Agent Experience your user is working under pressure and often mid-call, so content has to be scannable, unambiguous and quick to act on.
## Plain language
Write in plain English. Aim for a reading age of around 9 and keep sentences under 25 words, which is what GOV.UK asks for and is not the same as writing for children. Domain terms are exempt, because you can't simplify MPAN. Use the simplest word that is accurate.
**Do:** Help
**Do:** About
**Do:** Buy
**Don't:** Assist
**Don't:** Approximately
**Don't:** Purchase
Keep sentences short. One idea per sentence. Front-load the most important information, put the key point first, then the supporting detail.
**Do:** Payment failed. Card declined by the bank.
**Don't:** The bank has declined the card, which means the payment failed.
Avoid double negatives. They force the reader to decode meaning under pressure.
**Do:** This account is eligible for support.
**Don't:** This account is not ineligible for support.
Write positively. A negative construction makes your user invert the instruction before they can act on it.
**Do:** To submit, complete all required fields.
**Don't:** You can't submit if you don't complete all required fields.
Cut weak sentence openers. Remove `you can`, `there is`, `there are` and replace with a direct verb.
**Do:** Add notes to this account.
**Don't:** You can add notes to this account.
Avoid adverbs and unnecessary adjectives, they are usually redundant. Delete `simply`, `easily`, `just`, and similar on review.
Do not use Latin abbreviations: `e.g.`, `i.e.`, `etc.` Spell it out or restructure the sentence.
**Do:** For example, a gas meter or electricity meter
**Don't:** E.g. a gas or electricity meter
## Active voice
Use active voice in most cases. It is shorter, clearer, and more direct.
**Do:** We couldn't process this payment.
**Don't:** This payment could not be processed.
Passive voice is acceptable where active voice would sound accusatory, which is usually where the user caused the error. Naming us as the actor makes a bank's decision sound like ours.
**Do:** Your payment has been declined.
**Don't:** We declined your payment.
Use simple verb tenses, past, present, future. Avoid perfect tenses (has/have done) and progressive tenses (is/was doing).
**Do:** You entered an incorrect password.
**Do:** Your changes will be saved.
**Don't:** You've entered an incorrect password.
**Don't:** Your changes will be being saved.
Write about what actions and controls do, not how to physically interact with them.
**Do:** Select Submit payment
**Do:** Enter the address
**Do:** Open the account menu
**Don't:** Click the Submit payment button
**Don't:** Type the address
**Don't:** Tap the account icon
## Labels and calls to action
Interactive elements should use verb-first labels that describe the action.
**Do:** Submit payment
**Don't:** Submission
Be specific. Every call to action on a page should be unique so screen readers can distinguish between them.
Omit articles (a, an, the) in labels and action-based headings.
**Do:** Add note
**Don't:** Add a note
Avoid personal possessive pronouns in labels.
**Do:** Update address
**Don't:** Update your address
## Voice and tone
Tone should be neutral, direct, and helpful. This is a professional tool, not a consumer app.
Contractions are appropriate and improve readability, use them naturally (`can't`, `we couldn't`, `you'll`). Avoid contractions in legal notices, payment processing, and account security contexts where casual language reduces trust.
Do not use politeness padding (`please`, `thank you`) in UI labels. It adds length without value.
Do not inflate ease of use. Avoid `easy`, `simple`, `just`, they assume something about how easy your user is finding it.
**Do:** Set up your payment plan in 3 steps.
**Don't:** It's easy, just set up your payment plan.
Avoid urgency language (`warning`, `immediately`, `danger`) unless the situation genuinely warrants it. Reserve strong signals for genuinely critical moments.
## Consistency and terminology
Use the same word for the same concept throughout. In a data-dense tool a synonym reads as a different thing, so your user will wonder what changed.
Pick one term and use it everywhere. The words for interface actions are in [Preferred terms](/content/preferred-terms).
When an acronym appears for the first time in a context, spell it out once with the acronym in parentheses: `Meter Point Administration Number (MPAN)`. Do not spell out platform-wide acronyms on every occurrence.
Avoid creating new acronyms. Only use an acronym if your audience is already familiar with it, or if it is an internationally recognised abbreviation (SMS, PDF, CSV).
## Feature and function naming
Most features do not need a branded or capitalised name. Refer to them descriptively.
**Do:** bulk upload, payment plan
**Don't:** Bulk Upload, Payment Plan
Describe what a feature does rather than just naming it.
**Do:** Set up a payment plan to spread this balance over time.
**Don't:** Payment Plan
Every new name is another concept your user has to learn. Only introduce a named concept when it is necessary.
## Writing about people
Default to `they` and `them` where you don't know someone's pronouns. Don't write `his/her` or `he or she`.
`disabled` is fine where it describes a control rather than a person, as in 'Sorting is temporarily disabled'. Where you mean a person, say what they need rather than naming a condition.
## Capitalisation
Use sentence case for all UI text, headings, labels, buttons and notifications. Only capitalise proper nouns and product names.
Every punctuation mark has a rule, and they're together in the [punctuation reference](/content/dates-and-numbers#punctuation-reference), along with dates, numbers and currency.
## Related
- [Preferred terms](/content/preferred-terms)
- [Error writing](/content/error-messages)
- [Accessibility in content](/content/accessible-content)
- [Formatting, dates, numbers, currency](/content/dates-and-numbers)
---
# Dates and numbers
How to format dates, numbers, currency, punctuation and lists.
Section: content
Docs: https://nebula.kaluza.com/content/dates-and-numbers
---
These guidelines apply to dates, numbers and currency in Nebula components and the products built with them. Your product's shared formatter produces these, so what you choose is which one to ask it for.
## Dates
Dates come in four lengths.
| Length | Example | Where to use it |
|---|---|---|
| Numeric | 14/01/2026 | Table cells and column headers, where the column is narrow and every row carries a date |
| Short month | 14 Jan 2026 | Cards, summaries, sentences and confirmations, where the date is read once rather than scanned |
| Long month | 14 January 2026 | A page heading, where there's one date and plenty of room |
| ISO | 2026-01-14 | Storage, sorting, filtering, import and export. Never shown to your user |
Numeric is the default.
A numeric date is ambiguous if your reader expects month first, so where the date carries weight, such as a payment date or a contract end, use the short month form.
These four are day-first, because every market Nebula serves today reads dates that way. A market that reads month-first will need its own set.
Where a row needs the time as well, keep the date's length and put the time after it. Show only the parts that carry meaning: a seconds value in a payment history is noise.
### Times
Use the 24-hour clock, `HH:mm`, unless the market or the use case needs something else. The default overrides the locale on purpose, because a timeline read under pressure is where a missing `am` or `pm` becomes a real error.
### Date ranges
Use `to`. Avoid hyphens or dashes in running text.
**Do:** 14 Jan to 28 Feb 2025
**Don't:** 14 Jan - 28 Feb 2025
### Relative time
When showing elapsed time or time remaining, use natural language units. Switch from smaller to larger units at the appropriate threshold.
Time remaining takes the same units, with `About` where the estimate is rough.
**Do:** 1 hour ago, 3 days ago, 2 months ago
**Do:** About 1 hour remaining
**Don't:** 60 minutes ago, 1 days ago
**Don't:** 3,600 seconds remaining
### Seasons
Avoid references to seasons, they are hemisphere-dependent and ambiguous. Use months or quarters instead.
**Do:** April to June 2025
**Don't:** Autumn 2025
## Numbers
Use numerals for every number, including one to nine, and including at the start of a sentence. Someone scanning for a figure will find a numeral faster than a word.
**Do:** 3 accounts match this address.
**Don't:** Three accounts match this address.
Fractions are the exception, because a numeral fraction reads as a date or a ratio. Spell them out in running text.
**Do:** Two-thirds of accounts on this tariff
**Don't:** 2/3 of accounts on this tariff
The rest is formatting, and applies wherever a number appears.
- Use the % symbol rather than the word, and numerals for measurements, currency and data values in tables.
- Separate thousands with a comma: `1,200` not `1200`.
- Use a full stop for decimals: `£12.50` not `£12,50`.
- Spell out large round numbers: `81 million` not `81,000,000`. Use K, M or B only in a chart or a narrow table cell, with no space: `71M records`.
## Currency
Don't write a currency format. Pass the amount and the market to your product's shared formatter, which reads CLDR, the locale database every browser ships, and already knows where the symbol goes and which separators to use.
| Market | Locale | Renders as |
|---|---|---|
| UK | `en-GB` | £1,200.50 |
| Australia | `en-AU` | $1,200.50 |
| Belgium | `fr-BE` | 1 200,50 € |
| Belgium | `nl-BE` | € 1.200,50 |
Belgium needs two answers rather than one. Between `fr-BE` and `nl-BE` the symbol moves and both separators swap, which is why there's no single Belgian format to write down.
Two decisions are still yours. Disambiguate a dollar amount only where more than one dollar currency can appear on the same screen, and use the tax term the market uses.
## Punctuation reference
| Mark | Rule |
|---|---|
| Full stop | Not at the end of standalone labels, headings, or buttons. Use in body content and multi-sentence messages. |
| Oxford comma | Use in lists of three or more: `Name, address, and date of birth` |
| Colon | Use to introduce a list or simplify a complex sentence. Not at the end of a form field label. |
| Em dash (`—`) | Do not use in product content. Use a comma, colon, or full stop instead. |
| En dash (`–`) | For numerical ranges only when `to` cannot be used, e.g., in tight table cells: `FY2024–25`. No spaces around it. |
| Hyphen | For compound modifiers: `up-to-date records`, `third-party supplier`. No spaces. |
| Ellipsis (`...`) | Only for truncated content or loading states: `Loading...`. Not for effect or in button labels. |
| Ampersand (`&`) | Use `and`, which an ampersand doesn't translate cleanly into. Where a navigation label is too wide to fit and nothing else will give, `&` is the one place it's allowed. |
| Exclamation mark | Avoid. Never in error messages, failure states or alerts. |
| Semicolon | Avoid entirely. Use a full stop or comma instead. |
| Slash (`/`) | Do not use to combine words: avoid `and/or`, `his/her`. Choose one or restructure. |
| Emoji | Never use in product content. |
## Lists
- Use bulleted lists for two or more concepts of equal weight.
- Use numbered lists for sequential steps.
- Lead into a list with an introductory phrase ending in a colon: `You can choose:`
- Use parallel construction, if items are actions, all should start with a verb; if nouns, all should be nouns.
- Capitalise the first letter of each list item.
- Do not use terminal punctuation in list items unless the item is a complete sentence. If one item needs a full stop, all items need one.
## Tables and columns
- Write column headers as nouns in sentence case.
- Put a unit or currency symbol in the header rather than in every cell: `Amount (£)`, then `120.50`.
- Show an empty cell as a dash, which keeps the column scannable. Write `None` only where the absence is itself something your user acts on, such as an account with no payment plan.
- Use the same date length in every column. Two lengths in adjacent columns will read as two different kinds of date.
## Related
- [Content guidelines](/content/basics)
---
# Error messages
How to write error messages that say what happened and what to do next.
Section: content
Docs: https://nebula.kaluza.com/content/error-messages
---
These guidelines apply to the error text in Nebula components and the products built with them. Error messages are some of the most important content in a product. When something goes wrong, the user needs to understand what happened and what to do next. A poor error message leaves them stuck.
The best error message is the one that never appears. Before writing an error message, consider whether the error can be prevented through design: better input constraints, auto-correction, or clearer hints. Only surface an error message when other options are exhausted.
## Structure
Every error message should aim to answer:
- **What happened**
- **Why**, if useful
- **What to do next**, if known and not clear from context
**Do:** Date of birth doesn't match.
**Do:** Date of birth doesn't match. Ask the customer to confirm, then re-enter.
**Don't:** Invalid input.
**Don't:** Invalid input. Please try again.
Whether to name the customer in the next step depends on whether your user is on a call, which not every flow guarantees. An instruction to ask will otherwise turn a correction into a call they didn't need to make. Where you can't rely on a live conversation, say what needs to be right rather than who to ask.
Include the underlying cause only when it genuinely helps your user resolve the issue. Do not explain system constraints for their own sake.
## Language
**Avoid the word 'invalid'.** It tells your user nothing. Name the mistake they actually made, and write one message for each way the field can fail rather than one covering all of them. Where the data can't tell the failures apart, say what the field expects instead of guessing which one happened.
**Do:** Sort code must be 6 digits, for example 12-34-56.
**Don't:** Invalid sort code.
**For errors affecting the user's current task**, lead with the effect in the title, not the technical cause.
**Do:** Couldn't save payment plan
**Don't:** HTTP 500 error
**For multiple errors**, tell the user exactly how many changes are needed and what they are.
**Do:** To save, make 2 changes: Enter an MPAN. Add a primary contact.
**Don't:** There are 2 errors on this page.
**For errors without a resolution**, give a concrete action rather than a vague link.
**Do:** Supply point data unavailable. Refresh to try again.
**Don't:** Something went wrong. Refresh the page to try again.
Name what failed wherever you can. `Something went wrong` is a last resort, for when the system genuinely can't say, and it always needs a next step after it.
**Use the user's own data where possible.** Specific values reduce the need to cross-reference.
**Do:** Payment of £247.50 on 14 Jan 2025 couldn't be processed. Check the account details and try again.
**Don't:** Payment couldn't be processed.
## Two types of error
### System errors
The platform or a third party failed. Use neutral, impersonal phrasing. Do not imply the user did something wrong.
**Do:** Couldn't load this account. Check your connection or try again.
**Don't:** Account failed to load.
### User errors
The user entered something incorrectly. Use passive voice or neutral framing to avoid sounding accusatory. Describe the problem and say what the field expects.
**Do:** Sort code must be 6 digits. For example, 12-34-56.
**Do:** The payment has been declined. Check the card details and try again.
**Don't:** You entered the wrong sort code.
**Don't:** You declined the payment.
## Tone by severity
Match the tone to the consequence of the error.
| Tone | When to use | Example |
|---|---|---|
| Instructive | Low-consequence, simple fix | `Unable to load this page.` |
| Reassuring | Minor error where the user may be worried | `Servers timed out. Try again, and if the problem persists, contact support.` |
| Supportive | Serious error, data loss, or major action required | `Something went wrong. Your progress has been saved. Contact support if this continues.` |
## Apologies
Do not apologise for minor errors. Overuse makes apologies meaningless and delays the useful information. Reserve `sorry` for serious errors that involve data loss or require significant user action to resolve.
Never use whimsical language: `oops`, `whoops`, `oh no`. These undermine trust and are inappropriate in operational contexts.
**Do:** Payment data unavailable. Refresh to try again.
**Do:** You don't have permission to view managed exceptions.
**Don't:** Sorry, something went wrong!
**Don't:** No access to this page, sorry!
## Error codes
Include error codes only if the user can use them to contact support or resolve the issue. Always place the code at the end of the message.
**Do:** This profile is unavailable. Try reloading. (Error code: 10213)
**Don't:** Error 10213. This profile is unavailable.
## Validation errors on forms
Validate on submission. Don't surface a correctness error while your user is typing, because it interrupts them mid-entry.
A character counter is the one exception. It reports how much they've typed rather than judging whether it's right, so it can update live. A format mask isn't an exception: it interrupts in exactly the way this rule exists to prevent.
When errors appear after submission:
- Show them adjacent to the relevant field, below the label and hint text
- List them in the [ErrorSummaryNotification](/components/notification) at the top of the form, with a link to each field
- Use the same wording in the summary as in the inline error, if the inline error contains a link, omit the link from the summary version
The ErrorSummaryNotification title should remain as `There is a problem`. Consistent wording helps users recognise the error pattern across the platform.
## Two approaches to validation error wording
**Instruction**, tell the user directly what to do. For example: `Enter your first name`
Use when the fix is obvious and the field purpose is clear.
**Description**, describe what is wrong, then what to do. For example: `First name must be 35 characters or fewer`
Use when you need to explain a constraint the user may not have anticipated.
Choose whichever sounds more natural and helpful. Do not mix both approaches in the same message.
## Related
- [Content guidelines](/content/basics)
- [Notification component](/components/notification)
- [Form guidance](/patterns/forms)
---
# Preferred terms
The word to use for each interface action, and where the words for domain concepts live.
Section: content
Docs: https://nebula.kaluza.com/content/preferred-terms
---
These guidelines apply to the words for actions and navigation in Nebula components and the products built with them. One concept gets one word, everywhere. If your user meets 'Remove' on one screen and 'Delete' on the next, they have to work out whether the two do the same thing.
## Actions
| What the control does | Word |
|---|---|
| Sends data onward for someone else to act on | Submit |
| Asks your user to acknowledge a consequence before it happens | Confirm |
| Keeps a change to something that already exists | Save |
| Puts something into a collection that already exists | Add |
| Makes something that didn't exist before | Create |
| Opens something so it can be changed | Edit |
| Takes something out of a list, where it still exists elsewhere | Remove |
| Destroys something, where nothing else holds it | Delete |
| Queries a set of records | Search |
Remove and Delete are the pair most often swapped, and both are right somewhere. What separates them is what happens to the thing, not how final it sounds: if your user can still get it back from elsewhere, it was removed.
Where two words would both do, we've picked one so the products agree with each other. Use Search rather than Find or Lookup, and Edit rather than Change or Amend. The alternatives aren't wrong, they're just not the ones we use.
Where an action destroys something, say what it destroys rather than leaving the verb to carry it. For how much friction that needs, see [Destructive actions](/patterns/destructive-actions).
## Cancelling and closing
| What the control does | Word |
|---|---|
| Abandons a task, where progress will be lost | Cancel |
| Dismisses a panel or modal that changed nothing | Close |
Someone pressing Cancel expects to lose their work. Someone pressing Close doesn't, so swapping the two tells them the wrong thing about what they're about to lose. Where Cancel would be ambiguous because the task is named, name it on both buttons: 'Keep payment' alongside 'Delete payment' tells your user what each one leaves them with.
## Moving through a task
| What the control does | Word |
|---|---|
| Moves forward through a multi-step task | Next |
| Moves back through a multi-step task | Back |
Don't number these labels. The step position belongs to [Progress Stepper](/components/progress-stepper), and a number in the button will go stale the moment a step is added.
Back is for a task, and Previous is for a sequence that isn't one, which is why [Datepicker Field](/components/datepicker-field) announces its calendar controls as 'Previous month' and 'Next month'. Someone stepping back through a form is undoing their own progress; someone paging through a calendar isn't.
## Terms Nebula already sets
Some wording ships with the components. Where it's a prop you can override it, and where it's an accessible name you can't.
These are props. Use the default unless you have a reason not to, so the same control reads the same way in every product.
| Component | Prop | Default |
|---|---|---|
| [Notification](/components/notification) | `title` on the error summary | 'There is a problem' |
| [Overflow Menu](/components/overflow-menu) | `label` | 'More actions' |
| [Progress Stepper](/components/progress-stepper) | `finalStepLabel` | 'Finish' |
| [Datepicker Field](/components/datepicker-field) | `clearLabel` | 'Clear' |
These are fixed. Assistive technology reads them and nothing shows them, so you won't find them in a screenshot and can't change them from your product.
| Component | Announced as |
|---|---|
| [Modal](/components/modal), [Notification](/components/notification) | 'Dismiss', for the close control |
| [Page](/components/page) | 'Back' |
| [CTA Link](/components/cta-link), [Text Link](/components/text-link), [Overflow Menu](/components/overflow-menu) | ' (opens in new window)' |
| [File Field](/components/file-field) | 'Selected files' |
| [Combobox Field](/components/combobox-field) | 'Options' |
| [Datepicker Field](/components/datepicker-field) | 'Previous month', 'Next month', 'Previous year', 'Next year' |
| [Tooltip](/components/tooltip) | 'Info' |
Where one of these is wrong for your product it's usually wrong everywhere, so raise it against the component rather than working around it. 'Info' is the one to know about: an icon-only control should be announced by what it does, not by what it is.
## Domain terms
The words for things in the energy business, such as MPAN, Direct Debit, meter read and tariff, aren't set here. Local teams own them, because which word is right depends on the market and sometimes on regulation.
What still applies is the rule above. Pick one word per concept and use it everywhere, and record which of those words are proper nouns that keep their capitals, as Direct Debit does.
## Related
- [Content basics](/content/basics)
- [Dates and numbers](/content/dates-and-numbers)
---
# Choosing a form field
Pick the field that matches the data you are collecting, and the number of options the user is choosing from.
Section: patterns
Docs: https://nebula.kaluza.com/patterns/choosing-a-form-field
---
## Overview
The right field usually follows from what you're collecting. The decision only gets interesting when the user is choosing from a set of options.
This pattern covers which field to use. Each field's own page covers how to use it, and [Forms](/patterns/forms) covers how to structure the form around it.
## The core decision
Start with whether the value comes from a known list of options, then work down.
### Where the value isn't from a known list
- **Is it a specific type of input, such as a number, an amount of money, an email address, a date or a file?**
- **Yes.** Use the field for that type.
- **No.** Will the answer run to more than one line?
- **Yes.** Use [Textarea Field](/components/textarea-field).
- **No.** Use [Text Field](/components/text-field).
### Where the value comes from a known list
- **Can more than one option be selected?**
- **Yes.** Up to about six options, use [Checkbox Field](/components/checkbox-field). More than that, use [Combobox Field](/components/combobox-field) with `multiple` set.
- **No.** Up to about six options, use [Radio Field](/components/radio-field). About seven to fifteen, use [Select Field](/components/select-field). More than that, or wherever the user needs to search, use [Combobox Field](/components/combobox-field).
## Principles
### Use the most specific field for the data
Where a field exists for the thing you're collecting, use it rather than a plain Text Field. The specialised variants set the input's type, so a phone number brings up a keypad on a phone and a password is masked as it's typed. A Text Field labelled 'Email' does none of that.
### Don't use a field to display a value
If a value is read-only, render it as text rather than putting it in a field, and use [Summary List](/components/summary-list) where it belongs in a set of labelled values.
### Show all the options when there are few
A short list of options is faster to scan when every option is visible, which is why the count matters more than the kind of data. Hiding four options behind a Select Field costs the user an interaction to see something that would have fitted on the page.
## Scenarios
### A specific type of input
Use the field built for it rather than a Text Field with a telling label:
- **A number**: [NumberField](/components/subcomponents)
- **An email address**: [EmailField](/components/subcomponents)
- **A telephone number**: [TelField](/components/subcomponents)
- **A password**: [PasswordField](/components/subcomponents), which masks characters as the user types
- **An amount of money**: [Currency Field](/components/currency-field), which prefixes the currency symbol
- **A date**: [Datepicker Field](/components/datepicker-field)
- **A month and year**: [Monthpicker Field](/components/monthpicker-field)
- **A file**: File Field, which is for adding files rather than choosing from ones that already exist
The first four share the Text Field API. See [Text Field](/components/text-field).
```tsx
```
Datepicker Field covers dates the user knows from memory too, such as a date of birth. Its input takes a typed `dd/mm/yyyy` date without the calendar being opened, so nobody has to page back through years. [Date Field](/components/date-field), which split the date into three inputs for that case, is deprecated for new work.
### More than one line of text
Use [Textarea Field](/components/textarea-field). A single short value like a name or a reference number belongs in a Text Field, even where the field looks wide enough for more.
```tsx
```
### Plain text
Use [Text Field](/components/text-field), where none of the specialised variants fits and the answer is a single line.
```tsx
```
### Several options from a list
Use [Checkbox Field](/components/checkbox-field) where the user can pick any number of options from a short list, and a single Checkbox inside it for one on or off choice.
```tsx
```
Once the list is long enough that the user would rather search it than read it, use [Combobox Field](/components/combobox-field) with `multiple` set, which shows each selection as a removable chip.
### One option from a list
Use [Radio Field](/components/radio-field) for up to about six options, where every option can be read at once.
```tsx
```
From about seven options, use [Select Field](/components/select-field). Past about fifteen, or wherever the user would rather search than scroll, use [Combobox Field](/components/combobox-field), which handles single selection as well as multiple.
A Radio Field with a single option is never right, because a radio can't be deselected once chosen. For a single yes or no, use a Checkbox.
## Accessibility
- **Every field needs a visible label**, whichever you choose, and a placeholder is not one. See [Forms](/patterns/forms#labels-and-hints).
- **A group of radios or checkboxes is one field.** Radio Field and Checkbox Field wrap the group so it's announced as a whole rather than as loose controls.
- **A single radio traps the user**, because it can't be deselected once chosen. Use a Checkbox for a binary choice.
- **No field has a disabled state**, which is deliberate. See [Disabled states](/patterns/forms#disabled-states).
- **Give every field a unique id**, since each one associates its label by id, and a repeated id breaks that association for both fields.
## Related
- [Forms](/patterns/forms)
- [Text Field](/components/text-field)
- [Textarea Field](/components/textarea-field)
- [Select Field](/components/select-field)
- [Combobox Field](/components/combobox-field)
- [Radio Field](/components/radio-field)
- [Checkbox Field](/components/checkbox-field)
- [Datepicker Field](/components/datepicker-field)
---
# Destructive actions
Decide how much friction an irreversible action needs, and when to offer a way back instead.
Section: patterns
Docs: https://nebula.kaluza.com/patterns/destructive-actions
---
## Overview
Destructive actions remove or overwrite data: deleting a record, cancelling a contract, issuing a refund, overriding a meter reading. This pattern covers how much friction to put in front of them, and when to offer a way back instead.
This pattern does not cover who is allowed to perform an action. See [Permission restrictions](/patterns/permission-restrictions).
## The core decision
How much friction an action deserves depends on **how costly it is to reverse.**
- **If cheap to reverse**, let the user get on with the task and make reversing it easy to find.
- **If costly or impossible to reverse**, add friction in proportion to the cost, and explain in plain language what the consequence is.
Most actions do not need that much friction. Keep it for consequences that are serious and cannot be put right.
**Be sympathetic to how frequently your user performs the action.** A confirmation seen many times a day will lose its impact, and protects less each time it appears. Where an action is routine, make it reversible rather than confirming it, and keep it on the page instead of behind a modal.
## Reversible actions
Where an action can be easily undone, do not interrupt the user. Let them perform it, then offer a way back if needed.
- It's not always necessary to ask first. If the user can put a reversible action right themselves, a confirmation costs them time without preventing negative outcomes.
- Offer undo immediately after the action, in a [Notification](/components/notification). Notifications do not dismiss themselves, so the undo stays until the user acts on it or clears it. It does go when they navigate away, so it covers the mistake they have just made rather than one they find later.
If the action cannot be undone once that notification is gone, it is not really reversible, and it belongs in the section below.
```tsx
const [deleted, setDeleted] = useState(false);
deleted ? (
setDeleted(false) },
]}
/>
) : (
setDeleted(true)}>
Delete payment method
)
```
## Irreversible actions
Where an action cannot be undone, put a **friction modal** in front of it: a [Modal](/components/modal) whose only job is to state the consequence and make the user stop and read it.
- State the consequence as a fact, not a question. See [Copy guidance](#copy-guidance).
- Keep the emphasis on the confirming action, not on the control that opens it. Which variant to use for each is set out in [Button choice](#button-choice) below.
- One primary action per modal.
- Never open a modal from a modal. If a task inside a modal needs its own confirmation, that first task shouldn't have been in a modal.
- Do not rely on colour alone to carry the warning. The title and the button text must make the consequence clear without it.
```tsx
const [isOpen, setIsOpen] = useState(false);
<>
setIsOpen(true)}>
Cancel contract
setIsOpen(false)}
autoFocus
>
This cannot be undone. The customer will need a new contract before supply can resume.
setIsOpen(false)}>
Cancel contract
setIsOpen(false)}>
Keep contract
>
```
## Repeated and bulk actions
A confirmation that appears every time will lose impact. For actions a user performs routinely, prefer making the action reversible over confirming it.
Where a bulk action is irreversible, state the scale in the confirmation rather than leaving it to be inferred: 'Delete 12 accounts?' tells the user something 'Delete selected accounts?' does not.
On preventing a double submission: Nebula has no disabled states, with one exception that applies here. [CTA Button](/components/cta-button) has a `loading` prop that replaces the button content with a loading indicator and disables the button while the action is in flight. That is the sanctioned guard against a double click. It is not a substitute for the operation being idempotent on the server, which is what actually protects against a repeated request.
## Button choice
Match the variant to how prominent the action should be.
Use the filled destructive button for the action itself, where it's the main action on the page or in the modal.
```tsx
Delete account
```
Use the outlined destructive link when navigating to a destructive action or a prompt, rather than performing it. There is no filled destructive link, so the filled button only ever appears on the destination.
```tsx
Cancel my green plan
```
Use the tertiary destructive button where a contained button doesn't work in the layout.
```tsx
Remove payment method
```
Use a plain tertiary button, with no red at all, where the action needs real de-emphasis. This is most relevant for tables: one destructive action per row might mean a column of red buttons, which will dilute the impact of red as a warning colour. In these cases you can de-emphasise the trigger and let the confirmation carry the weight instead.
```tsx
Remove
```
See [CTA Button](/components/cta-button) and [CTA Link](/components/cta-link) for the full set.
## Accessibility
- **Focus is trapped inside the modal while it is open, and returns to the trigger on close.** [Modal](/components/modal) does both by default, and `Escape` closes it.
- **Pass `autoFocus` to move focus into the modal when it opens.** This one is opt-in. Without it focus stays where it was, and a keyboard user has to tab into the dialog to reach the buttons.
- **Land focus so the consequence is announced before a button is reachable.** Focusing the first focusable element will land the user directly on a button they might activate by reflex. Putting initial focus on the modal itself means the title and the consequence are read first. This is our own position rather than established practice, so it is worth testing.
- **Never communicate destructiveness through colour alone.** Not every user can see the colour or knows what it signifies.
- **Announce the outcome**, not just the prompt. A screen reader user needs to know the action completed, and that undo is available, without seeing a notification appear.
## Copy guidance
### Name the action in the title
**Do:** Delete customer?
**Don't:** Are you sure you want to delete customer?
### State the consequences
These should be phrased as a fact, not as a question.
**Do:** This cannot be undone.
**Don't:** Are you sure? You will not be able to reverse this.
### Label the button with a verb
Use the same verb as the title, such as Add, Delete or Save.
**Do:** Delete
**Don't:** OK
**Don't:** Yes
**Don't:** Done
**Don't:** Confirm
### Don't joke or soften
Someone cancelling a vulnerable customer's contract is not in a light moment.
See [Error messages](/content/error-messages) for system message wording.
## Related
- [Modal](/components/modal)
- [CTA Button](/components/cta-button)
- [Notification](/components/notification)
- [Permission restrictions](/patterns/permission-restrictions)
- [Forms](/patterns/forms)
## References
- [Dialog pattern (IBM Carbon)](https://carbondesignsystem.com/patterns/dialog-pattern/)
- [Button (GOV.UK Design System)](https://design-system.service.gov.uk/components/button/)
- [Interruption pages (GOV.UK Design System)](https://design-system.service.gov.uk/patterns/interruption-pages/)
- [Modal dialog (Atlassian Design System)](https://atlassian.design/components/modal-dialog/usage)
- [Warning messages (Atlassian Design System)](https://atlassian.design/foundations/content/designing-messages/warning-messages)
---
# Empty states
Say why a space is empty, and what to offer as the way out.
Section: patterns
Docs: https://nebula.kaluza.com/patterns/empty-states
---
## Overview
An empty state is what a list, table or panel shows when it has nothing in it. The same blank space might mean nothing has been added yet, that a search found nothing, that a filter is too narrow, that something failed, or that there's genuinely nothing to deal with.
This pattern covers what to say in each case, and what to offer as the way out.
## The core decision
What to show depends on why the space is empty, so work that out before writing anything.
- **Nothing added yet.** Say what will appear here, and offer the action that creates the first one.
- **A search found nothing.** Confirm the search ran, name what was searched for, and offer a way to broaden it.
- **A filter excluded everything.** Make clear the filter is responsible rather than the data, and offer to clear it.
- **Nothing needs attention.** This is likely good news, so use the opportunity to reassure: confirm the work is clear, and offer no action because there's nothing to do.
- **Something failed.** Don't dress a failure as emptiness. See [Errors are not empty states](#errors-are-not-empty-states) below.
Use [Empty State](/components/empty-state) for all of these except a failure.
```tsx
const [filtered, setFiltered] = useState(true);
filtered ? (
setFiltered(false) }}
/>
) : (
Direct Debit
Card
)
```
## Errors are not empty states
There's a difference between showing nothing because there is nothing to show, and showing nothing because data could not be retrieved. Treating these the same can have real consequences for customers, for instance, a user who reads 'No payments found' will tell the customer they have no payments. If the service is actually down, that's a false statement about someone's account. Don't use an empty state for a failure.
The same goes for anything else that isn't really emptiness.
## Fitting the space
How much to show depends on how big the space is. Empty State gives you a centred icon, a bold title, a description and room around them, which suits a whole page or a main content region. In a small panel, a card, or a single field, plain text reads better.
Where more than one Empty State component would be visible in the viewport at once, for instance on a dashboard page, keep Empty State for the main region and give the others plain text.
## Accessibility
- **Replace the thing that was empty rather than sitting beside it.** An empty table shouldn't render its column headers and footer, or a screen reader user will hear the whole table structure before reaching the message saying there's nothing in it.
- **`title` renders as bold text, not a heading.** Someone navigating by heading won't land on it, so where an empty state replaces a whole page or a major region, put a real heading above it.
- **Announce the change when content becomes empty.** Filtering a table to zero changes the page without moving focus, so a sighted user sees the rows vanish while a screen reader user hears nothing. Convey it through a live region, or move focus to the message. This is our own position rather than established practice.
- **The icon carries no information.** Empty State renders a decorative search icon, already hidden from assistive technology, so the title and description have to carry the whole message.
## Copy guidance
Keep it short enough to read at a glance, and cover one next step rather than several. Where there are genuinely several options, pick the most likely and let the rest be found elsewhere.
### Be specific
Name the thing, and where there was a query, name that too.
**Do:** No results for 'J Smith'
**Don't:** No results
### Say the filter is responsible
Otherwise someone may conclude the data is missing.
**Do:** No payments match these filters
**Don't:** No payments found
## Related
- [Empty State](/components/empty-state)
- [Notification](/components/notification)
- [Data Table](/components/data-table)
- [Loading states](/patterns/loading-states)
- [Error messages](/content/error-messages)
## References
- [Empty states pattern (IBM Carbon)](https://carbondesignsystem.com/patterns/empty-states-pattern/)
- [Empty state (Atlassian Design System)](https://atlassian.design/components/empty-state/usage)
- [Designing messages (Atlassian Design System)](https://atlassian.design/foundations/content/designing-messages)
---
# Forms
Build forms that are quick to complete, keyboard-friendly, and easy to recover from errors.
Section: patterns
Docs: https://nebula.kaluza.com/patterns/forms
---
## Overview
Most of the work in an operational tool happens inside a form. Logging a fault, taking a payment, correcting an account record: it is all someone typing into fields, often while a customer waits.
Simple is best. Most of what follows is about taking things out rather than putting them in.
This pattern covers how a form is designed and structured. For the component API, see [Form](/components/form).
```tsx
const isValidEmail = (value) => /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(value);
const Example = () => {
const [errors, setErrors] = useState([]);
const onSubmit = (e) => {
e.preventDefault();
const newErrors = [];
const {
name,
email,
terms
} = Object.fromEntries(new FormData(e.currentTarget));
if (!name) {
newErrors.push({ id: 'name', message: 'Enter a name' });
}
if (email && !isValidEmail(email)) {
newErrors.push({ id: 'email', message: 'Enter a valid email address' });
}
if (!terms) {
newErrors.push({
id: 'terms',
message: 'You must agree to the terms & conditions',
});
}
setErrors(newErrors);
if (!newErrors.length) {
alert('success');
}
}
return (
<>
Example form
>
);
}
```
## Information to collect
Ask only for what is definitely needed. Every extra field slows a user down and increases the chance of error.
- Ask only for information that will be used. Challenge requests for data that might be useful 'for reporting later'.
- Start with questions that let users know if they are not eligible, so you avoid wasting their time.
- Put questions in a logical order. Payment details come after delivery details, for example.
## Form structure
### Single-page forms
Use when the task is standard, the number of fields is low to moderate, and users need to complete most of them every time. Single-page forms are the fastest for keyboard users and let users reference earlier answers while completing later fields.
Avoid them when the form spans multiple unrelated business concepts, such as logging a fault and taking a payment in the same form.
### Multi-step forms
Use when the task has logically distinct phases, such as Identify customer → Log issue → Process refund. Multi-step forms reduce cognitive overload for complex workflows and allow progress to be saved if the workflow is interrupted.
See [Multi-step tasks](/patterns/multi-step-tasks).
### Conditional logic
Use a conditional reveal when a field is only relevant based on a previous answer. Do not show irrelevant fields greyed out or disabled. Show only what is immediately relevant.
Reveal new fields below the input that triggered them. A required field revealed above the user's current position will be missed, and they will submit the form and get a confusing error.
If an option is unavailable because of conditional logic, explain why in descriptive copy and provide a way to resolve the blocker if one exists.
## Layout
- Use a single-column layout. Multi-column layouts are error-prone, slower to use, and cause abandonment.
- Align buttons to the left edge of the last input. This is where users naturally look for them, and it supports screen magnifier users.
- Use [Stack](/components/layout-components) to control vertical spacing between form fields.
- Use `FormFields` and `FormActions` to manage spacing and layout consistently. See [Form](/components/form).
## Labels and hints
- Give every field a visible label. Sighted users can see them, visually-impaired users can hear them, and motor-impaired users can more easily set focus to the related input.
- Never use placeholder text as a label or as a place to put hint text. It disappears when the user types and is problematic for screen readers.
- Place hint text below the label and above the input. Use it when the reason for asking is not obvious, or when users are likely to make a mistake, such as a field with a complex set of formatting rules.
- Keep hint text concise. It should aid scanning, not slow it down.
## Required and optional fields
Mark the exceptions, not the rule. Fields are required by default, so marking optional fields with the `optional` prop keeps visual clutter down and draws attention to the few fields users can skip.
Where a form shows required-field markers, it needs a key explaining them at the top. See [theming](/guides/theme/#markrequiredformfields) for how to turn marking on, and [Form](/components/form) for how the key is rendered.
## Field groups
Use a fieldset to group related fields, such as address or contact information. A legend element describes the group for screen readers and must be the first element within the fieldset.
Fields do not always need grouping, but where they are grouped it should always be done with a fieldset.
Nebula has no standalone fieldset component. Use [Card](/components/card), [Stack](/components/layout-components), and [Heading](/components/heading), changing the semantic elements with the `as` prop [from styled-components](https://styled-components.com/docs/api#as-polymorphic-prop). There is a worked example in the [Card documentation](/components/card/#other-html-elements).
## Disabled states
Nebula's fields and buttons have no disabled states. Disabled elements give a poor user experience: screen readers often skip them because they are not focusable, keyboards will not register them, and where they are rendered the contrast is frequently too low to read, leaving it unclear what the user needs to do.
**For buttons**, use the default state and rely on error handling in your form. This gives users feedback on what they have missed. The one exception is while a form is submitting, where disabling the submit button prevents duplicate submissions.
**For fields**, reframe the problem. Is there a better way to design the experience that removes the need for a deactivated state? Descriptive copy is often a better way to explain why something is unavailable:
> Because you have told us you only want green energy, you cannot select coal as an energy source.
>
> If you would like to select non-green energy instead, you can update your preferences here.
Where a field requires additional information in a particular state, use a conditional reveal rather than a disabled state.
## Validation
Much of the guidance above exists to help users avoid validation errors, but some will encounter them anyway. Make those as painless to fix as possible.
- Validate on submission, not while the user types. Live validation interrupts users mid-answer.
- Be tolerant of minor formatting mistakes such as extra spaces, dashes, or slashes. Do the hard work so users do not have to.
- Add `novalidate` to the form element to disable the browser's default HTML validation, whose messages are not always helpful or consistent. `Form` does this for you.
- Display a list of errors at the top of the form using `ErrorSummaryNotification`, with each error linking to its field. See [Notification](/components/notification).
- Use concise, plain language and avoid pleasantries like 'please'. See [Error messages](/content/error-messages).
## Error types
### Field validation errors
Use `ErrorSummaryNotification` when errors relate to specific fields. The summary lists every error and links each one to the relevant field. Inside `Form`, it automatically receives keyboard focus when it appears, and field errors are routed to the right field using each field's `name` prop.
### General errors
Use a [Notification](/components/notification) with `variant="error"` for errors that do not relate to a specific field, such as an API timeout. Explain what happened and what the user should do next. The `generalError` prop on `Form` handles this, including moving focus to the message.
## The Form component
Use `Form`, `FormFields`, and `FormActions` as the default structure for every form. Much of the guidance on this page is already handled there: disabling browser validation, standardising spacing, routing errors to the right field, moving focus to the error summary, and rendering the required-fields key. Building a form any other way means reimplementing all of it.
Only render fields outside `Form` where there is a clear reason to. See [Form](/components/form) for what it handles and what you take on yourself if you do.
## Accessibility
Most of this page is accessibility guidance already, so this is a summary of where it lives rather than a separate set of rules.
- **Every field needs a visible label**, and a placeholder is not one. See [Labels and hints](#labels-and-hints).
- **Nothing is disabled.** Disabled controls are skipped by screen readers, ignored by keyboards, and often too low-contrast to read. See [Disabled states](#disabled-states) for what to do instead.
- **Errors are announced and reachable.** `ErrorSummaryNotification` takes keyboard focus when it appears and links each error to its field, so a screen reader user hears what went wrong and can get to it. See [Error types](#error-types).
- **Browser validation is off**, because its messages are inconsistent between browsers and not reliably announced. See [Validation](#validation).
- **Revealed fields appear below their trigger.** A field revealed above the user's current position is missed by anyone moving through the form linearly, which includes keyboard and screen reader users. See [Conditional logic](#conditional-logic).
- **Grouped fields use a real fieldset and legend**, so the group is announced as one thing. See [Field groups](#field-groups).
For accessibility guidance across Nebula, see the [accessibility guide](/guides/accessibility).
## Related
- [Form](/components/form)
- [Text Field](/components/text-field)
- [Notification](/components/notification)
- [CTA Button](/components/cta-button)
- [Error messages](/content/error-messages)
## References
- [Know why you're asking every question (GOV.UK Service Manual)](https://www.gov.uk/service-manual/design/form-structure#know-why-youre-asking-every-question)
- [Avoid multi-column forms (Baymard Institute)](https://baymard.com/blog/avoid-multi-column-forms)
- [Placeholders are problematic (Adam Silver)](https://adamsilver.io/articles/placeholders-are-problematic/)
- [Live validation is problematic (Adam Silver)](https://adamsilver.io/articles/live-validation-is-problematic/)
- [Form design: handling optional fields (Adam Silver)](https://adamsilver.io/articles/form-design-handling-optional-fields/)
- [Marking required fields in forms (Nielsen Norman Group)](https://www.nngroup.com/articles/required-fields/)
- [Avoid default field validation (Adrian Roselli)](https://adrianroselli.com/2019/02/avoid-default-field-validation.html)
- [Disabled buttons suck (Hampus Sethfors)](https://axesslab.com/disabled-buttons-suck/)
- [Why you shouldn't include disabled interaction elements in your design system (Chris Atherton)](https://uxdesign.cc/why-you-shouldnt-include-disabled-interaction-elements-in-your-design-system-76a2d4307faf)
- [Why you shouldn't gray out disabled buttons (UX Movement)](https://uxmovement.com/buttons/why-you-shouldnt-gray-out-disabled-buttons/)
- [A few lessons in form design (Shankar)](https://uxdesign.cc/a-few-lessons-in-form-design-2279e3bdf5f8)
---
# Loading states
Choose what to show while something loads, and keep the page usable while it arrives.
Section: patterns
Docs: https://nebula.kaluza.com/patterns/loading-states
---
## Overview
A loading state tells someone the system is working. Choosing the right one can set an expectation of how long that will take. Used well a loading indicator can keep the user oriented, but overuse can make the product feel slower than it is.
The guidance below is organised by what is loading, rather than by how long the loading takes. The duration is still important, and should guide how much to show, but the thing being loaded should decide what to show.
## The core decision
The screen should react to the click immediately, even when the thing being loaded is slow to arrive. Under about 100ms feels instant. Past that, show something straight away rather than waiting for the data.
The most appropriate pattern depends on two things: whether you know the shape of what's being loaded, and whether the wait is long enough to notice.
- **If the shape of the result is known**, draw it with skeleton components. A skeleton that matches the layout tells the user where the data will appear and lets them aim at it before it arrives.
- **If the shape is not known**, show a loading indicator, for example a spinner, instead. A chart or a result list whose size depends on the data has no layout you can draw up front.
- **If the wait is short enough not to register**, show nothing. A loader that flashes on and off will distract more than no loader at all.
## Page and section loads
Use skeletons where the layout is predictable: `Skeleton`, `SkeletonText`, `SkeletonHeading`, `SkeletonCircle` and `SkeletonCTA`. See [Skeleton Loading](/components/skeleton-loading).
- Match the real layout closely. A skeleton that doesn't match what arrives will make the page jump, wasting the time it was meant to save.
- Skeleton the content, not the container. A card or table renders as normal, and the text, headings and images inside it get skeletons.
- Use `SkeletonCTA` for a button whose label or availability depends on data that is still loading.
- Show anything that doesn't depend on the data straight away, rather than waiting for the data to arrive. A page title that is always 'Payments' should render immediately, not as a grey bar.
Skeletons work best for waits long enough to notice but short enough to sit through, roughly a second to a few seconds. Beyond that, say something about progress instead of implying the content is nearly there.
```tsx
```
## When you cannot draw the layout
A spinner is for a region you cannot draw in advance, for example: a chart whose axes depend on the data, or a preview of an unknown file type. Most unknown cases are not really unknown, though. A paginated table knows its page size, so skeleton rows will match what arrives. A list with no bound does not, and guessing a row count causes the jump skeletons are meant to prevent.
- **Several regions loading at once.** Use skeletons where the regions can be drawn, or one spinner for the whole area where they cannot. Several spinners will read as several unrelated problems.
- **Do not mix the two in the same view.** Half grey blocks and half spinning circles will look broken rather than loading.
Nebula does not currently ship a spinner, so the ones in KAE come from components-core and semantic-ui.
## Data refresh
If someone is looking at data, let them stay in control of whether it gets refreshed. The best way to handle a refresh depends on what triggered it.
If the user has initiated the refresh, they are waiting for the new data, so bring it in and keep as much of the page in place as you can.
- Replace only the part whose data changed. In a table, swap the rows for skeleton rows and leave the header and filters standing. Resetting the whole page loses the user's place and their scroll position.
- Put the loading state on whatever triggered it, like the filter or the refresh button.
- Do not dim or grey out data that is still readable. It takes away something useful and gives nothing back.
The data itself swaps when the new data arrives. If the refresh is quick enough not to register, show nothing at all and let it change.
If the system has newer data to show, do not swap it in while they are reading. Put a [Notification](/components/notification) below the nearest heading to say newer data is available, with an action to reload that region, and let them choose when. Notifications stay until they are acted on, so nothing is missed.
This matters most where someone is reading a record aloud to a customer. Content that changes mid-sentence will cause more trouble than content that's briefly out of date.
## Actions in flight
When the user submits something, the feedback belongs on the thing they acted on.
- Use the `loading` prop on [CTA Button](/components/cta-button). It replaces the button content with a loading indicator and disables the button while the action is in flight, which is the one place Nebula sanctions a disabled control. See [Disabled states](/patterns/forms#disabled-states).
- Do not cover the page for an action that affects one part of it.
- Say what is happening, not just that something is. 'Saving' then 'Saved' tells the user more than a spinner.
```tsx
const [saving, setSaving] = useState(false);
{
setSaving(true);
setTimeout(() => setSaving(false), 2000);
}}
>
Save
```
## Long waits
Where an operation runs long enough that the user could reasonably do something else, stop describing it as loading and start describing the progress.
- Use [Progress Bar](/components/progress-bar) when real progress can be measured. A bar that visibly advances is the feedback, so do not put a spinner next to it as well.
- Where it cannot, set an expectation in words and let the user decide whether to wait.
- Beyond around ten seconds people abandon the task, so an unexplained wait of that length needs either progress or a way out.
## Accessibility
- **Announce that loading started**, through an ARIA live region, for anyone who cannot see the skeleton or indicator.
- **Announce that it finished.** This is the step most often missed. Someone who cannot see an indicator disappear has no way of knowing the page is usable again, so convey completion through the live region or by moving focus to the content that arrived.
- **Give every loading indicator a label** that names the process: loading, saving, submitting.
- **Do not stop the animation under reduced motion.** A frozen indicator will look like a stalled process.
- **Announce a failure too.** A load that stops without explanation will leave a screen reader user with nothing to go on.
- Skeletons are decorative and should be hidden from assistive technology, with the live region carrying the message instead.
## Copy guidance
### Say what's loading
Not just that something is. Naming the thing tells the user whether they're waiting on the part they actually care about.
**Do:** Loading payments
**Don't:** Loading
**Don't:** Please wait
### Carry the verb through every state
For an action, the label should always say what is happening or what just happened.
**Do:** Save → Saving → Saved
**Don't:** Save → Loading → Save
### Use ellipses sparingly
An ellipsis is only needed where the label doesn't already read as in progress. A verb in its -ing form, alone or with a noun, is clear without one: 'Saving' and 'Loading payments' both work.
Where you do add one, use the single character (…) rather than three full stops.
## Related
- [Skeleton Loading](/components/skeleton-loading)
- [Progress Bar](/components/progress-bar)
- [CTA Button](/components/cta-button)
- [Destructive actions](/patterns/destructive-actions)
- [Forms](/patterns/forms)
## References
- [Loading pattern (IBM Carbon)](https://carbondesignsystem.com/patterns/loading-pattern/)
- [Loading accessibility (IBM Carbon)](https://carbondesignsystem.com/components/loading/accessibility/)
- [Inline loading (IBM Carbon)](https://carbondesignsystem.com/components/inline-loading/usage/)
- [Spinner (Atlassian Design System)](https://atlassian.design/components/spinner/usage)
- [Skeleton (Atlassian Design System)](https://atlassian.design/components/skeleton/usage)
---
# Multi-step tasks
Divide a task across steps where splitting helps, and keep someone oriented as they move through it.
Section: patterns
Docs: https://nebula.kaluza.com/patterns/multi-step-tasks
---
## Overview
A multi-step task splits one piece of work across several screens, each with its own submit, so someone completes it in stages rather than all at once. Opening an account, processing a refund and arranging a meter exchange are usually this shape.
Splitting has a cost as well as a benefit. Every step is a page load, and someone working at speed will feel each one, so the gain has to be real.
This pattern covers how a task is divided and how someone moves through it. The design of the fields inside any one step is in [Forms](/patterns/forms).
## The core decision
Split a task into steps where at least one of the following is true:
- **Some parts have to be done before others.** An account has to exist before it can be linked to a supply, or the system has to act on one answer before it can ask the next. A dependency between two fields isn't this, because a conditional reveal on a single page will handle that. See [Conditional logic](/patterns/forms#conditional-logic).
- **Work in progress might be lost, and each step can be saved as it's completed.** The saving is the condition rather than the splitting. Where the backend only commits at the end, steps give someone the appearance of banked progress without the fact of it.
- **The work genuinely arrives in phases.** It changes hands, or waits on information someone doesn't have yet, so the steps follow a shape the task already has.
- **Someone has to be able to stop and come back.** A named step they've completed is easier to hand on or pick up again than a position partway down a long page.
- **The questions have little to do with each other.** Separating them lets someone deal with one group at a time instead of reading past the rest to find their own.
Where none of these is true, keep the task on one page, even if that means a long form. A single page is faster for keyboard users, and it lets someone check an earlier answer while filling a later field.
## Dividing the steps
Divide by what someone is doing, not by how many fields fit on a screen. Each step should be nameable as one thing: 'Identify customer', 'Describe the fault', 'Take payment'.
- **Keep each step to one subject.** If a step needs two unrelated headings, it is likely better as two steps.
- **Put identification and eligibility first**, so a task that cannot proceed stops early rather than after everything has been entered.
- **Order the steps the way the work happens.** Payment comes after the details it is paying for.
- **Keep one decision on one step.** Someone choosing between options needs all the options in front of them, so a choice split across two screens will be made with half the information.
## Branching and skipped steps
Where an earlier answer makes a later step irrelevant, skip that step rather than showing it empty or disabled.
- **Pass only the steps that apply.** [Progress Stepper](/components/progress-stepper) derives the total from the `steps` array, so a step someone skips has to come out of the array, or the count will promise them a step they'll never reach.
- **Determine the route and its number of steps as early as you can.** A total that changes partway through will look like a mistake, so the questions that decide the route belong in the first step or two.
- **Where the route can't be known up front, leave the count out** rather than showing a number you'll have to revise.
- **Skip the same steps on the way back**, so going back follows the route someone actually took.
- **If there is a review step, show only the steps the user encountered.** Leave a skipped step out of the review rather than listing it as empty or 'Not applicable'.
## One step per page
Give each step its own page and its own URL rather than swapping the contents of a single screen.
- **The step heading is not a live region.** [Progress Stepper](/components/progress-stepper) renders the step name as a heading, and a heading that changes in place says nothing to a screen reader, so a step that advances without a page load has to announce itself in a status message. A page load does that work for you.
- **The browser back button should go back a step.** Where steps swap in place, back takes someone out of the task altogether, which is rarely what they meant by it.
- **A URL per step can be refreshed, linked and returned to.** Someone who reloads partway through should land on the step they were on, not at the beginning.
Do not run a multi-step task inside a [Modal](/components/modal). A modal has nowhere to put a URL, a back button or a review step.
## Showing progress
[Progress Stepper](/components/progress-stepper) shows where someone is and what is coming next. Put it at the top of every step, above the form.
```tsx
```
- **Back sits to the left of the forward button**, so each chevron points the way its button goes.
- **The stepper is not navigation.** It is display-only, so moving back needs a control of its own.
- **The stepper does not list every step.** It names the step someone is on and the one after it. Where someone needs the shape of the whole task before they commit to it, put that on a start page.
Where a task has no persistent indicator, because it is only two steps, the step still needs a heading that says what it is.
## Moving between steps
Every step submits before the next one loads, so each has its own forward control. Put a chevron on it. See [CTA Button](/components/cta-button).
- **Give every step after the first a way back.** Going back should not clear anything, and the step someone returns to should still hold the answers they gave it.
- **Make leaving deliberate.** Where a task can be abandoned, give it an explicit way out rather than relying on the browser back button. Where leaving discards what has been entered, see [Destructive actions](/patterns/destructive-actions).
## Saving progress
Every step that submits gives someone a point they can get back to, which is one of the main reasons to split a task at all. A task that only persists on the final submit will lose everything to a closed tab or a dropped connection.
Where each step's answers go depends on what the API accepts.
- **Where the API takes a step at a time**, submit each step as it is completed. The work will be durable as soon as the step is done, and the step can be validated on the server where it stands. See [Validation](#validation).
- **Where the API only takes the whole task**, hold the answers in the client until the final submit and treat that store as temporary.
- **One task may need both.** A step that creates a record server-side can sit alongside steps held in the client until the end.
Anything held in the client is still customer data. Keep it to what the task needs, and clear it once the task is submitted or abandoned.
### Returning to an unfinished task
Where a step's answers are saved, say so when the user leaves, and tell them where to find the task again.
## Reviewing before submitting
Where a task commits something that is awkward to undo, end it with a review step showing every answer before the final submit. Someone who has been through five screens has no other way to see what they are about to send.
Build it from [Summary List](/components/summary-list), and give each row a way back to the step that set it.
```tsx
Jane Doe
Change customer
No hot water
Change fault
```
- **The change control goes inside the value.** `SummaryItem` takes a label and its value, with no separate slot for an action.
- **Name what each control changes.** Passing `icon` to a [CTA Button](/components/cta-button) makes it icon-only and turns its children into both the tooltip and the accessible name, so 'Change customer' is what a screen reader announces even though only the icon is drawn.
- **A change control returns to the review.** Someone who goes back to correct one answer should land on the review again, not walk forward through every remaining step.
- **Say what submitting does.** `FormActions` takes a `message` for text above the buttons, which is where that expectation belongs. See [Form](/components/form).
## Validation
Validation and error handling follow the standard form guidance in [Forms](/patterns/forms#validation), down to the [error types](/patterns/forms#error-types) and the components that carry them. What a multi-step task adds is a decision about where each check runs and where its errors appear.
Ideally, validate each step as someone moves on from it, so a mistake will surface on the screen that produced it.
### Inline validation
Every step should perform inline validation of its own fields when someone moves to the next step, whatever the backend is doing.
`ErrorSummaryNotification` links each error to its field and moves focus to the summary, and both only work while those fields are on screen, so each step should raise its own errors.
### Backend validation
Where backend validation happens depends on whether a step's data can be submitted on its own.
- **Where a step can be persisted by itself**, validate it on the server as the step is sent, so a rejection will land on the step and the field that caused it.
- **Where everything goes at the final submit**, server-side problems can only surface at the end. Name the step each one belongs to and link back to it, because a summary pointing at a field three pages back has nothing to move focus to.
Either way, an API timeout or a rejected request is not a validation error. It belongs in `generalError` on `Form`, which says what happened without implying someone mistyped. See [Error types](/patterns/forms#error-types).
## Accessibility
Much of this page is accessibility guidance already, so this is a summary of where it sits rather than a separate set of rules.
- **Each step is its own page load**, because the step heading is not a live region and a step that advances in place is announced to nobody. See [One step per page](#one-step-per-page).
- **The step heading carries the count.** [Progress Stepper](/components/progress-stepper) names it 'Step 2 of 4, Describe the fault' and hides its segmented bar from screen readers, since the heading already says the same thing. Set `headingAs` to keep the page outline in order.
- **Nothing on the stepper takes `role="progressbar"` or `aria-current`.** The first strips the heading of its meaning and the second has no labelled steps to point at.
- **Change controls on the review step are named individually**, so a column of edit buttons does not reach a screen reader as six identical controls. See [Reviewing before submitting](#reviewing-before-submitting).
- **Going back does not clear a step**, so nobody is made to re-enter an answer they already gave. See [Moving between steps](#moving-between-steps).
- **Errors stay on the step that owns them**, where the summary can still move focus to the field. See [Validation](#validation).
For accessibility guidance across Nebula, see the [accessibility guide](/guides/accessibility).
## Copy guidance
### Label the forward button with what it does
'Continue' is right where a step only moves on. Where submitting the step also saves or sends something, say that instead.
**Do:** Save and continue
**Don't:** Next
### Name the outcome on the final step
The last button commits the task, so name what it does rather than the act of sending a form.
**Do:** Process refund
**Don't:** Submit
### Keep the back control plain
One word, since the destination is a screen someone has already seen.
**Do:** Back
**Don't:** Go back to previous step
## Related
- [Progress Stepper](/components/progress-stepper)
- [Form](/components/form)
- [Summary List](/components/summary-list)
- [CTA Button](/components/cta-button)
- [Forms](/patterns/forms)
- [Destructive actions](/patterns/destructive-actions)
## References
- [Check answers (GOV.UK Design System)](https://design-system.service.gov.uk/patterns/check-answers/)
- [Progress indicators (Nielsen Norman Group)](https://www.nngroup.com/articles/progress-indicators/)
---
# Permission restrictions
Help users understand what they can see, what they can do, and where something is unavailable to them.
Section: patterns
Docs: https://nebula.kaluza.com/patterns/permission-restrictions
---
## Overview
Restrictions appear in three scenarios: a whole page the user cannot access, a data item within a page they can otherwise see, or an action they are not allowed to perform. Each has the same underlying challenge: helping users understand a restriction without exposing information they should not see.
This pattern covers how a restriction is presented. It does not cover how permissions are granted, or any access escalation process.
## The core decision
What to show depends on whether the user needs to know the restricted thing exists.
- **Where they don't**, hide it. Remove it from navigation, omit it from the page, and do not render the action trigger.
- **Where they do**, show something to indicate the restriction. Where users are likely to keep searching for a restricted item, make the restriction visible even though the content itself stays hidden.
## Principles
### Apply restrictions on page load
Restrictions must be in place before any content is rendered. Data must not appear or flicker before a restriction takes effect. Even a brief flash is a data protection issue.
### Restrict early in flows
If a flow requires a permission the user does not have, surface that before step one rather than letting someone progress through several steps only to hit a restriction at the end. If only one step or branch needs the permission and the rest of the flow can continue without it, restrict just that step.
### Make visible restrictions clearly visible
Where users can see that something is restricted, show it plainly on the page. Do not put restriction information behind a hover state or a tooltip. Someone who needs to act on a restriction should be able to see it without effort.
### Provide a path to access where possible
Aim to give users a way to request access, or to find out who can grant it. A link, a team name, or an instruction, whatever is realistic in context. A restriction the user can do nothing about is a dead end.
## Scenarios
The examples below use a reference page to illustrate each scenario. The page itself is for demonstration only.
### Whole pages
Use when an entire page is inaccessible.
- If the user does not need to know the page exists, remove it from navigation and remove any links to it from other pages.
- If the user must know it exists, keep the navigation items and links visible.
Build a restricted-page state in both cases, using a [Notification](/components/notification) with `variant="warning"`. Users may have bookmarked the page or followed a shared link, whether or not it appears in their navigation.
### Information on a page
Use when specific data on an otherwise accessible page is restricted.
- If the user does not need to know the information exists, omit it entirely.
- If the user must know, replace the value with a 16px lock icon and the word 'Restricted', which reads differently from an empty field or a 'no data' state.
- If more than one item on the page is restricted, explain the group once with a single [Notification](/components/notification) using `variant="info"`. Do not repeat a restriction message against every item.
Where access can be requested, put a brief instruction or contact detail in the notification rather than against each value.
```tsx
const Restricted = () => (
Restricted
);
Jane Doe
```
### Read-only pages
Use when every action on a page is unavailable. This is the individual actions scenario below, applied when no action is available rather than only some.
- If the user does not need to know they are in read-only mode, hide all action triggers.
- If they need to know, hide all action triggers and add a [Notification](/components/notification) with `variant="info"` explaining that the page is read-only.
### Individual actions
Use when a user can reach a page but cannot perform one or more actions on it.
- If the user does not need to know an action exists, do not render its trigger.
- If the user must know an action is restricted, render the trigger and show the restriction where the action would normally happen. If activating the trigger would normally open a [Modal](/components/modal), open the modal and explain the restriction inside it. If it would normally navigate to a page, show the restriction on that page.
Surfacing the restriction in the place the action would have happened keeps the explanation where the user is looking, rather than making them hunt for why nothing happened.
[CTA Button](/components/cta-button) has no disabled state, and a custom greyed-out button is not a substitute. It does not communicate why the action is unavailable, and it is skipped or unreadable for many users. See [Disabled states](/patterns/forms#disabled-states) in the Forms pattern.
## Accessibility
- **Apply restrictions before render.** Never remove content from the DOM after it has been displayed. Use server-side or early-load checks so restricted content never reaches the browser at all.
- **Hide restricted content from assistive technology too.** When content is omitted, make sure it is not merely hidden visually. It must be absent for screen reader users as well.
- **Announce restrictions that appear dynamically.** If a restriction notification appears in response to a user action, move focus to it or render it in an ARIA live region, or it will go unnoticed.
- **Let the visible text carry the meaning.** [Icon](/components/icon) renders `aria-hidden="true"`, so the lock is decorative and the adjacent "Restricted" text is what screen reader users hear. Do not use the lock icon on its own, and do not label the icon, which would duplicate the text.
- **Return focus after a modal.** Where a restriction is surfaced in a modal, follow the usual [Modal](/components/modal) focus behaviour: focus moves into the modal on open and returns to the trigger on close.
## Copy guidance
Be direct and specific. Say what the user cannot access and, where possible, name the route to getting access.
**Do:** This information is restricted. Contact [team name] to request access.
**Don't:** You do not have permission to view this.
See [Error messages](/content/error-messages) for writing system messages more generally.
## Related
- [Notification](/components/notification)
- [Modal](/components/modal)
- [CTA Button](/components/cta-button)
- [Icon](/components/icon)
- [Forms](/patterns/forms)
---
# Progressive disclosure
Choose the right way to hold back secondary content, and keep what everyone needs on the page.
Section: patterns
Docs: https://nebula.kaluza.com/patterns/progressive-disclosure
---
## Overview
Progressive disclosure keeps a page scannable by holding back information that only some users need, behind something they can open when they need it.
Nebula has several components that do this and they aren't interchangeable. This pattern covers which one to use. Each component's own page covers how to use it.
## The core decision
What to use depends on how much information you're holding back, and whether the user needs it beside the page or in place of it.
- If it's one block of supporting detail, use [Disclosure](/components/disclosure).
- If there are several such blocks and users pick between them, use [Accordion](/components/accordion).
- If each one is a complete view of the same subject, use [Tabs](/components/tabs).
- If it's too much for a Disclosure and leaving the page would lose the user's place, use [Modal](/components/modal).
- If it's a long or multi-step task, give it a page. A short one can sit in a modal.
- If it's a word or phrase that needs explaining, use [Tooltip](/components/tooltip).
## Principles
### Show what most users need
If most users will need to see the information, it's better to show it on the page. That isn't a judgement about how critical the information is, but about how many users need it and how often they need it.
### Say what's behind the trigger
The label is all a user has to go on when they decide whether to open something, so it needs to describe what's there. A trigger reading 'More information' makes them open it to find out whether they needed to.
### Remember that hidden content isn't searchable
Find-in-page won't match text inside a collapsed Disclosure or Accordion, or inside an inactive tab panel. If users are likely to search the page for a term, keep that term in the visible text.
## Scenarios
### One block of supporting detail
Use [Disclosure](/components/disclosure) for a single block of detail that a user reads in the context of what surrounds it.
Keep the content modest. If you find yourself putting a heading, a form or a primary action inside one, the content has outgrown the component.
```tsx
The estimate uses the last three meter readings and the average consumption
for similar properties in the area. It is replaced by an actual figure as
soon as a new reading is submitted.
```
### Several blocks the user picks between
Use [Accordion](/components/accordion), inside an `AccordionGroup`, where there are several blocks of detail and the user chooses which to open. A set of frequently asked questions is the clearest case.
The group is what makes it an Accordion rather than several Disclosures. If you only have one toggle, use a Disclosure, which carries less visual weight.
```tsx
Refunds reach the account they were paid from within five working days of
approval.
Yes, up until the point the refund is approved. After that it has to be
reversed and reissued.
```
### Complete views of the same subject
Use [Tabs](/components/tabs) where each view stands on its own and the user needs one at a time, such as payments and meter readings for one account.
Tabs imply the views are peers, so they're wrong for detail that supports the page rather than divides it. They're also wrong where the user needs to see two views at once, which is a comparison rather than a disclosure.
```tsx
Payments
Meter readings
Four payments in the last six months, all collected on time.
Two readings submitted in the last six months, both estimated.
```
### More detail without losing your place
Use [Modal](/components/modal) where the detail is more than a Disclosure should carry and leaving the page would cost the user their place. Someone mid-call who navigates away from an account to read a policy note has lost the thing they were working on.
What makes this reasonable is that the user opened it. The caution about modals is about interrupting someone, and a modal a user opened to see more doesn't interrupt anything.
It's still the heaviest option here, because focus is held inside it and the page behind it is hidden from assistive technology. Use it where keeping context matters, not wherever a Disclosure felt too small.
> **Expandable rows** - some Advanced Tables reveal a row's detail by expanding it in place, which is another reasonable way to keep the user where they are. Advanced Table isn't part of Nebula yet, so this guidance will be updated when it is.
### Working through a task
A short task can sit in a modal, particularly where the user needs to stay in the context of the page behind it.
If it's longer, or has several steps, give it a page instead. A multi-step form in a modal has nowhere to put a step in the URL, and nothing to return to if the user needs to check something else first. See [Multi-step tasks](/patterns/multi-step-tasks).
### A word or phrase explained in place
Use [Tooltip](/components/tooltip) for a short gloss on something already on screen, and `DefinitionTooltip` where you're explaining jargon.
A tooltip holds nothing interactive and users can miss it entirely on touch, so whatever is inside one needs to exist somewhere else too. That makes it the narrowest option here: a sentence at most, and never the only route to something.
```tsx
The account is on a{' '}
fixed tariff
{' '}
until April.
```
Where the explanation belongs beside a label or a heading rather than inside a sentence, use `IconTooltip`. It renders its own info icon, which you can't change.
```tsx
Estimated annual cost
```
### What to type into a field
Use the field's own hint text, not a tooltip. Hint text is always visible, sits between the label and the input, and needs no discovery. See [Forms](/patterns/forms#labels-and-hints).
If you're reaching for a tooltip on a form field, the label is usually doing too little work. Improve the label first.
## Accessibility
- **Each component announces its own state.** Disclosure renders a native `details` and `summary`, so the browser supplies the expanded state without any props. See [Disclosure](/components/disclosure).
- **Collapsed content is hidden from assistive technology as well as from view**, which is what makes hiding something everyone needs a barrier rather than a tidy-up.
- **A tooltip is the least reachable option.** It doesn't take focus, it holds nothing interactive, and users can miss it on touch. See [Tooltip](/components/tooltip).
- **Triggers are reached in reading order**, so if you put a Disclosure above the content it explains, it's announced before there's anything to explain.
- **Nesting is ruled out**, for a Disclosure inside a Disclosure and an Accordion inside an Accordion. If content seems to need it, the hierarchy is too deep for disclosure at all.
## Copy guidance
### Name what's behind the trigger
The label is the whole basis for deciding whether to open it.
**Do:** How this estimate was calculated
**Don't:** More information
### Write a trigger as a label, not a link
A trigger that reads like navigation suggests the user will be taken somewhere, and they'll lose their place looking for the way back.
**Do:** Payment methods we accept
**Don't:** View payment methods
### Keep tooltip text to a sentence
If it needs more than that, the content belongs in a Disclosure where it can be read properly.
**Do:** A tariff whose unit rate is fixed for an agreed period.
**Don't:** A fixed tariff is one where the unit rate you pay is agreed in advance and does not change for the length of the agreement, which protects you from price rises but also means you do not benefit from falls.
## Related
- [Disclosure](/components/disclosure)
- [Accordion](/components/accordion)
- [Tabs](/components/tabs)
- [Tooltip](/components/tooltip)
- [Modal](/components/modal)
- [Forms](/patterns/forms)
---
# Accessibility
Learn how to build accessible apps following best practices.
Section: guides
Docs: https://nebula.kaluza.com/guides/accessibility
---
## Principles
- Available, usable, understandable to all
- Simplicity - energy is getting more complex, let's keep it as simple as possible
- Use 'tried and tested' design patterns as a first resort
- Include participants with access needs in our usability testing
- Conduct regular technical accessibility audits on our products
- Adopt automated accessibility testing into our production pipelines
- Accessibility eclipses previously accepted ways of working
## What does Accessibility mean?
- Making products available, usable and understandable to all
- Making sure that everyone has an equal experience when using a product
- Building for people with disabilities (visual, hearing, motor or cognitive)
- Not just restricted to disabilities (internet access, language, device use)
## Guidelines
### Language
- Use plain English. Don’t use jargon or idioms.
- Expand on uncommon acronyms the first time they are used.
#### Why?
Our users will have different levels of comprehension and the simpler the
language is, the easier it will be to read. If English isn’t your native
language then keeping words simpler will make it easier to translate
(using either your own ability or by using software to translate pages).
#### Further reading and examples
- [How people read - Government Digital Service](https://www.gov.uk/guidance/content-design/writing-for-gov-uk#how-people-read)
- [Tone of voice - Monzo](https://monzo.com/tone-of-voice/)
- [Keep content clear and concise](https://www.w3.org/WAI/tips/writing/#keep-content-clear-and-concise)
### Colour
- You can use colour to convey meaning, but it has to be accompanied by a symbol, emoji and/or text explaining the situation.
- Contrast between text and its background must be sufficient in all cases. Must pass Web Content and Accessibility Guidelines [WCAG 2.2, 1.4.3 Contrast (Minimum), Level AA](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
#### Why?
If you struggle to distinguish colours, then using only colour can make it
difficult to complete tasks. Errors in forms being the most common case
where red is used to suggest an error has occurred.
The colour ratios should help all users distinguish background and
foreground colours, but again those who struggle to distinguish colours
will be helped by the contrast ratios specified in the WCAG 2.2
requirement.
#### Tools
- [Stark - Figma plugin](https://www.getstark.co/)
- [WebAIM colour contrast checker](https://webaim.org/resources/contrastchecker/)
- [aXe - browser plugin](https://www.deque.com/axe/)
#### Further reading and examples
- [Don’t use color as the only visual means of conveying information](https://medium.com/salesforce-ux/7-things-every-designer-needs-to-know-about-accessibility-64f105f0881b#3829)
- [Accessible emoji](https://tink.uk/accessible-emoji/)
### Headings
- Headings should label ensuing sections of content. Statements and straplines are not headings.
- Use the correct heading levels, so that nested headings make sense.
- Use semantic heading elements rather than using styling alone.
- There should only be one heading of level 1 per page/screen.
#### Why?
Clear headings provide a mechanism for users to navigate your site.
Assistive technology users, such as screen reader users, use headings as a
way to navigate through a page or to comprehend a page quickly to work out
if this page contains the information they are looking for. Providing
headings also increases the readability of your site.
#### Further reading and examples
- [Web Accessibility Tutorial - Headings](https://www.w3.org/WAI/tutorials/page-structure/headings/)
- [Heading structures are tables of contents](https://hiddedevries.nl/en/blog/2018-09-01-heading-structures-are-tables-of-contents)
### Links
- Don't use the word link.
- Make links readable out of context.
#### Why?
Assistive Technology (AT) will read out an element as a link if the right
element has been used so there’s no need to use the word link. ATs usually
have a mechanism for listing out all links in a document so it’s helpful
to be able to read links out of context.
Users who access your site through a screen reader may search your page
for its links and even navigate a page by its links. Having links that
make sense by themselves will increase the ability to navigate your page.
#### Further reading and examples
- [Providing Context for Ambiguous Link Phrases](https://developer.paciellogroup.com/blog/2007/12/providing-context-for-ambiguous-link-phrases/)
### Images
- Make sure all images have appropriate alternative text.
- Decorative images should have no alternative text.
- Avoid using images of text where possible (this does not apply to logos).
#### Why?
Not all users will be able to see images for a number of reasons; they
might have a visual impairment, not be able to distinguish colours easily
or the image may not download correctly. Providing a description of the
what the image is ensures that all your users have an equal experience.
#### Further reading and examples
- [Alternative Text](https://webaim.org/techniques/alttext/)
### Input
Check out our [Input usage guidelines](/components/text-field) for an in-depth look at these.
---
# Theming
Learn how to create and customise a theme.
Section: guides
Docs: https://nebula.kaluza.com/guides/theme
---
## Create a theme
Creating your first theme is as simple as calling the `createTheme` function. It's ready to go out-of-the-box with the default theme. The next few sections will show you how to customise the theme to match your brand.
```tsx
import { createTheme } from '@kaluza-platform/nebula';
import { ThemeProvider } from 'styled-components';
const theme = createTheme({});
const MyApp = () => (
Welcome to my app!
);
```
## Customise a theme
Themes are built on a set of **core tokens** or [Foundations](/category/foundations). These tokens correspond to raw values, such as a font size or hex value.
```tsx
const myTheme = createTheme<{}>({
colors: {
primary: 'green',
primaryAlt: 'darkgreen',
},
fonts: {
body: 'Tahoma'
},
borderWidths: {
heavy: '2rem'
}
});
```
Our approach to theming is to set default values and then allow you to override the theme.
**Themes are combined with a deep merge**. This will update any dynamic tokens. When you add custom theme values you should also provide the typings.
```tsx
const myTheme = createTheme<{
fonts: {
superDisplay: string
},
borderRadius: {
full: string
}
}>({
fonts: {
superDisplay: 'Verdana'
},
borderRadius: {
full: '50%'
},
colors: {
primary: 'tomato'
}
});
```
It is also possible to extend the theme, adding your own variables.
```tsx
type AdditionalValues = {
somethingCustom: {
color: string;
},
}
const myTheme = createTheme({
colors: {
primary: 'green',
primaryAlt: 'darkgreen',
},
somethingCustom: {
color: 'tomato',
},
});
```
## CSS Reset
You will need to import the css reset in order for the theme to display components correctly. It will adjust the font size of the page to make it easier to work with rems. [Rem Units in CSS](https://www.sitepoint.com/understanding-and-using-rem-units-in-css/)
## Transitional configuration
The `config` block of the theme object contains properties used to manage the rollout of breaking changes. These are **transitional mechanisms only** — they exist to let apps adopt new behaviour on their own schedule and are not intended as permanent configuration options. All apps are expected to migrate to the default (enabled) state.
### `markRequiredFormFields`
Controls whether the new required field marking behaviour is active.
- `false` (default during rollout) - Existing behaviour: `optional` prop appends "(optional)" text; no asterisk is shown
- `true` - New behaviour: required fields display a red asterisk; `optional` suppresses it; `Form` renders the "Fields marked * are required" key
This applies consistently across every Nebula field component, including `RadioField`, `CheckboxField`, and `DateField`, not just `Field`-based components like `TextField` and `SelectField`.
```tsx
const theme = createTheme({
config: {
markRequiredFormFields: true,
},
});
```
When adopting this change:
1. Set `markRequiredFormFields: true` in your theme.
2. Audit your forms for fields that are genuinely optional and add `optional` to them.
3. Check forms that use field components outside the Nebula `Form` component - you will need to add the key text manually.
Once all teams have migrated, this property will be removed and the new behaviour will become the unconditional default.
### `accessibleBadge`
Controls whether the accessible badge styling is active.
- `false` (default during rollout) - Existing badge styles displayed
- `true` - New badge styles displayed (all existing badge styles map to one of the new styles
```tsx
const theme = createTheme({
config: {
accessibleBadge: true,
},
});
```
When adopting this change:
1. Set `accessibleBadge: true` in your theme.
2. Check badges render as expected - note that there is now only a single style per variant, and no outlined/filled distinction
Once all teams have migrated, this property will be removed and the new behaviour will become the unconditional default.
### `disableAutocomplete`
Controls whether browser autofill is disabled by default on form fields.
- `false` (default during rollout) - Existing behaviour: fields do not set `autocomplete`/`aria-autocomplete`, so browsers offer autofill suggestions as normal
- `true` - New behaviour: form fields default `autoComplete` to `"off"` to suppress browser autofill; a field's own `autoComplete` prop still overrides this
```tsx
const theme = createTheme({
config: {
disableAutocomplete: true,
},
});
```
When adopting this change:
1. Set `disableAutocomplete: true` in your theme.
2. Audit any screens where a field relies on browser autofill and add an explicit `autoComplete` prop to those fields.
3. Note that Chrome and Firefox deliberately ignore `autocomplete="off"` on password fields to preserve password-manager suggestions, so `PasswordInput`/`PasswordField` will still show that browser UI regardless of this setting.
Once all teams have migrated, this property will be removed and the new behaviour will become the unconditional default.