# 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}; } } ```