Skip to content
llms.txt
llms.txt

Usage recipes

This page contains the longer recipes that do not fit well in a package README. It focuses on composition and renderer decisions.

Terminal window
npm i @floating-ui-plus/web
Terminal window
bun add @floating-ui-plus/web

The Web package is SSR-safe. Create and connect controllers after the browser elements exist; your renderer owns DOM, styles, ARIA labels, and presence.

Use one controller for a reference/floating pair. pipe() composes plugins left to right and cleans them up in reverse order.

import {
autoUpdate,
createFloating,
dismiss,
focus,
hover,
offset,
role,
} from "@floating-ui-plus/web";
let open = false;
const tooltip = createFloating(() => ({
open,
onOpenChange(nextOpen) {
open = nextOpen;
render();
},
middleware: [offset(6)],
whileElementsMounted: autoUpdate,
})).pipe(hover(), focus(), dismiss(), role({ role: "tooltip" }));
tooltip.setReference(button);
tooltip.setFloating(panel);
tooltip.connect();

Apply the controller’s reference/floating attributes and positioning output in render(). Call disconnect() when the view is temporarily detached and destroy() when its owner is disposed.

Use native top-layer behavior when the surface can stay in its logical render tree. Use a portal only when the element must move to a body-level target.

import {
createFloatingTopLayer,
supportsFloatingTopLayer,
} from "@floating-ui-plus/web";
const topLayer = createFloatingTopLayer({ onOpenChange: setOpen });
topLayer.setKind("popover"); // Use 'dialog' for an HTMLDialogElement.
topLayer.setElement(panel);
topLayer.setRestoreFocusElement(reference);
topLayer.connect();
function render() {
if (supportsFloatingTopLayer("popover")) topLayer.sync(open);
else renderPositionedFallback();
}

createFloatingTopLayer() does not move nodes. A createPortalBridge() is a fallback context bridge when your renderer intentionally mounts elsewhere. Native Dialog focus returns to the bound reference after close. When using the controller directly, bind it with setRestoreFocusElement(reference) before the first sync() call.

For a custom modal, compose an overlay and focus manager with a dialog role. Use a real <dialog> instead when the browser’s modal focus and inertness are enough.

<floating-root interactions="click dismiss">
<floating-reference><button>Open</button></floating-reference>
<floating-portal>
<floating-overlay lock-scroll>
<floating-focus-manager modal return-focus outside-elements-inert>
<template slot="content">
<section aria-label="Settings">
Settings <button data-fup-close>Close</button>
</section>
</template>
</floating-focus-manager>
</floating-overlay>
</floating-portal>
</floating-root>

Use floating-list for ordered items, floating-composite for general roving focus, and floating-tree/floating-node for nested roots. Add nested to a submenu list and data-fup-close to leaf actions when the selected action should dismiss its owner.

createSearch()/useSearch() own debounce, IME composition, cancellation, stale-response protection, caching, de-duplication, and cursor pagination. createQuery()/useQuery() add editable input behavior, virtual focus, activation, and combobox ARIA by default. Set semantics: 'dialog' for a command palette or semantics: 'none' when the renderer owns ARIA. Every renderer should provide idle, loading, error, empty, and results states.

import { createQuery } from "@floating-ui-plus/web/query";
import { createSearch } from "@floating-ui-plus/web/search";
const search = createSearch({
source: async ({ query, signal, cursor }) => {
const response = await fetch(
`/api/products?q=${encodeURIComponent(query)}&cursor=${cursor ?? ""}`,
{ signal },
);
if (!response.ok) throw new Error("Search failed");
return response.json(); // {items, total?, nextCursor?}
},
getItemKey: (item) => item.id,
});
const query = createQuery({
search,
getItemLabel: (item) => item.name,
onOpenChange: setOpen,
onActivate: (item) => openProduct(item),
});
const unbindInput = query.bindInput(input);
query.setListElements([option]);
const unbindOption = query.bindOption(option, search.items[0]!, 0);
await search.loadMore();
// Dispose the bindings with the rendered view.
unbindOption();
unbindInput();

Use createFuzzySearchSource() for local collections. For data owned by a query library, omit source and push state through setControlledState().

dismiss() detects outside press, Escape, reference press, and scroll; it is not the close API. Explicit close commands and dismissal requests share one synchronous guard.

const floating = createFloating(() => ({
open,
onBeforeClose: (event, reason) => {
sendCloseMetric(reason);
return !hasUnsavedChanges();
},
onOpenChange: setOpen,
}));
floating.context.onOpenChange(false, sourceEvent, "escape-key");

Return false to keep the surface open. Opening is never blocked.

  • Use PLACEMENT/PLACEMENTS or ordinary strings such as bottom-start.
  • Apply offset() as the desired visual gap; arrows add their own height.
  • Keep native Popover/dialog surfaces in the logical render tree.
  • Use portals only for an intentional DOM target change.
  • Disconnect temporary views and destroy long-lived controllers on teardown.
  • Let TypeDoc generate API signatures; update this guide only when a usage flow or renderer composition changes.