FAB
A floating action button — icon-only or extended, with elevation, corner placement, a badge, a stacked group and a speed-dial menu
pnpm add @tessinaui/uiWhen to use
- The one primary action of a screen. Create, Compose, Add: the thing most people open this view to do. One FAB per screen.
- A positive, frequent action that should stay reachable while the content scrolls, pinned to a corner with
placement. - An extended FAB when the icon alone is ambiguous. Give it a
label; use the icon-only form only for a glyph everyone reads the same way. - Several related actions behind one anchor. Open a
FabMenuspeed dial, or stack a few secondary controls in aFabGroup. - Floating over media or a map. Use
elevation="overlay"ortranslucentso the control separates from what is beneath it.
When not to use
- For a screen with no single dominant action. Use a Button in the content or the header instead of forcing one to float.
- For minor, destructive or toolbar-style actions such as archive, delete, cut or volume. Put them in a Toolbar or an IconButton.
- For navigation between destinations. Use BottomNav or a Link.
- As a second or third floating button. Two competing FABs dilute the primary action; group related ones in a
FabGroup. - For a long list of actions. A speed dial suits a handful; past that, use a DropdownMenu.
- Beside a filled button in the same area. The two emphasise different things at once; pick one.
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 touch screens, tablets
included, 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
— softer than a square, still distinct from the round default.
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. The safe-area half applies once your page sets viewport-fit=cover in its
viewport meta tag; without it the browser keeps the page out of those areas
itself and the inset is 1rem. 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 arrow keys follow the
direction the actions fan out: in a dial that opens upward, the up arrow moves
away from the button. 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.
Skeleton
For a screen that is still composing. A FAB that is already mounted stays enabled over skeleton content — see the guidelines above.
RTL
Under dir="rtl" the icon slots mirror and the badge moves to the top-left corner. Unset, dir is inherited from the page. placement corners are physical — they do not mirror.
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" | — | Text direction. Unset, it inherits the page's 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 |
dir | Content | "ltr" | "rtl" | — | Pass the trigger's dir. The menu portals to <body>, so align cannot mirror without it |
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 any touch screen, phone or tablet:
mdand up are large enough already, and2xs/xs/smcarry an invisible tap extension. Each speed-dial action carries a 44px band across its whole row. Note that agetBoundingClientRect()probe cannot see either — they are pseudo-elements. - 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. The up and down arrows move in the direction the actions are painted, and the action nearest the button comes first whichever way the dial opens, including when it flips below the button for lack of room. Its items are named by their visible text rather than by a tooltip, which is what makes them work under touch. The action with keyboard focus wears the same focus ring as the FAB, on its circle.- 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 |