# CTA Button
Trigger actions on the current page.
Section: components
Docs: https://nebula.kaluza.com/components/cta-button
---
## When to use
- To trigger an action that happens on the current page, such as submitting a form, saving changes, or confirming a decision
- As the primary call to action in a section or page
- To confirm or cancel a destructive operation
## When not to use
- **To navigate to another URL** - use [CTA Link](/components/cta-link) instead. Buttons trigger actions; links go somewhere.
- **For inline text actions within a paragraph** - use [Text Link](/components/text-link) instead. A button in running text disrupts reading flow.
## Anatomy
1. **Button label**: descriptor of the action that will be performed when pressed, for example "Submit"
2. **Icon left**: icon to the left of the text label to improve understanding and functionality
3. **Icon right**: icon to the right of the text label to improve understanding and functionality
4. **Icon only**: icon used on the button without a visible text label
5. **Icon only text label**: accompanying text label visible on hover and focus
## Basic usage
```tsx
Plant a Tree
```
## Variants and features
### Primary
Use for the main action on a page or in a section. There should be at most one primary button per section.
```tsx
Plant a Tree
```
### Secondary
Use for supporting actions that sit alongside a primary button, such as "Cancel" next to "Save", or "Back" next to "Next".
```tsx
Tell me more about trees
```
### Tertiary
Use for low-emphasis actions that are available but not the focus of the page, such as an optional link to further reading or a secondary escape route alongside a primary and secondary button pair.
```tsx
Skip for now
```
### Destructive
Use when the action is irreversible, such as deleting an account, removing a payment method, or cancelling a service. This variant signals to the user that the action carries significant consequences.
Use the filled variant (`DestructiveCTAButton`) for a single high-prominence destructive action. Use the outlined variant (`DestructiveSecondaryCTAButton`) when the destructive action sits alongside another button. Use the low-emphasis variant (`DestructiveTertiaryCTAButton`) when the destructive action needs to be available but not visually dominant.
```tsx
Delete account
```
```tsx
Cancel service
```
```tsx
Remove payment method
```
### Icons
Add an icon to the left or right of the label to reinforce meaning. Use `iconLeft` for icons that categorise or contextualise the action, and `iconRight` for icons that indicate direction or continuation.
A right-pointing chevron (`iconRight="chevron-right"`) signals that the journey continues on the next step rather than completing. Use it consistently across multi-step flows.
```tsx
Next
```
```tsx
Download report
```
### Icon only
Use the `icon` prop to show an icon instead of a label, for compact contexts such as a table row or toolbar. The `children` become the button's tooltip and accessible name, so always provide them.
`icon` takes precedence over `iconLeft` and `iconRight`, which are ignored. It has no effect on the `primary` and `destructive` variants, so use a secondary or tertiary variant.
```tsx
Edit meter reading
Delete meter reading
```
### Full width
The `fullWidth` prop controls how the button sizes to its container:
- `'small'` (default): full width below the smallest breakpoint, auto width above
- `'always'`: full width at all breakpoints
- `'never'`: always auto width
```tsx
Save changes
```
### Small size
Use `size="small"` for compact contexts such as table rows, chips, or dense UI where the default button height (`space.9`) would be too large. The small variant uses `space.6` height.
```tsx
Save
```
### Loading state
Set `loading` to `true` when an async action is in progress. The button replaces its label with a spinner and becomes disabled automatically. Do not re-enable the button manually while it is in the loading state.
```tsx
Save
```
## Content guidance
Write button labels that describe exactly what happens when the button is pressed. Vague labels like "OK" or "Yes" make users pause to remember what they agreed to.
- Use sentence case: "Save changes", not "Save Changes"
- Use active language: start with a verb that describes the outcome ("Submit", "Download report", "Delete account")
- Each button on a page should have a unique label. If two buttons do the same thing in different contexts, add visually hidden text to distinguish them for screen reader users
- Avoid possessive pronouns: "Save account details" rather than "Save my account details"
- Use a right chevron icon to signal that the journey continues across steps, not that the action completes
Common patterns:
| Context | Label |
|---|---|
| Form submission | "Save changes", "Submit" |
| Multi-step flow | "Next", "Save & continue" |
| Destructive confirmation | "Delete account", "Cancel service" |
| Dismissal | "Cancel", "Go back" |
## Rules and guidance
### Usage
- **CTAButton** MUST only be used to trigger actions, not to navigate to another URL. Rationale: Buttons and links have distinct semantics. A button that navigates breaks screen reader expectations and violates WCAG 2.2 SC 4.1.2. [id: ctabutton.action-not-navigation; testability: manual]
- **CTAButton destructive variant** MUST only be used for actions that are irreversible or have significant consequences. Rationale: The destructive variant uses error colour semantics. Using it for ordinary actions desensitises users to genuine risk. [id: ctabutton.destructive-variant-reserved; testability: manual]
- **CTAButton** MUST NOT be used in place of a link when the action navigates to a URL. Rationale: Use CTALink instead. A button rendered as a link breaks keyboard navigation and screen reader announcement. [id: ctabutton.no-link-in-button; testability: manual]
- **CTAButton primary variant** SHOULD appear at most once per page section. Rationale: Multiple primary buttons compete for attention and dilute the call to action. [id: ctabutton.one-primary-per-section; testability: context]
### Content
- **CTAButton** MUST always include a visible text label. Rationale: Icon-only buttons lack accessible names and are ambiguous for all users. Use VisuallyHidden if a label must be hidden from sighted users. [id: ctabutton.icon-alone-no-label; testability: auto]
- **CTAButton label** MUST be unique within the page, or supplemented with visually hidden text to distinguish it. Rationale: Duplicate labels prevent screen reader users from distinguishing between buttons that perform different actions. [id: ctabutton.label-unique; testability: manual]
- **CTAButton label** SHOULD be written in sentence case. [id: ctabutton.label-sentence-case; testability: auto]
- **CTAButton label** SHOULD start with a verb that describes the outcome of pressing the button. Rationale: Active labels reduce hesitation and clarify the consequence of the action. [id: ctabutton.label-active-language; testability: manual]
- If used in a multi-step flow with a label like "Next" or "Save & continue": **CTAButton** SHOULD use iconRight="chevron-right" when the action advances to the next step of a multi-step flow. Rationale: A chevron signals continuation rather than completion, helping users understand they are still in a flow. [id: ctabutton.chevron-right-journey-continues; testability: manual]
- **CTAButton icon** SHOULD reinforce the label rather than repeat it or contradict it. Rationale: Icons used purely as decoration add noise. Icons that contradict the label create confusion. [id: ctabutton.icon-reinforces-label; testability: manual]
- **CTAButton label** SHOULD NOT use possessive pronouns such as "my" or "your". Rationale: Possessive pronouns add length without adding clarity. "Save account details" is clearer than "Save my account details". [id: ctabutton.label-no-possessive-pronouns; testability: auto]
### Interaction
- If icon prop is set and variant is not primary or destructive: **CTAButton** WILL wrap icon-only secondary, tertiary, and destructive-secondary/tertiary variants in a TooltipTrigger, using children as the tooltip content and accessible name. Rationale: The icon prop is only honoured for non-primary and non-destructive variants. The component wraps the button in TooltipTrigger automatically so no separate tooltip wiring is needed. [id: ctabutton.will-icon-only-tooltip; testability: auto]
- **CTAButton** WILL set disabled automatically when loading is true. The component passes disabled={disabled || loading} to the underlying button. [id: ctabutton.loading-disabled; testability: auto]
- **CTAButton** MUST NOT have its disabled state manually toggled while loading is true. Rationale: The loading prop sets disabled automatically. Overriding this can produce inconsistent visual and accessible states. [id: ctabutton.loading-no-manual-disable; testability: auto]
- **CTAButton** SHOULD retain the same label prop when loading is true. Rationale: The label is replaced visually by the spinner, but it remains the accessible name. Changing it mid-flight can confuse screen reader users. [id: ctabutton.loading-label-unchanged; testability: manual]
## Accessibility
### In brief
CTA Button is a native `button`. A screen reader announces its label and the role, button. An icon only button is named by its tooltip.
### What Nebula provides
- The browser's own `button`: its role, `Enter` and `Space` activation and focus.
- A two-colour focus ring when the button has keyboard focus.
- Icons beside the label are hidden from screen readers. On an icon only button the `children` become the tooltip and the accessible name, and the tooltip opens on hover and focus and closes with `Esc`.
- While `loading`, the button is natively `disabled` and a screen reader reads "Loading" in place of the label.
### Keyboard behaviour
The button is reached with `Tab` and activated with `Enter` or `Space`.
#### Interactions
- **Tab** - Moves focus to the button
- **Enter or Space** - Activates the button
### Accessible design
#### Give each button on a page its own label
A screen reader user moving from button to button hears two "Edit" buttons as the same thing. Where the visible label has to repeat, as in a table, add the context after it as hidden text: "Edit" plus "billing address", for example with [Visually Hidden](/components/visually-hidden/).
WCAG: [2.4.6 Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) [2.5.3 Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html)
#### Name the action and what it acts on in a destructive button
Red is the only other signal, and not everyone sees it. On a tertiary destructive or an icon only button the words are all there is: "Delete row", not "Delete".
WCAG: [1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html)
### Accessible development
#### Always pass `children` to an icon only button
The `children` are both the tooltip and the accessible name, so without them the button has no name and a screen reader announces only "button". Write them as the action, for example "More actions", so a speech input user can say what the tooltip shows.
WCAG: [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) [2.5.3 Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html)
#### Don't add `aria-label` to a button with a visible label
The visible text is already the name. A different `aria-label` breaks speech input, and a matching one adds nothing.
WCAG: [2.5.3 Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html)
#### If a button in a form does not submit it, set `type="button"`
CTA Button sets no `type`, so inside a form every button submits it. A "Cancel" or "Add row" button then sends the form.
#### If a user needs to find a button that is unavailable, prefer `aria-disabled` to `disabled`
Native `disabled` takes the button out of the tab order, so a keyboard user cannot reach it to learn why it is unavailable. Block the action in the handler instead. Nebula has no style for `aria-disabled`, so show the unavailable state yourself.
### 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
### CTAButton
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
- `iconLeft` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show before the label. Use `'off'` for no icon.
- `iconRight` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show after the label. Use `'off'` for no icon.
- `loading` (type `boolean`, optional, default `false`). Shows a loading indicator in place of the content and disables the button.
- `fullWidth` (type `'always' | 'never' | 'small'`, optional, default `'small'`). When the button fills the width of its container. `'small'` means full width on small screens only. Ignored for icon-only buttons.
- `variant` (type `'primary' | 'secondary' | 'tertiary' | 'destructive' | 'destructive-secondary' | 'destructive-tertiary'`, optional, default `'primary'`). The visual style of the button.
- `icon` (type `IconName | (string & {})`, optional). Name of an icon to show instead of the label, making an icon-only button. The `children` become its tooltip and accessible name. Takes precedence over `iconLeft` and `iconRight`, which are ignored. Ignored for the `primary` and `destructive` variants.
- `size` (type `'default' | 'small'`, optional, default `'default'`). The size of the button.
- `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.
### PrimaryCTAButton
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
Always sets `variant` to `'primary'` (passing one has no effect).
- `iconLeft` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show before the label. Use `'off'` for no icon.
- `iconRight` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show after the label. Use `'off'` for no icon.
- `loading` (type `boolean`, optional, default `false`). Shows a loading indicator in place of the content and disables the button.
- `fullWidth` (type `'always' | 'never' | 'small'`, optional, default `'small'`). When the button fills the width of its container. `'small'` means full width on small screens only. Ignored for icon-only buttons.
- `icon` (type `IconName | (string & {})`, optional). Name of an icon to show instead of the label, making an icon-only button. The `children` become its tooltip and accessible name. Takes precedence over `iconLeft` and `iconRight`, which are ignored. Ignored for the `primary` and `destructive` variants.
- `size` (type `'default' | 'small'`, optional, default `'default'`). The size of the button.
- `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.
### SecondaryCTAButton
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
Always sets `variant` to `'secondary'` (passing one has no effect).
- `iconLeft` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show before the label. Use `'off'` for no icon.
- `iconRight` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show after the label. Use `'off'` for no icon.
- `loading` (type `boolean`, optional, default `false`). Shows a loading indicator in place of the content and disables the button.
- `fullWidth` (type `'always' | 'never' | 'small'`, optional, default `'small'`). When the button fills the width of its container. `'small'` means full width on small screens only. Ignored for icon-only buttons.
- `icon` (type `IconName | (string & {})`, optional). Name of an icon to show instead of the label, making an icon-only button. The `children` become its tooltip and accessible name. Takes precedence over `iconLeft` and `iconRight`, which are ignored. Ignored for the `primary` and `destructive` variants.
- `size` (type `'default' | 'small'`, optional, default `'default'`). The size of the button.
- `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.
### TertiaryCTAButton
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
Always sets `variant` to `'tertiary'` (passing one has no effect).
- `iconLeft` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show before the label. Use `'off'` for no icon.
- `iconRight` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show after the label. Use `'off'` for no icon.
- `loading` (type `boolean`, optional, default `false`). Shows a loading indicator in place of the content and disables the button.
- `fullWidth` (type `'always' | 'never' | 'small'`, optional, default `'small'`). When the button fills the width of its container. `'small'` means full width on small screens only. Ignored for icon-only buttons.
- `icon` (type `IconName | (string & {})`, optional). Name of an icon to show instead of the label, making an icon-only button. The `children` become its tooltip and accessible name. Takes precedence over `iconLeft` and `iconRight`, which are ignored. Ignored for the `primary` and `destructive` variants.
- `size` (type `'default' | 'small'`, optional, default `'default'`). The size of the button.
- `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.
### DestructiveCTAButton
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
Always sets `variant` to `'destructive'` (passing one has no effect).
- `iconLeft` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show before the label. Use `'off'` for no icon.
- `iconRight` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show after the label. Use `'off'` for no icon.
- `loading` (type `boolean`, optional, default `false`). Shows a loading indicator in place of the content and disables the button.
- `fullWidth` (type `'always' | 'never' | 'small'`, optional, default `'small'`). When the button fills the width of its container. `'small'` means full width on small screens only. Ignored for icon-only buttons.
- `icon` (type `IconName | (string & {})`, optional). Name of an icon to show instead of the label, making an icon-only button. The `children` become its tooltip and accessible name. Takes precedence over `iconLeft` and `iconRight`, which are ignored. Ignored for the `primary` and `destructive` variants.
- `size` (type `'default' | 'small'`, optional, default `'default'`). The size of the button.
- `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.
### DestructiveSecondaryCTAButton
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
Always sets `variant` to `'destructive-secondary'` (passing one has no effect).
- `iconLeft` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show before the label. Use `'off'` for no icon.
- `iconRight` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show after the label. Use `'off'` for no icon.
- `loading` (type `boolean`, optional, default `false`). Shows a loading indicator in place of the content and disables the button.
- `fullWidth` (type `'always' | 'never' | 'small'`, optional, default `'small'`). When the button fills the width of its container. `'small'` means full width on small screens only. Ignored for icon-only buttons.
- `icon` (type `IconName | (string & {})`, optional). Name of an icon to show instead of the label, making an icon-only button. The `children` become its tooltip and accessible name. Takes precedence over `iconLeft` and `iconRight`, which are ignored. Ignored for the `primary` and `destructive` variants.
- `size` (type `'default' | 'small'`, optional, default `'default'`). The size of the button.
- `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.
### DestructiveTertiaryCTAButton
Also accepts all props from `ButtonHTMLAttributes`, `HTMLAttributes`.
Always sets `variant` to `'destructive-tertiary'` (passing one has no effect).
- `iconLeft` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show before the label. Use `'off'` for no icon.
- `iconRight` (type `IconName | 'off' | (string & {})`, optional, default `'off'`). Name of an icon to show after the label. Use `'off'` for no icon.
- `loading` (type `boolean`, optional, default `false`). Shows a loading indicator in place of the content and disables the button.
- `fullWidth` (type `'always' | 'never' | 'small'`, optional, default `'small'`). When the button fills the width of its container. `'small'` means full width on small screens only. Ignored for icon-only buttons.
- `icon` (type `IconName | (string & {})`, optional). Name of an icon to show instead of the label, making an icon-only button. The `children` become its tooltip and accessible name. Takes precedence over `iconLeft` and `iconRight`, which are ignored. Ignored for the `primary` and `destructive` variants.
- `size` (type `'default' | 'small'`, optional, default `'default'`). The size of the button.
- `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.
### adjusted-icon
```css
@when side === 'only' {
margin: 0;
flex-shrink: 0;
}
@when side !== 'only' && side === 'left' && name === 'chevron-left' {
margin-left: -${space[1]};
flex-shrink: 0;
}
@when side !== 'only' && !(side === 'left' && name === 'chevron-left') && side === 'right' && name === 'chevron-right' {
margin-right: -${space[1]};
flex-shrink: 0;
}
```
### cta-button-base
```css
font-size: ${responsiveFontSizes.body[0]};
font-weight: ${fontWeights.bold};
font-family: ${fonts.display};
line-height: ${lineHeights.body};
padding: ${$size === 'small' ? '0.2rem' : space[2]} ${$hasIconRight ? iconEdgePaddingInline : edgePaddingInline} ${$size === 'small' ? '0.2rem' : space[2]} ${$hasIconLeft ? iconEdgePaddingInline : edgePaddingInline};
border-radius: ${radii.standard};
transition: ${transitions.standard};
border-width: ${borderWidths.standard};
min-height: ${$size === 'small' ? space[6] : space[9]};
@when $iconOnly {
height: ${$size === 'small' ? space[6] : space[9]};
padding: 0;
}
text-align: left;
display: inline-flex;
justify-content: center;
align-items: center;
vertical-align: top;
text-decoration: none;
border-style: solid;
background: ${currentVariant.background};
border-color: ${currentVariant.border};
color: ${currentVariant.foreground};
width: ${$iconOnly ? `${minHeight}` : $fullWidth === 'never' ? 'auto' : '100%'};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
@when !$iconOnly && $fullWidth === 'small' {
width: auto;
}
}
&:focus-visible {
${focusRing}
}
&:not(:disabled) {
cursor: pointer;
}
path {
fill: ${currentVariant.foreground};
}
svg {
pointer-events: none;
vertical-align: middle;
align-self: center;
margin-top: -0.2rem;
margin-bottom: -0.2rem;
&:first-child {
margin-right: ${$size === 'small' ? space[1] : space[2]};
}
&:last-child {
margin-left: ${$size === 'small' ? space[1] : space[2]};
}
}
&:hover {
background: ${currentVariant.backgroundHover};
border-color: ${currentVariant.borderHover};
color: ${currentVariant.foregroundHover};
path {
fill: ${currentVariant.foregroundHover};
}
}
```