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:: is the speed dial: a floating
trigger that expands into a small stack of secondary actions.
<x-wirekit:: 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:: 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:: 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.
Clearing a surface you measure yourself
A bottom nav has a fixed height, which is why the modifier above can carry a fixed
number. A consent banner, a bottom sheet or an editing bar does not: only your page
knows how tall it is, and it changes with the text inside it. Set --wk-fab-lift
while that surface is up, and the button rises by exactly that much:
const banner = document.querySelector('[data-consent-banner]');
document.documentElement.style.setProperty('--wk-fab-lift', `${banner.offsetHeight}px`);
// …and when the reader dismisses it
document.documentElement.style.removeProperty('--wk-fab-lift');
The default is 0px, so a page that never sets it renders exactly as it did. The value
ADDS to the corner clearance and to wk-fab-above-nav, so a page that has a bottom nav
and a banner gets both lifts rather than the larger one. Set it anywhere above the
button — on the root element as here, or on the element the FAB sits in.
This is not polish. WCAG 2.2 Focus Not Obscured (Minimum) names a cookie banner as its example of sticky content that fails the criterion when it hides a component receiving focus, and a FAB under a banner is exactly that.
Keeping content out from under it
The section above moves the BUTTON out of something else's way. This is the other direction, and it is the one that bites: a FAB is fixed over whatever scrolls beneath it, so when a long list reaches its end, the last row is behind the button.
Keyboard focus is handled for you. The button marks the band it covers, so a control a reader tabs to comes to rest above it rather than under it (keeping the focus clear of a pinned bar). The clearance below is for the end of the page, where nothing is left to scroll.
Put wk-fab-clearance on the scrolling container:
<div class="overflow-y-auto wk-fab-clearance">
{{-- …a long list, whose last row stays reachable… --}}
</div>
It reserves the button's own box (--size-wk-fab), the clearance it keeps from the
viewport edge, and one step of breathing room — including the phone's home-indicator
inset. Opt-in for the same reason its counterpart is: the component cannot see what
scrolls beneath it, and reserving the space unconditionally would leave a gap at the
bottom of every page that has a FAB and nothing under it.
Do not copy the button's size into your own stylesheet to do this by hand. That is
what --size-wk-fab exists for — a hardcoded copy keeps working right up until the
size moves, and then it is wrong with nothing to say so. A FAB that covers the last
control on a page is easy to miss: a test that clicks that control reports a timeout,
which reads like slow CI rather than a covered control.
Size on a phone
The button is 3rem below the md breakpoint and 3.5rem at and above it. A fixed 56px is a
fifth of a 320px phone's width, in the corner under the thumb and over the content, while the
same button is unremarkable on a desktop.
3rem keeps four pixels of headroom over the 44px AAA target size (WCAG 2.5.5),
so the smaller step does not spend the ergonomics it exists to improve.
The token steps, not the component's classes, which is what keeps wk-fab-clearance
honest: the room a scrolling container reserves is derived from --size-wk-fab, so it follows
the button down instead of holding desktop space open on a phone. Override the token in your own
:root and both move together:
:root {
--size-wk-fab: 4rem;
}
This is the same threshold at which the FAB gives up its bottom-nav clearance, so the component has one breakpoint rather than two.
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:: 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.
A touch screen has neither, so there the names of an open speed dial are always
shown; set hideLabel for a dial whose symbols speak for themselves. 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:
- An
aria-labeloraria-labelledbyof yours names the button and its group of actions in place oflabel. - 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 panel is a named
role="group"and the trigger saysaria-haspopup="true", notmenu:role="menu"is a keyboard CONTRACT, with one tab stop for the whole menu, arrow keys to move inside it, Escape to leave, and focus leaving it closes it. The actions here are ordinary links and buttons, each with its own tab stop, so a reader who followed an announced menu would press the arrow keys and get nothing. A group is what this is. - Focus leaving the open panel closes it, so a keyboard user cannot tab past an open panel and leave it hanging over the page behind them.
- 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. |