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.
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
<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.
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
lands on top of it. Add wk-fab-above-nav:
<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 |
<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:
{{-- 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: 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:
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. |
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.
- ↑ / ↓ walk the actions and wrap.
- Esc 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'srole="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 |
|---|---|
| Tab | Move focus to the trigger. |
| Enter / Space | Open the actions and focus the first. |
| ↑ ↓ | Move between actions. |
| Esc | Close and return focus to the trigger. |