---
title: FAB
description: A floating action button that fans out into secondary actions.
category: Overlays
related:
  - /components/bottom-nav
  - /components/dropdown
  - /components/button
visibility: guest
draft: false
---

# FAB

**FAB** stands for "floating action button" — the round button that hovers over
the content in a screen corner, holding the one thing this screen is really for.
The pattern comes from mobile design systems, where the primary action needs to
stay reachable no matter how far the page has scrolled.

WireKit ships two of them. `<x-wirekit::fab>` is the speed dial: a floating
trigger that expands into a small stack of secondary actions.
`<x-wirekit::fab.button>` is the single-action version — one tap, no menu.

:::preview{title="A speed dial with three actions"}
<div style="position: relative; height: 14rem; width: 100%;">
    <x-wirekit::fab label="Create" style="position: absolute; inset-block-end: 1rem;">
        <x-wirekit::fab.action label="New note" icon="edit" />
        <x-wirekit::fab.action label="Upload file" icon="upload" />
        <x-wirekit::fab.action label="Invite someone" icon="users" />
    </x-wirekit::fab>
</div>
:::

:::source{language="blade"}
<x-wirekit::fab label="Create">
    <x-wirekit::fab.action label="New note" icon="edit" />
    <x-wirekit::fab.action label="Upload file" icon="upload" />
    <x-wirekit::fab.action label="Invite someone" icon="users" />
</x-wirekit::fab>
:::

The preview sits in a `position: relative` frame so the fixed button is contained
in the box — in your app you just drop in `<x-wirekit::fab>` and it pins to the
viewport corner (see the Show Code above, which omits the demo frame).

## Usage

```blade
<x-wirekit::fab label="Create">
    <x-wirekit::fab.action label="New note" icon="edit" />
    <x-wirekit::fab.action label="Upload file" icon="upload" />
    <x-wirekit::fab.action label="Docs" icon="book-open" href="/docs" />
</x-wirekit::fab>
```

Every action needs a `label`. The buttons are icons, so the label is their whole
name — without one a screen reader announces "button" three times.

An action with an `href` renders as a link, and one without renders as a button. A
thing that navigates is a link; a thing that acts is a button. Getting that wrong
costs middle-click, "open in new tab", and the status bar.

## Single action

Not every floating button fans out. When there is exactly ONE action — open a
feedback modal, start a new message — reach for `<x-wirekit::fab.button>` instead of
the speed dial. It is a plain button (or a link, with `href`): one tap, no menu, and
it announces its target honestly through `aria-haspopup` (default `dialog`) rather
than promising a menu that is really a dialog.

:::preview{title="A single-action FAB that opens a dialog"}
<div style="position: relative; height: 11rem; width: 100%;">
    <x-wirekit::modal name="fab-feedback">
        <x-slot:trigger>
            <x-wirekit::fab.button label="Send feedback" icon="message" style="position: absolute; inset-block-end: 1rem;" />
        </x-slot:trigger>
        <x-wirekit::modal.header>Send feedback</x-wirekit::modal.header>
        <x-wirekit::modal.body>
            <x-wirekit::textarea name="feedback" label="What went wrong?" rows="3" />
        </x-wirekit::modal.body>
        <x-wirekit::modal.footer>
            <x-wirekit::modal.close>
                <x-wirekit::button intent="neutral" surface="ghost" size="sm">Cancel</x-wirekit::button>
            </x-wirekit::modal.close>
            <x-wirekit::button size="sm">Send</x-wirekit::button>
        </x-wirekit::modal.footer>
    </x-wirekit::modal>
</div>
:::

:::source{language="blade"}
<x-wirekit::modal name="feedback">
    <x-slot:trigger>
        <x-wirekit::fab.button label="Send feedback" icon="message" />
    </x-slot:trigger>
    <x-wirekit::modal.header>Send feedback</x-wirekit::modal.header>
    <x-wirekit::modal.body>
        <x-wirekit::textarea name="feedback" label="What went wrong?" rows="3" />
    </x-wirekit::modal.body>
    <x-wirekit::modal.footer>
        <x-wirekit::modal.close>
            <x-wirekit::button intent="neutral" surface="ghost" size="sm">Cancel</x-wirekit::button>
        </x-wirekit::modal.close>
        <x-wirekit::button size="sm">Send</x-wirekit::button>
    </x-wirekit::modal.footer>
</x-wirekit::modal>
:::

The button really does open the dialog above — click it. That matters more than it
sounds: `aria-haspopup="dialog"` is a promise made to assistive technology, and a
button that announces a dialog and opens nothing tells a screen-reader user
something untrue. The attribute describes behavior; it does not create it.

Pass `haspopup="menu"` / `"listbox"` / `"true"` when the button opens something other
than a dialog, or a falsy value when it just navigates or fires an event. With `href`
it renders an `<a>` and auto-injects `rel="noopener noreferrer"` for `target="_blank"`.
It shares the speed dial's corner positioning, including the safe-area insets that
keep it clear of the iOS home indicator and a landscape notch.

## Sitting above a bottom nav

Both are fixed, so a FAB on a page with a [bottom nav](/components/bottom-nav)
lands on top of it. Add `wk-fab-above-nav`:

```blade
<x-wirekit::fab label="Create" class="wk-fab-above-nav">…</x-wirekit::fab>
```

Opt-in, because this component cannot see whether a nav exists elsewhere in the
document — and guessing would lift every FAB off the corner it belongs in. The lift
disappears at `md` along with the nav; add `wk-fab-above-nav-always` if you kept
the nav on desktop.

## Position

`position` follows the writing direction rather than assuming everyone reads left
to right:

| `position` | Where |
|------------|-------|
| `end` | The inline-end corner (default — bottom right in English) |
| `start` | The inline-start corner |
| `center` | Centered along the bottom |

`placement` is the other axis, and the two combine into all four corners. The
single-action `<x-wirekit::fab.button>` accepts it; the default `block-end` is
the familiar bottom corner.

| `placement` | Where |
|-------------|-------|
| `block-end` | Along the bottom edge (default) |
| `block-start` | Along the top edge |

```blade
<x-wirekit::fab.button placement="block-start" position="start" icon="plus" label="Add" />
```

Both edges keep clear of the hardware: the bottom offset folds in the iOS home
indicator, the top one the status bar and notch.

## The name a screen reader reads

A FAB with an icon and nothing else needs `label` — the icon is decorative, so
the label is the entire accessible name.

A FAB with words in it does not. The visible text becomes the accessible name,
and `label` is left out of the markup:

```blade
{{-- Announced as "Send feedback", the same words that are on screen --}}
<x-wirekit::fab.button>Send feedback</x-wirekit::fab.button>
```

That is [WCAG 2.5.3, Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html):
someone using voice control activates a control by the words they can see, so a
button reading *Send feedback* has to answer to *Send feedback*.

A `label` alongside visible text is kept when it **contains** that text — useful
for adding context (`Send feedback about this page`). One that contradicts it is
dropped rather than shipped, because it would break exactly the voice-control
case above.

A slot holding markup rather than words — a custom icon — still needs `label`:
there is nothing in it to read.

## Action names are visible, too

An icon-only action tells a screen reader what it does through its `label`. That
label is also shown beside the button when you hover it or reach it with the
keyboard — otherwise anyone looking at the screen sees a column of circles and has
to press one to find out what it is.

The name appears on focus as well as hover, so it is not a mouse-only affordance.
It is positioned so revealing it never shifts the buttons, and it cannot intercept
a click meant for the action beneath it.

An action that fills its own slot instead of using `label` shows nothing extra —
its markup already says what it is.

### Turning the visible name off

Pass `hideLabel` when you want the bare circles — the icon-only speed dial the
pattern started as. It takes the name off the SCREEN only: `aria-label` stays, so
a screen reader still announces the action.

Open both and hover an action to see the difference:

:::preview{title="With names on hover (default) and with hideLabel"}
<div style="position: relative; height: 15rem; width: 100%; contain: layout;">
    <div style="position: absolute; inset-block-end: 1rem; inset-inline-start: 1rem;">
        <x-wirekit::fab label="Labeled actions" position="start">
            <x-wirekit::fab.action icon="edit" label="Write a post" />
            <x-wirekit::fab.action icon="image" label="Upload an image" />
            <x-wirekit::fab.action icon="users" label="Invite someone" />
        </x-wirekit::fab>
    </div>
    <div style="position: absolute; inset-block-end: 1rem; inset-inline-end: 1rem;">
        <x-wirekit::fab label="Bare actions">
            <x-wirekit::fab.action icon="edit" label="Write a post" hideLabel />
            <x-wirekit::fab.action icon="image" label="Upload an image" hideLabel />
            <x-wirekit::fab.action icon="users" label="Invite someone" hideLabel />
        </x-wirekit::fab>
    </div>
</div>
:::

:::source{language="blade"}
{{-- Names appear on hover and on keyboard focus --}}
<x-wirekit::fab label="Labeled actions">
    <x-wirekit::fab.action icon="edit" label="Write a post" />
    <x-wirekit::fab.action icon="image" label="Upload an image" />
</x-wirekit::fab>

{{-- Bare circles. The accessible name is unchanged. --}}
<x-wirekit::fab label="Bare actions">
    <x-wirekit::fab.action icon="edit" label="Write a post" hideLabel />
    <x-wirekit::fab.action icon="image" label="Upload an image" hideLabel />
</x-wirekit::fab>
:::

`label` stays required either way. Hiding a name is a visual choice; not having
one is a defect, and the component says so during development.

## Props

### `<x-wirekit::fab>`

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | `'Actions'` | Names the trigger and the menu. The trigger is an icon, so this is all a screen reader gets. |
| `position` | `string` | `'end'` | `end`, `start`, `center`. |
| `scope` | `string\|null` | `null` | Class-scope override. |

### `<x-wirekit::fab.action>`

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | `''` | The action's name. Announced to screen readers, and shown beside the icon on hover and on keyboard focus. |
| `hideLabel` | `bool` | `false` | Keep the accessible name but take it off the screen — the bare icon-only speed dial. |
| `icon` | `string\|null` | `null` | Icon name. Falls back to the slot. |
| `href` | `string\|null` | `null` | Renders a link instead of a button. |
| `scope` | `string\|null` | `null` | Class-scope override. |

### `<x-wirekit::fab.button>`

The single-action FAB — a plain button, no menu.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string\|null` | `null` | The accessible name when the button carries no words of its own. Falls back to `Action`. Ignored when the button shows text, unless it contains that text — see [The name a screen reader reads](#the-name-a-screen-reader-reads). |
| `position` | `string` | `'end'` | `end`, `start`, `center` — the inline corner, shared with the speed dial. |
| `placement` | `string` | `'block-end'` | `block-end` (bottom) or `block-start` (top). Combines with `position`, so all four corners are reachable. |
| `haspopup` | `string\|bool` | `'dialog'` | What it opens, for `aria-haspopup`. A falsy value omits the attribute. |
| `href` | `string\|null` | `null` | Renders an `<a>` instead of a `<button>`. |
| `icon` | `string\|null` | `null` | Icon name. A slot overrides it; a plus is the fallback. |
| `scope` | `string\|null` | `null` | Class-scope override. |

## Slots

| Slot | Description |
|------|-------------|
| `trigger` | Replaces the default plus icon. |
| default | The actions. |

## Accessibility

The interesting part of a speed dial is not the fan — it is what happens to focus.
A menu that opens and leaves focus on the trigger is a menu a keyboard user cannot
reach; one that traps focus is a menu they cannot leave. This does neither:

- Opening moves focus to the **first action**.
- <kbd>↑</kbd> / <kbd>↓</kbd> walk the actions and wrap.
- <kbd>Esc</kbd> closes and hands focus **back to the trigger** — where the reader
  was — but only if focus was inside the menu. A stray Escape from elsewhere on the
  page does not yank it.
- The trigger's `aria-haspopup="menu"` and the panel's `role="menu"` agree, so a
  screen reader is not promised a popup whose contents are something else.
- Actions are 44px, the touch minimum, and are the smallest targets on the screen.
- The plus/close icons both stay in the DOM so they can cross-fade, so the inactive
  one is hidden from assistive tech — otherwise the trigger announces both states.
- The rotation is dropped under `prefers-reduced-motion`.

## Keyboard Interaction

| Key | Action |
|-----|--------|
| <kbd>Tab</kbd> | Move focus to the trigger. |
| <kbd>Enter</kbd> / <kbd>Space</kbd> | Open the actions and focus the first. |
| <kbd>↑</kbd> <kbd>↓</kbd> | Move between actions. |
| <kbd>Esc</kbd> | Close and return focus to the trigger. |
