Skip to main content
Copy for LLM

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.

A speed dial with three actions

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.

A single-action FAB that opens a dialog

Discard your changes?

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::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. 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:

With names on hover (default) and with hideLabel

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-label or aria-labelledby of yours names the button and its group of actions in place of label.
  • 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 says aria-haspopup="true", not menu: 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.

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store