
# Button

**A native button with a loading state and a disabled state that can keep focus.**

**Reference:** [View Source](https://github.com/jramke/fluid-primitives/tree/main/Resources/Private/Primitives/Button)

**ButtonExamples.html**

```html
<ui:button>Save changes</ui:button>

```

## Features

- Renders a native `<button>` and sets `type="button"` by default, so a button inside a form never submits it by accident - pass `type="submit"` for the submit button
- `disabled` and `loading` states, a loading button is disabled too
- `focusableWhenDisabled` keeps a disabled button in the tab order, so focus is not lost when it gets disabled
- `indicator` parts show and hide content per state, for loading spinners and text changes
- Works as the `asChild` target of triggers like `ui:collapsible.trigger` and as the building block of your own components

## Anatomy

Unlike most components the button has no `root` part. It is a single element with optional parts, so the folder-named file is its root (`<ui:button>`), see [Root or the Folder Name](/docs/core-concepts/file-structure.md#content-root-or-the-folder-name).

```html
<primitives:button>
    <primitives:button.indicator state="idle" />
    <primitives:button.indicator state="loading" />
    <primitives:button.indicator state="disabled" />
</primitives:button>
```

## Installation

```bash
typo3 ui:add button
```

Or copy the files manually from GitHub (https://github.com/jramke/fluid-primitives/tree/main/Resources/Private/Registry/Button) into your project.

Read more about installing [Components and Primitives](/docs/core-concepts/primitives.md).

## Examples

### Disabled

A disabled button gets the native `disabled` attribute. Browsers take it out of the tab order, and a focused button loses focus the moment it is disabled.

**ButtonExamples.html**

```html
<ui:button disabled="{true}">Save changes</ui:button>

```

### Focusable When Disabled

With `focusableWhenDisabled` the button gets `aria-disabled="true"` instead of the `disabled` attribute and stays focusable. Clicks and the Enter and Space keys are ignored in the capture phase, before any other listener of the button runs, so it still never submits a form or opens the dialog it triggers. Press <kbd>Tab</kbd> to compare the two buttons below.

**ButtonExamples.html**

```html
<div class="flex flex-wrap gap-3">
    <ui:button disabled="{true}">Not focusable</ui:button>
    <ui:button disabled="{true}" focusableWhenDisabled="{true}">Focusable</ui:button>
</div>

```

### Loading States

For a button that starts loading after it was activated, set `focusableWhenDisabled` so focus stays on it while it is disabled.

Some browser and screen reader combinations do not reliably announce a change of the text inside a focused button. Make the changing text the explicit accessible name instead: wrap the indicators in an element with an `id` and point `aria-labelledby` at it.

Each `indicator` is only visible in its `state`: `idle`, `loading` or `disabled`. `loading` wins over `disabled`, and a state without an indicator renders nothing, so add one for every state the button can be in. Decorative content like a spinner needs `aria-hidden="true"`.

The server renders the state, and on the client you change it with `api.setLoading()` and `api.setDisabled()`. Mount the button yourself with `autoMount="{false}"` and a `rootId`, as below. `ui:button` only loads its own auto-mounting entry file for a button with `focusableWhenDisabled`, because a plain button needs no JavaScript:

**ButtonExamples.html**

```html
<f:variable name="labelId" value="{ui:id()}" />

<ui:button
    autoMount="{false}"
    rootId="loading-button"
    focusableWhenDisabled="{true}"
    aria-labelledby="{labelId}"
    class="min-w-32">
    <ui:button.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\ButtonIndicatorState::Loading')}">
        <svg class="animate-spin" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
            <path d="M21 12a9 9 0 1 1-6.219-8.56" />
        </svg>
    </ui:button.indicator>
    <span id="{labelId}">
        <ui:button.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\ButtonIndicatorState::Idle')}">Submit</ui:button.indicator>
        <ui:button.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\ButtonIndicatorState::Loading')}">Submitting</ui:button.indicator>
    </span>
</ui:button>

<vite:asset entry="EXT:docs/Resources/Private/Components/ui/ButtonExamples/Loading.entry.ts" />

```

**Loading.entry.ts**

```ts
import { mount } from 'fluid-primitives';
import { Button } from 'fluid-primitives/button';

mount('ui:button', 'loading-button', ({ props }) => {
    const button = new Button(props);
    button.init();

    // A click while the button is loading never reaches this listener.
    button.api.addEventListener('click', () => {
        button.api.setLoading(true);
        setTimeout(() => button.api.setLoading(false), 4000);
    });

    return button;
});

```

For a submit button of a form, pass `form.api.isSubmitting` as `loading`, see [Forms](/docs/core-concepts/forms.md#content-submit-button).

### As a Trigger

The button renders a plain `<button>` and does not set `data-state` or an `id`, so a trigger can merge its own attributes onto it with `asChild`: `aria-expanded` and `data-state` come from the collapsible, `type` and the disabled handling from the button.

**ButtonExamples.html**

```html
<ui:collapsible.root class="w-64 text-sm">
    <ui:collapsible.trigger asChild="{true}">
        <ui:button variant="secondary">
            <ui:collapsible.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\CollapsibleIndicatorState::Closed')}">Show details</ui:collapsible.indicator>
            <ui:collapsible.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\CollapsibleIndicatorState::Open')}">Hide details</ui:collapsible.indicator>
        </ui:button>
    </ui:collapsible.trigger>
    <ui:collapsible.content>
        <p class="mt-2 rounded-md bg-accent px-3 py-2">The button keeps its own behavior and gets the attributes of the trigger on top.</p>
    </ui:collapsible.content>
</ui:collapsible.root>

```

### Custom Components

The button is meant to be a part of larger components of your own. Put it into the template, give it a `rootId` derived from your component's, and mount it from your component's entry file. The component then drives the button from its own logic, here a backup that runs for a moment.

**ButtonExamples.html**

```html
<ui:prop name="lastBackup" type="string" default="2 days ago" />

<f:variable name="labelId" value="{ui:id()}" />

<div class="flex w-84 items-center justify-between gap-4 rounded-lg border p-4 text-sm">
    <div>
        <p class="font-medium">Backup</p>
        <p class="text-muted-foreground" {ui:ref(name: 'status' )}>Last backup {lastBackup}</p>
    </div>

    <ui:button
        focusableWhenDisabled="{true}"
        aria-labelledby="{labelId}"
        autoMount="{false}"
        rootId="{rootId}-action">
        <ui:button.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\ButtonIndicatorState::Loading')}">
            <svg class="animate-spin" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
                <path d="M21 12a9 9 0 1 1-6.219-8.56" />
            </svg>
        </ui:button.indicator>
        <span id="{labelId}">
            <ui:button.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\ButtonIndicatorState::Idle')}">Back up now</ui:button.indicator>
            <ui:button.indicator state="{f:constant(name: 'Jramke\FluidPrimitives\Enum\ButtonIndicatorState::Loading')}">Backing up</ui:button.indicator>
        </span>
    </ui:button>
</div>

<vite:asset entry="EXT:docs/Resources/Private/Components/ui/ButtonExamples/BackupCard.entry.ts" />
```

**BackupCard.entry.ts**

```ts
import { mount, mountAll } from 'fluid-primitives';
import { Button } from 'fluid-primitives/button';

mountAll('ui:buttonExamples.backupCard', ({ props, createHydrator }) => {
    const h = createHydrator();
    const statusEl = h.query('status');

    const button = mount('ui:button', `${props.id}-action`, ({ props }) => {
        const button = new Button(props);
        button.init();
        return button;
    });

    button?.api.addEventListener('click', async () => {
        button.api.setLoading(true);
        await new Promise(resolve => setTimeout(resolve, 2000));
        button.api.setLoading(false);
        if (statusEl) statusEl.textContent = 'Last backup just now';
    });
});

```

## API Reference

### button

The button. Renders a `<button>` element.

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `type` | `string` | No | `'button'` | The `type` of the button. It defaults to `button`, so a button inside a form does not submit it. Use `submit` or `reset` to opt in. |
| `disabled` | `boolean` | No | `false` | Whether the button is disabled. |
| `loading` | `boolean` | No | `false` | Whether the button is loading. A loading button is disabled too. |
| `focusableWhenDisabled` | `boolean` | No | `false` | Keeps a disabled button focusable. It then gets `aria-disabled` instead of the `disabled` attribute, and clicks and key presses are ignored. |
| `attributes` | `array` | No | `[]` | Additional attributes that should be rendered on the component where ui:attributes is used. |
| `asChild` | `boolean` | No | `-` | If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. |
| `rootId` | `string` | No | `-` | The root ID of the component, used for hydration and identification. |
| `ids` | `array` | No | `[]` | The IDs of of the component parts for composition. |
| `autoMount` | `boolean` | No | `true` | Whether the client initializes the component on its own. Set it to false to mount it yourself with `mount()` |
| `class` | `string` | No | `-` | The CSS class(es) to be applied to the component. |

#### Rendered data attributes

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled |
| `data-loading` | Present while the button is loading. |

### button.indicator

Shows its content only while the button is in its `state`. Renders a `<span>` element.

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `state` | `Enum\ButtonIndicatorState` | Yes | `-` | The state of the button this indicator is shown in. |
| `attributes` | `array` | No | `[]` | Additional attributes that should be rendered on the component where ui:attributes is used. |
| `asChild` | `boolean` | No | `-` | If true the component uses its child only without the component template. Like Radix UI asChild or Base UI render props. |
| `class` | `string` | No | `-` | The CSS class(es) to be applied to the component. |

### Machine options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | `-` | The unique identifier of the button. |
| `ids` | `Record<string, string> \| undefined` | `-` | The ids of the parts other elements reference: `root`. |
| `disabled` | `boolean \| undefined` | `-` | Whether the button is disabled. |
| `loading` | `boolean \| undefined` | `-` | Whether the button is loading. A loading button is disabled too. |
| `focusableWhenDisabled` | `boolean \| undefined` | `-` | Keeps a disabled button focusable. It then gets `aria-disabled` instead of the `disabled` attribute, and clicks and key presses are ignored. |

### Machine JavaScript API

| Name | Type | Description |
| --- | --- | --- |
| `state` | `ButtonState` | The state of the button, which decides the indicators that are shown. |
| `disabled` | `boolean` | Whether the button is disabled, either on its own or because it is loading. |
| `loading` | `boolean` | Whether the button is loading. |
| `setDisabled` | `(value: boolean) => void` | Disables or enables the button. A loading button stays disabled. |
| `setLoading` | `(value: boolean) => void` | Starts or stops loading. A loading button is disabled too. |
| `addEventListener` | `(...args: Parameters<HTMLButtonElement['addEventListener']>) => void` | Adds an event listener to the button element, the same as `addEventListener` of the element. |
