FAB
A floating action button — icon-only or extended, with elevation, corner placement, a badge, a stacked group and a speed-dial menu
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Fab } from "@tessinaui/ui";<Fab label="Create" leadingIcon={<Plus />} />The FAB is the button family in a floating posture: same variants, same intents,
same size ladder, same tone contract. What it adds is elevation, corner
placement that clears the device's safe area, a glass fill for sitting over
media, and two containers — FabGroup for a stack and FabMenu for a speed
dial.
Rules worth following
- One FAB per screen. It is the single most prominent action in a view; a second one competes with it rather than adding to it.
- Never two floating buttons side by side. Put them in a
FabGroup, which gives them one anchor and one gap. - Name an icon-only FAB. With no
label,aria-labeloraria-labelledbyis required — the type will not compile without one. - Keep a speed dial to two to six actions, and never open one from an extended FAB.
- Do not gate the FAB on content loading. Real apps render it enabled over skeleton lists and announce completion with a toast, rather than putting a spinner in the button.
Examples
Default
An extended FAB — a pill with a leading icon and a label.
Icon Only
Omit label for the plain FAB: the box goes square, the glyph steps up one size,
and aria-label becomes the accessible name.
Variants
Four weights — primary, secondary, outline and ghost. The neutral
primary is the quiet, high-contrast fill most product FABs use.
Intents
All twenty variant × intent combinations. Every one paints; intent is
expressed through text colour on the quiet tiers and through the fill on
primary.
Sizes
Six rungs. 2xs (24px) is square-only — it has no room for a label — and the
sub-44px sizes grow an invisible tap extension on phones so they still meet the
WCAG 2.5.5 touch target.
Rounded
The radius cascade. full is the default circle or pill; xl gives the squircle
Material specifies for its own FABs.
Elevation
A floating control casts a shadow. raised is the default; overlay is for
sitting over media. The shadow is dropped in dark mode by design — on a dark page
there is nothing to darken, so separation comes from the fill's own contrast.
Placement
placement pins the FAB to a corner of the nearest positioned ancestor, insetting
by max(1rem, env(safe-area-inset-*)) so it clears the home indicator and the
notch. It is absolute and opt-in, so an unplaced FAB stays in the normal flow.
Corner names are physical and do not mirror under dir="rtl" — ask for
bottom-left if you want the trailing corner in an RTL layout.
Badge
A count for a cart or an inbox. The digits are aria-hidden, so put the count in
the FAB's own accessible name — otherwise it is announced twice or not at all.
Group
FabGroup stacks related floating buttons under one anchor. The primary is
written first, so it is the first tab stop, and paints at the bottom of the
stack, nearest the thumb.
Map Controls
The same container as a named cluster of secondary controls, placed on the side edge rather than the corner — deliberately outside the thumb arc, because the bottom-right slot belongs to the screen's primary action.
Menu
The speed dial. Built on the house menu primitives, so arrow keys, typeahead,
Escape and focus return to the trigger all work. The + rotates into a × when
the menu opens.
The actions do not appear all at once: each is 40ms behind the one before it, so
the stack unfurls outward from the button rather than snapping into place.
Closing is a dismissal rather than a reveal, so everything leaves together.
Only opacity and transform move, and the whole reveal is motion-safe: gated —
under prefers-reduced-motion: reduce the items simply appear.
As a Link
render swaps the host element. An anchor host keeps link semantics: no
role="button", activated by Enter only, and aria-disabled plus removal from
the tab order rather than a disabled attribute an anchor would ignore.
States
Loading overlays its spinner rather than swapping it in, so the FAB keeps its width and its label; the control stays focusable and swallows activation instead of natively disabling itself.
On Color
Placed on a coloured Surface, FABs adopt the on-color tone automatically.
API Reference
Fab
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Visible label (the extended FAB). Omit for the icon-only square FAB |
variant | "primary" | "secondary" | "outline" | "ghost" | "primary" | Visual weight |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic meaning |
tone | "default" | "on-color" | inherited | Whether it sits on a coloured surface |
size | "2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Density. 2xs is square-only |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Corner treatment |
elevation | "none" | "raised" | "overlay" | "raised" | Drop shadow; dropped in dark mode by design |
placement | "none" | "bottom-right" | "bottom-center" | "bottom-left" | "top-right" | "top-left" | "none" | Absolute corner placement, safe-area aware |
translucent | boolean | false | Glass fill for use over media. Available, but not demonstrated on this page yet |
leadingIcon | React.ReactNode | — | Icon before the label. Decorative |
trailingIcon | React.ReactNode | — | Icon after the label. Needs a label |
loading | boolean | false | Busy. Overlays a spinner, keeps focus, swallows activation |
disabled | boolean | false | Not operable. Wins over loading |
showBadge | boolean | false | Notification dot on the top-trailing corner |
badgeCount | string | number | — | Count inside the badge. aria-hidden — put it in the name too |
render | React.ReactElement | — | Render into another element, typically an <a> |
dir | "ltr" | "rtl" | "ltr" | Text direction |
aria-label | string | — | Required when there is no label |
FabGroup
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "vertical" | Stack direction |
placement | same as Fab | "none" | The group owns the anchor so its children do not |
aria-label | string | — | Names the cluster; adds role="group" when present |
FabMenu
FabMenu · FabMenuTrigger · FabMenuContent · FabMenuItem.
| Prop | On | Type | Default | Description |
|---|---|---|---|---|
closeIcon | Trigger | React.ReactNode | — | Icon while open. Omitted, the leading icon rotates 45° |
side | Content | "top" | "bottom" | "left" | "right" | "top" | Which way the actions fan out |
align | Content | "start" | "center" | "end" | "end" | Alignment against the trigger |
sideOffset | Content | number | 12 | Gap between trigger and first action |
backdrop | Content | boolean | false | Dim the page behind the open menu |
icon | Item | React.ReactNode | — | The action's glyph |
label | Item | string | — | The action's name. Real text, so it is the accessible name |
size | Item | Fab["size"] | "sm" | Circle size |
FabVisual
The FAB's pixels with none of its semantics, for the rare places a real button is
illegal — inside a menuitem, for instance. If what you are building can be a
button, use Fab.
Accessibility
- An icon-only FAB requires
aria-labeloraria-labelledby; this is enforced in the props type, so the unnamed case does not compile. loadingreportsaria-busyandaria-disabledand keeps the control focusable — a button that natively disables itself on click throws focus to the document body mid-interaction.- The focus ring is
focus-visible:ring-2againstring-offset-surface, so it is visible in both themes. - Touch targets meet 44×44px on phones:
mdand up are large enough already, and2xs/xs/smcarry an invisible tap extension. Note that agetBoundingClientRect()probe cannot see it — it is a pseudo-element. - Badge digits are
aria-hidden; the count belongs in the FAB's accessible name. FabMenuinherits the house menu's keyboard model: arrows, typeahead, Escape, and focus return to the trigger. Its items are named by their visible text rather than by a tooltip, which is what makes them work under touch.- Motion is
motion-safe:gated, duration included, soprefers-reduced-motion: reducesilences it.
Screen reader coverage
| Reader | Status |
|---|---|
| VoiceOver (macOS) | Passed — names, badge counts, and the loading busy state all announce |
| NVDA | Untested — does not run on this machine |
| JAWS | Untested — does not run on this machine |