Skip to content

Fab

The floating action button, built out of parts that already exist. A circular accent trigger hangs off a viewport corner, and what it opens is a Dropdown whose rows are the actions. Three layouts share that one DOM: the menu surface as shipped, a speed-dial stack of labelled circles, and a flower that fans the actions on an arc around the trigger — the second two are pure CSS over the same rows. Fab adds the fixed corner, the circle, the corner-to-placement mapping, an optional scrim, the plus rotating into a cross, and the two attributes the laid-out variants key on. Everything else is Dropdown's, and this component holds no state, no hook and no handler of its own.

  • Stable
  • React 19
  • Built on Dropdown
  • Zero hooks, zero handlers

Closed, then open. The whole open state of the skin is one attribute — the aria-expanded Dropdown writes on the trigger.

Code
const ITEMS: DropdownItem[] = [
{ kind: 'group', label: 'Crear' },
{ kind: 'item', label: 'Nota', icon: <NoteIcon />, shortcut: '⌘N', onSelect: newNote },
{ kind: 'item', label: 'Foto', icon: <PhotoIcon />, shortcut: '⌘⇧F', onSelect: newPhoto },
];
<Fab items={ITEMS} triggerLabel="Acciones rápidas" />
<div class="wayra-fab" style="position: static">
<button type="button" class="wayra-fab-trigger" aria-label="Acciones rápidas" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div>

Two required props and nothing else. items is the content — the same DropdownItem union Dropdown documents, unchanged — and triggerLabel is the accessible name of the circle, required text rather than a default so the button is translated by construction. There is no trigger to pass and no surface to place: the corner picks the placement, and mounting the component anywhere in the tree docks it.

fab.tsx has no useState, no useEffect and no event handler. It renders a <div>, an optional scrim <span>, and a Dropdown whose trigger is the circular <button>. Everything in this table therefore behaves exactly as Dropdown documents it, because it is Dropdown:

What Where it comes from
The top layer, and the flip when there is no room Popover, under Dropdown
Anchor positioning against the trigger Popover
Light dismiss, a click outside the popover attribute
role="menu", the rows, the roving tabindex Dropdown
Arrow keys, Home / End, typeahead, focus return Dropdown
Escape, from the row that took the focus on opening Dropdown, which stops it before the browser’s own close request
aria-haspopup, aria-expanded, aria-controls on the trigger Dropdown, cloned onto it
Closing after a row runs Dropdown

What Fab adds is the skin and the geometry — and the skin’s open state is read straight off the attribute Dropdown already writes:

.wayra-fab:has(.wayra-fab-trigger[aria-expanded="true"]) .wayra-fab-scrim { … }
.wayra-fab-trigger[aria-expanded="true"] > svg { rotate: 135deg; }

There is no data-open on the root. aria-expanded is already the truth of the open state, it is already on the element that changes, and :has() is what lets a parent read it — so the rotation, the neutral open fill and the scrim all cost zero JavaScript, and hand-written markup gets them by writing one attribute.

Four corners in one stage. Each circle is a separate Fab; the stage is the demo's box, not the component's.

Code
<Fab position="bottom-end" items={ITEMS} triggerLabel="…" /> {/* default */}
<Fab position="bottom-start" items={ITEMS} triggerLabel="…" />
<Fab position="top-end" items={ITEMS} triggerLabel="…" />
<Fab position="top-start" items={ITEMS} triggerLabel="…" />
<div style="position: relative; flex: 1 1 100%; inline-size: 100%; block-size: 12rem; overflow: clip; border-radius: 0.75rem; box-shadow: inset 0 0 0 1px var(--wayra-color-base-300)">
<div class="wayra-fab" style="position: absolute">
<button type="button" class="wayra-fab-trigger" aria-label="bottom-end" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div><div class="wayra-fab" data-position="bottom-start" style="position: absolute">
<button type="button" class="wayra-fab-trigger" aria-label="bottom-start" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div><div class="wayra-fab" data-position="top-end" style="position: absolute">
<button type="button" class="wayra-fab-trigger" aria-label="top-end" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div><div class="wayra-fab" data-position="top-start" style="position: absolute">
<button type="button" class="wayra-fab-trigger" aria-label="top-start" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div>
</div>

position docks the button and decides where the menu goes, in one prop: the menu opens away from the block edge the button is nearest, and lines up with the same inline edge the button hangs off.

position Menu placement Menu align
bottom-end (default) block-start — opens upward end
bottom-start block-start — opens upward start
top-end block-end — opens downward end
top-start block-end — opens downward start

Both halves are logical. The inline half follows the writing direction, so bottom-end is the bottom-right corner under dir="ltr" and the bottom-left under dir="rtl", with no second rule and no mirrored stylesheet — the whole mapping is two CSS rules, each flipping one axis, and the default corner needs no rule at all. data-position is always written, bottom-end included; markup written by hand may leave it off, and a missing attribute reads as bottom-end.

<div className="my-toolbar">
<Fab position="custom" placement="block-start" align="end"
items={ITEMS} triggerLabel="Acciones rápidas" />
</div>

The fifth value opts out of the viewport entirely: the root becomes a normal in-flow box — position: static, no insets — and where it sits is your CSS’s business, a toolbar, a card corner, a sticky footer. Because there is no corner left to decide the menu’s geometry, two props hand that decision back: placement ("block-end" by default — the menu opens downward) and align ("start" by default). Under any corner both are ignored: one prop, one truth — the corner is the geometry.

Two things follow and are worth knowing. The scrim still veils the whole viewport (it stays position: fixed), and the root takes isolation: isolate so the veil keeps painting between the page and the button. And a custom flower has no corner to pick its sweep from, so it defaults to the bottom-end arc and you aim it with the two public knobs --wayra-fab-arc-from / --wayra-fab-arc-to.

For plain corner nudging you never need custom: --wayra-fab-edge moves a docked fab off its edges, on both axes at once.

variant="stack" open. The same Dropdown rows, re-dressed: the icon span is an opaque circle, the label span a pill, and each arrives on its own 35ms step of the settle curve.

The default variant="menu" opens the Dropdown surface every other page documents. The two other values keep exactly the same DOM — the same rows, the same keyboard, the same aria-* — and change only the paint:

  • stack is the speed dial: a column of circular actions above (or below) the trigger, each named by a pill. The circle sits on the trigger’s own inline axis and the pill points inward, mirroring for -start corners and under dir="rtl" with no extra rule.
  • flower fans the actions on an arc around the trigger. The angle is trigonometry, not a lookup table: each row interpolates its angle from --wayra-dropdown-index over --wayra-dropdown-count — the two custom properties Dropdown writes inline — so two actions take the ends of the sweep and eight spread evenly between them, and the radius grows with the count so they never overlap. Labels are clipped, not removed: every circle still has its accessible name. The arc is physical, so it mirrors through a sign read off a dir="rtl" on the fab or any ancestor — which works at the Chrome 111 floor — and :dir(), where the browser has it (Chrome 120+), puts an LTR island inside an RTL page back. The menu itself opens with placement="cover", laid over the trigger, whatever the corner — markup written by hand writes data-placement="cover" on it for the same reason.

variant="flower" open in the default corner: the arc sweeps from due inline-start up to due block-start, and reverses under dir="rtl".

Because a row is also its geometry slot in these two layouts, items admits only item and checkbox rows there — a separator or a group throws, the way Tabs throws on a mixed item list, instead of rendering a heading floating in an arc.

The keyboard does not change: the arrows walk the actions in item order, typeahead still jumps by label — the flower’s clipped pills and the stack’s visible ones are the same string — Escape closes and refocuses the circle, and the focused action paints its circle in the accent, which is the menu highlight transplanted onto a button. A press does the same with the menu’s press step: the circle darkens toward base-content (5.18:1 or more in every role of the AA themes) and gives under the pointer, a stack’s pill takes one press wash, and the row behind them stays transparent.

labels="hover": the pills are at opacity 0 until the pointer or the keyboard reaches their row. Hover an action to see one.

labels="hover" (stack only) fades each pill in on its row’s :hover and :focus-visible, so a keyboard user walking the stack gets the same disclosure a pointer gets. Faded, never removed — the accessible name stays in the tree in every state. flower ignores the prop (its labels are always clipped); menu has nothing to fade.

Code
<Fab size="sm" items={ITEMS} triggerLabel="sm" />
<Fab items={ITEMS} triggerLabel="md" /> {/* md, the default */}
<Fab size="lg" items={ITEMS} triggerLabel="lg" />
<div class="wayra-fab" data-size="sm" style="position: static">
<button type="button" class="wayra-fab-trigger" aria-label="sm" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div>
<div class="wayra-fab" style="position: static">
<button type="button" class="wayra-fab-trigger" aria-label="md" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div>
<div class="wayra-fab" data-size="lg" style="position: static">
<button type="button" class="wayra-fab-trigger" aria-label="lg" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div>
Step Diameter knob At the default unit
sm 11 × --wayra-size-selector 44px
md 14 × --wayra-size-selector 56px
lg 16 × --wayra-size-selector 64px

Three steps off a ladder of its own, not the field ladder every control walks. The small end sits on WCAG 2.5.5’s 44px comfortable target line rather than on 2.5.8’s 24px floor, because a floating button is a thumb target before it is anything else — there is nowhere smaller to go and no xs.

The same prop is the menu’s row step, so a small fab opens a small menu and the two never look like they came from different sets.

The action circles of stack and flower walk a second named ladder one seat down — 9 / 11 / 13 × --wayra-size-selector, so 36 / 44 / 52px — which makes the md action exactly the sm trigger. Every step clears WCAG 2.5.8’s 24px floor, and in the stack the pill is part of the same button, so the real target is wider still.

Code
<Fab items={ITEMS} triggerLabel="…" /> {/* primary, the default */}
<Fab color="accent" items={ITEMS} triggerLabel="…" />
<Fab color="error" items={ITEMS} triggerLabel="…" />
<div class="wayra-fab" style="position: static">
<button type="button" class="wayra-fab-trigger" aria-label="primary" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div>
<div class="wayra-fab" data-color="accent" style="position: static">
<button type="button" class="wayra-fab-trigger" aria-label="accent" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div>
<div class="wayra-fab" data-color="error" style="position: static">
<button type="button" class="wayra-fab-trigger" aria-label="error" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div>

color travels as data-color, primary included. Markup written by hand may leave it off: color.css falls back to primary. The circle is the role solid on its -content pair, which is the library’s two-token colour contract doing the contrast work.

The same value is passed on to the composed menu, so the highlight that sweeps the active row matches the button that opened it. A row carrying tone: 'danger' still re-points the accent to error for itself, exactly as on the Dropdown page.

The same markup twice, with aria-expanded hard-coded to true on the second.

Code
{/* Nothing to write: Dropdown flips aria-expanded and CSS does the rest. */}
<Fab items={ITEMS} triggerLabel="Acciones rápidas" />
<div class="wayra-fab" style="position: static">
<button type="button" class="wayra-fab-trigger" aria-label="Cerrado" aria-expanded="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div><div class="wayra-fab" style="position: static">
<button type="button" class="wayra-fab-trigger" aria-label="Abierto" aria-expanded="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
</div>

Two things change when the menu opens, and both are the open state’s signature: the accent gives way to a neutral surface — 14% base-content over base-100, so the glyph on it clears the 3:1 non-text floor — and the + rotates 135° into an ×. One glyph, one rotation on the settle curve; there is no second icon to cross-fade and no triggerIconOpen prop.

That is also the constraint on triggerIcon: whatever you pass is turned 135°, so a glyph that is not symmetric about both axes reads oddly at that angle. A plus, a cross, a four-pointed star and a dot all survive it; a pencil does not.

<Fab scrim items={ITEMS} triggerLabel="Acciones rápidas" />

scrim dims the page while the menu is open. It is a flat color-mix — 20% base-content over the page — and never a blur: the library ships flat opaque surfaces, and backdrop-filter was withdrawn in a design review rather than kept behind a prop.

It is purely visual, in three ways worth stating plainly:

  • pointer-events: none. It is not a click catcher. Light dismiss belongs to the browser, and adding a second one would only race it.
  • aria-hidden="true". It is a <span> with no text and nothing to announce.
  • z-index: -1, which keeps it inside the fab’s own stacking context — under the trigger, over the page. The menu is in the top layer and paints above both regardless.

Nothing in JavaScript switches it on. The rule reads the trigger’s aria-expanded through :has(), which is why the scrim carries no attribute of its own and why the component needs no state to draw it.

const [open, setOpen] = useState(false);
<Fab open={open} onOpenChange={setOpen} items={ITEMS} triggerLabel="Acciones rápidas" />

open, defaultOpen and onOpenChange are passed straight to Dropdown and behave exactly as documented there — including where focus goes, which is the part worth re-reading: opening from open or defaultOpen moves no focus, opening from the trigger lands on a row, and closing on purpose hands focus back to the circle. onOpenChange fires for the closes the browser started too, so a controlled fab that ignored it would reopen on the next render.

The class plus the data-* attributes is the public API, and every preview on this page is written against it with no React involved. The skin — the corner, the circle, the role fill, the rotation — needs no JavaScript beyond flipping one attribute. Every data-* is optional here: the component writes all of them, defaults included, and a missing one reads as its default.

<div class="wayra-fab" data-color="accent">
<button type="button" class="wayra-fab-trigger" aria-label="Acciones rápidas"
aria-haspopup="menu" aria-expanded="false" aria-controls="fab-menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14"/><path d="M5 12h14"/>
</svg>
</button>
</div>
const trigger = document.querySelector('.wayra-fab-trigger');
trigger.addEventListener('click', () => {
const open = trigger.getAttribute('aria-expanded') === 'true';
trigger.setAttribute('aria-expanded', String(!open));
});

Six lines, and the circle turns neutral, the plus rotates into a cross and the scrim fades in — because all three are keyed on that one attribute.

The menu half is a different exercise, one level down: it is not a class, it is a component, and reproducing it by hand is Dropdown’s own hand-written markup section. What that section says applies here unchanged — the paint is reproducible, the keyboard is not. The roving tabindex, typeahead, Escape and the focus return are the component’s, and they are the whole reason it exists.

Property Default What it moves
--wayra-fab-size the size step — 56px at md Diameter of the circle. Re-declared by each data-size, so override it after the step or per instance.
--wayra-fab-edge 1.25rem The gap to each docked edge, on both axes at once.
--wayra-fab-z 20 Stacking number, chosen to clear the in-flow overlay fallback (10).
--wayra-fab-gap 0.75rem The column gap between two stacked actions.
--wayra-fab-arc unset — computed from the count Radius of the flower. Unset on purpose: the fallback grows with --wayra-dropdown-count so eight actions clear each other; set a length on the root to pin it.
--wayra-fab-arc-from / --wayra-fab-arc-to 180deg / 90deg, re-pointed per corner The two ends of the flower’s sweep. Public because a position="custom" fab has no corner to pick them; each corner preset re-points the pair.

--wayra-fab-z only matters below the top-layer tier. Where the browser has popover and anchor positioning, the menu is in the top layer and any number is inert there; the knob exists for the fallback path, and for a page whose own furniture is stacked higher than the fab.

Everything else the file uses — the glyph size, the two durations, the ring colour — is a private --_wayra-fab-* value and not API.

The port audit first cut the daisyUI-shaped fan outright (2026-08-23); the same week the decision was reopened and the fan came back — as CSS. What stayed dead is the part that made the legacy fan worse than a menu: there is no second geometry engine, no second keyboard, no per-action component and no unlabelled circle. stack and flower are stylesheet re-dressings of the same role="menuitem" rows, which is why they cost no JavaScript and why every circle kept its name. The one thing the layouts refuse is a separator or group row — a heading has no seat on an arc, so the component throws rather than guessing.

Both are gone from the legacy API. A fab that runs one action and opens a menu is two controls in one target — the trigger opens the menu, and the action that would have been mainAction is simply the first row. closeButton is what the trigger already becomes: the same button closes the menu, which is exactly what the rotation into an × is telling the user.

The root’s children are the scrim and the menu, so items is the content and the props type omits children (the Dropdown precedent). One smuggled past the types through a cast is still not rendered.

FabProps extends ComponentProps<"div"> minus children and color, so id, style, ref, className, every aria-* and every data-* land on the root — the box that is fixed to the corner.

Prop Type Default Description
items readonly DropdownItem[] — required The actions, in order, as Dropdown rows: item, checkbox, separator, group. A button that opens an empty menu does nothing. Under stack / flower only item and checkbox are admitted — a separator or group throws.
triggerLabel string — required Accessible name of the circle. Required text rather than a default, so the button is translated by construction — the glyph inside names nothing.
variant "menu" | "stack" | "flower" "menu" The layout of what opens: the Dropdown surface, the speed-dial column, or the trigonometric arc. Same DOM in all three; travels as data-variant.
labels "always" | "hover" "always" Stack only: hover fades each pill in on its row’s :hover / :focus-visible. The accessible name never leaves the tree. Travels as data-labels.
color WayraColor "primary" Semantic role of the trigger, as data-color. Passed on to the menu, so the row highlight matches.
size "sm" | "md" | "lg" "md" Diameter off the Fab ladder: 44 / 56 / 64px. Also the menu’s row step and the action-circle step. Travels as data-size.
position "bottom-end" | "bottom-start" | "top-end" | "top-start" | "custom" "bottom-end" Which corner it docks to, logically — or custom to opt out of the viewport and place the root yourself. A corner also decides the menu’s placement and align. Travels as data-position.
placement "block-start" | "block-end" "block-end" Which side the menu opens on — honored only under position="custom"; a corner decides its own, and a flower always covers the trigger.
align "start" | "center" | "end" "start" Which trigger edge the menu lines up with — honored only under position="custom". Under custom, the stack column follows it too.
scrim boolean false Dim the page while the menu is open. A flat veil, no blur; pointer-events: none and aria-hidden.
triggerIcon ReactNode a plus <svg> Glyph inside the circle. Decorative, and rotated 135° while the menu is open.
open boolean — Controlled open state, passed to Dropdown. Pair it with onOpenChange.
defaultOpen boolean false Open state on the first render, uncontrolled.
onOpenChange (open: boolean) => void — Fires with the new state, including the closes the browser started.
unstyled boolean false Strips the FAB skin only — the root’s class, the trigger’s and the scrim’s. The composed menu keeps its own classes; Dropdown has an unstyled of its own to give up. Every data-* and the whole behaviour survive.

The keyboard is Dropdown’s, whole and unchanged: Enter / Space and the arrows open the menu onto a row, the arrows walk it, typeahead jumps, Escape closes it and hands focus back to the circle, Tab closes it and lets the browser carry on to the next control.

  • The trigger says what it opens, before it opens. triggerLabel is its whole accessible name; aria-haspopup="menu", aria-expanded tracking the state and aria-controls pointing at the surface are all cloned on by Dropdown. A screen reader announces the label plus collapsed or expanded.
  • The menu names itself back through aria-labelledby on the trigger, so triggerLabel is also the menu’s accessible name — one string to translate, not two.
  • The glyph is decoration and says so. aria-hidden="true" on the default plus, and the same is expected of whatever you pass as triggerIcon. Nothing in the circle is announced except the label.
  • The scrim is out of the tree. aria-hidden="true", no text, and no pointer events — a screen reader never meets it, and the pointer goes straight through to the browser’s own light dismiss.
  • Every step clears 44px, the comfortable target line, which is the reason the ladder starts where it does. The focus ring is the one rule in states.css; the trigger declares --_wayra-ring-color and nothing else.
  • The flower’s names are clipped, not cut. The pill is visually hidden with the clip pattern, never display: none, so every circle keeps its accessible name and typeahead still matches it. labels="hover" fades with opacity for the same reason — and reveals on :focus-visible too, so the keyboard gets the same disclosure the pointer does.
  • Reduced motion zeroes the press, the background transition, the glyph rotation and the entry stagger by re-pointing --_wayra-fab-duration, --_wayra-fab-glyph-duration and --_wayra-fab-stagger — variables, never properties, so nothing freezes mid-turn and the open state still reads as open. The actions and the scrim keep a 160ms fade: the actions fade in together where they land, at full size, with no travel from the trigger. A pressed action circle keeps its press colour and drops its give.
  • Windows high contrast gives the circle a CanvasText border, because the solid role fill that normally delimits it is not painted there, and removes the scrim outright — a semi-transparent wash is painted opaque there, which would black the page out.

Do

One fab, one screen, one corner. It is the screen's single most likely action, and the menu holds the rest.

Don't

Two floating buttons competing for the same job. There is no primary action any more — and the second one is a Button in the toolbar it belongs to.

Do

A glyph symmetric about both axes. At 135° the plus is a clean ×, which is what tells the user the same button closes the menu.

Don't

An asymmetric triggerIcon. The rotation is unconditional, so an open menu leaves the glyph lying at an angle that means nothing.