@floating-ui-plus/web-components
v0.11.2
Published
Floating UI custom elements with interactions, focus management, portals, collections, and search
Maintainers
Readme
@floating-ui-plus/web-components
Headless Custom Elements for floating interfaces, including positioning, interactions, native surfaces, focus, collections, and editable queries. Your application keeps the HTML, classes, and result markup.
Install and register
npm install @floating-ui-plus/web-components
# or: pnpm add @floating-ui-plus/web-components
# or: bun add @floating-ui-plus/web-componentsImport once to register all elements. The package also re-exports the
framework-neutral APIs from @floating-ui-plus/web.
import '@floating-ui-plus/web-components';Popover with a native template
Use a root-owned <template slot="content"> for an anchored, conditional
surface. The root creates a fresh native Popover while it is open; a portal is
not needed for the usual case.
<floating-root
placement="bottom-start"
interactions="click dismiss"
>
<floating-reference>
<button type="button">Open settings</button>
</floating-reference>
<template slot="content">
<section aria-label="Settings">
Popover content
<button type="button" data-fup-close>Close</button>
</section>
</template>
</floating-root>The root's baseline accessibility contract uses dialog for an interactive
floating surface. Set floating-role only when selecting a different supported
semantic role, such as menu or tooltip. region is a landmark role, not a
role supported by the package's public floating-role contract.
The root-owned template[slot="content"] is the explicit native Popover
composition. A direct <dialog slot="floating"> uses native dialog behavior.
Direct slotted non-dialog surfaces
also use the Popover API by default; set top-layer="none" for an intentionally
positioned/custom surface. floating-role only sets ARIA semantics.
These are browser Web Standards APIs—the Popover API for anchored non-modal
surfaces and the native <dialog> element for modal surfaces—not a portal
emulation. The browser owns the corresponding top-layer behavior.
Opening a native dialog also applies a ref-counted document scroll lock using
CSS overflow: hidden. No touch-event interception is installed by default;
add an application-specific touch guard only when targeting a legacy iOS or
WebView environment that needs one.
Native dialogs also receive data-fup-safe-area and the CSS variables
--fup-safe-area-inset-top, --fup-safe-area-inset-right,
--fup-safe-area-inset-bottom, and --fup-safe-area-inset-left. Consume these
variables in surface CSS where a notch or home indicator needs extra space; the
package does not force a padding policy on custom dialog layouts.
Use a real <dialog slot="floating"> for a modal. The browser then owns the
top layer, focus, and inertness. Reserve <floating-portal> for a surface that
explicitly must escape a clipping ancestor or render at a custom target.
Configure function values as properties, rather than attributes. configure()
keeps root middleware, plugins, and an optional explicit topLayer together;
use it when more than one setting changes. Attribute values remain useful only
for declarative strings such as placement and interactions.
Attribute policy
Use an id for a unique application element that setup code needs to find,
and classes for repeated presentational parts. The package reserves data-*
for declarative component APIs and runtime state: data-fup-close,
data-search-*, data-active, and data-placement are examples. Avoid
application-specific data-* attributes that exist only as selector hooks.
import {
flip,
offset,
shift,
transformOrigin,
type FloatingRootElement,
} from '@floating-ui-plus/web-components';
const root = document.querySelector<FloatingRootElement>('floating-root')!;
root.configure({
middleware: [
offset(8),
flip({padding: 12}),
shift({padding: 12}),
transformOrigin({padding: 8}),
],
});Use root.close(event, 'click') for an imperative approved close. The root's
cancelable floatingbeforeclose event is the synchronous guard point; an
application with asynchronous confirmation should finish that work first and
then call close().
Native entry and exit animation
Native Popover and <dialog> surfaces close instantly unless their own CSS
explicitly includes a non-zero display or overlay transition with
allow-discrete. With that CSS present, the element remains mounted and
unhidden through the exit transition and is hidden after transitionend.
.floating-panel {
opacity: 0;
translate: 0 -0.25rem;
transform-origin: var(--floating-transform-origin, 50% 0%);
transition:
opacity 120ms cubic-bezier(0.23, 1, 0.32, 1),
translate 120ms cubic-bezier(0.23, 1, 0.32, 1),
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
.floating-panel:popover-open {
opacity: 1;
translate: 0 0;
}
@starting-style {
.floating-panel:popover-open {
opacity: 0;
translate: 0 -0.25rem;
}
}transformOrigin() writes the CSS variable from the final placement and
reference geometry. Keep it after placement-changing middleware; the CSS
fallback remains valid when it is omitted.
For a fixed placement, you may omit the middleware and define
transform-origin in CSS. CSS alone cannot observe flip() or shift()
results, so use transformOrigin() whenever the surface can move to another
side or alignment.
Template content stays mounted only while the native exit is running, and a
reopen cancels the pending unmount. Use <floating-transition> for a custom
surface with top-layer="none". See the
entry and exit animation guide
for Popover, dialog, reduced-motion, and presence examples.
Editable query
<floating-query> is the general searchable-result primitive. It supplies ARIA
combobox/listbox/option behavior and virtual focus by default. Inside a native
<dialog>, it automatically uses dialog semantics, so a Command palette does
not need semantics="dialog". Set semantics="dialog" for a non-native
dialog surface or semantics="none" when the application owns ARIA itself.
It does not own a selected value or participate in form submission. Omit
option-id-prefix unless an application needs deterministic option IDs: unique
IDs are generated by default.
<floating-root placement="bottom-start" interactions="click dismiss">
<floating-list navigation loop allow-escape>
<floating-query id="destination-query">
<floating-reference>
<input id="destination-input" aria-label="Destination" autocomplete="off" />
</floating-reference>
<template slot="content">
<floating-results>
<floating-results-status type="idle">
<p>Start typing.</p>
</floating-results-status>
<floating-results-status type="empty">
<p>No results.</p>
</floating-results-status>
<floating-results-item>
<floating-list-item>
<button type="button" data-search-text="label"></button>
</floating-list-item>
</floating-results-item>
</floating-results>
</template>
</floating-query>
</floating-list>
</floating-root>import {
createFuzzySearchSource,
type FloatingQueryElement,
} from '@floating-ui-plus/web-components';
const destinations = [
{id: 'seoul', label: 'Seoul'},
{id: 'tokyo', label: 'Tokyo'},
{id: 'paris', label: 'Paris'},
];
const query = document.querySelector<FloatingQueryElement>('#destination-query')!;
query.configure<(typeof destinations)[number]>({
search: {
source: createFuzzySearchSource(destinations, {keys: [{name: 'label'}]}),
getItemKey: (item) => item.id,
},
getItemLabel: (item) => item.label,
});
query.addEventListener('queryactivate', (event) => {
const {item} = (event as CustomEvent<{item: (typeof destinations)[number]}>).detail;
document.querySelector<HTMLInputElement>('#destination-input')!.value = item.label;
});Place a standard aria-live region inside <floating-query> when status copy
is needed; it is discovered automatically. status-selector remains available
for an intentionally different target.
configure() accepts either SearchOptions (the element owns the resulting
SearchController) or an existing SearchController. For server search,
provide an application-owned async source; the package does not choose your
transport or cache library. Use query.setQuery() for programmatic changes and
query.search?.loadMore() for cursor pagination.
External contenteditable and editor queries
Configure externalInput to use an editor outside the query's DOM subtree.
Query extraction, composition state, and document updates remain editor-owned:
query.configure({
externalInput: editorElement,
search: {source, getItemKey: (item) => item.id},
getItemLabel: (item) => item.label,
});
await query.updateComplete;
// In the editor update hook:
query.setExternalQuery(queryText, {composing: editorIsComposing});
// In the editor's own keyboard hook, after its IME guards:
const handled = !editorIsComposing && query.handleKeyDown(event);Keep <floating-query> inside <floating-list navigation loop virtual>.
External mode enables the list's manualKeyboard mode while bound, so FUP
does not intercept editor keydown before ProseMirror processes composition.
query.handleKeyDown(event) handles list navigation and Enter activation and
returns a boolean without redispatching a synthetic event. Escape, Tab,
document range validation, and transactions stay in the application.
Standalone lists also expose list.handleKeyDown(event); set the
manual-keyboard attribute to leave native event ordering to the caller.
The editor stays the root's real reference, while root.setPositionReference()
can track its caret separately. Removing/rebinding the query restores its
external loading/active-option attributes and the list's keyboard mode.
The demo's /fixtures/editor-query route tests this contract with real Tiptap.
Its Chrome and WebKit E2E covers typing, original-event navigation, portal
clicks, scrolling, composition event sequences, dismissal, and remount cleanup.
Synthetic composition events verify the integration contract; they do not
automate a platform's native IME candidate window.
See the editor Query guide for the external input API, an editor adapter example, and the guards to keep when migrating a custom slash menu.
Closing contract
data-fup-close remains a convenient declarative close control. For
imperative closing, call the owning root:
root.close(event, 'click');Every close path—close(), data-fup-close, dismiss interactions, and native
Popover/dialog close events—first emits the cancelable, bubbling and composed
floatingbeforeclose event. Its detail is {sourceEvent, reason}.
root.addEventListener('floatingbeforeclose', (event) => {
sendCloseMetric(event.detail.reason);
if (!canClose()) event.preventDefault();
});When prevented, the surface stays open and openchange is not emitted.
openchange only represents approved state changes and carries
{open, sourceEvent, reason}. Asynchronous approval is application-owned:
complete the work, then call root.close().
Transient presence stacks
floating-presence-stack renders timed notifications or other transient
surfaces from one native content template. It is headless visually, but owns
the core host box, bounded controller, opt-in pause binding, exit presence,
Top Layer lifecycle, and final clone removal. Clones default to native
popover="manual"; set top-layer="none" for an in-flow stack. The
application owns ARIA, focus-entry keys, layout, and styling.
<floating-presence-stack
id="notices"
tabindex="-1"
pause-on="pointer focus"
>
<template slot="content">
<article role="status">
<strong data-presence-text="title"></strong>
<button type="button" data-presence-close>Dismiss</button>
</article>
</template>
</floating-presence-stack>
<script type="module">
const notices = document.querySelector('#notices');
notices.add({title: 'Saved'});
</script>Use configure() when several runtime values belong to one application
configuration object. The equivalent limit, timeout, exit-duration,
top-layer, pause-on, and resume-delay attributes remain available for
static HTML. Code options take precedence over matching attributes, and
options is also an assignable property for custom-element bindings. Leave
exit-duration unset to unmount after the consumer CSS discrete
display / overlay length.
data-presence-text accepts a value path plus $id, $index, and
$remaining. The element writes data-status, data-presence-id,
data-presence-index, data-presence-overflowed, and
--floating-presence-index on the first template root, and
--floating-presence-count on the host. Mark a nested surface
with data-presence-item only when a multi-root template needs an explicit
target. The host reflects controller state with data-presence-paused and
data-presence-open, and
emits presencechange with {snapshot}. Set pause-on="pointer focus" or
call pause(reason) and resume(reason) from the interaction policy
appropriate to the application. Size --floating-presence-hit-span to the
current expanded stack (card height plus a fixed row times count - 1) so the
paused host box matches the cards, not a maximum cap.
Like floating-root, the stack reads its surface from
template[slot="content"]. The default Top Layer is Popover. Use none for
an in-flow surface; Dialog remains available for genuinely modal content but
is not appropriate for Toasts.
Set timeout="0" to keep entries open until close(id) is called.
Elements
| Element | Purpose |
| --- | --- |
| floating-root, floating-reference, floating-item | Reference, surface, placement, and interaction controller |
| floating-content, floating-portal, floating-overlay | Surface mounting, custom targets, and overlay/scroll lock |
| floating-focus-manager, floating-transition, floating-arrow | Focus behavior, presence state, and arrow geometry |
| floating-presence-stack | Declarative timed stack rendering, native Top Layer, pause/resume, exit presence, and removal |
| floating-tree, floating-node | Nested floating surfaces and parent focus restoration |
| floating-list, floating-list-item | Ordered items, typeahead, roving or virtual focus |
| floating-query, floating-results | Query input and rendered search phases/results |
| floating-results-status, floating-results-item, floating-results-more | Composable result phase, item, and pagination parts |
| floating-composite, floating-composite-item | Composite/grid roving focus; item-selector can register existing controls |
| floating-delay-group, next-floating-delay-group | Shared hover/focus delays |
<floating-results> is the preferred phase renderer. The previous
<floating-search> name remains registered as a deprecated compatibility
alias; migrate markup when convenient. The floating-results-* parts replace
the repeated native template[data-search-*] wrapper pattern. Keep
data-search-text and data-search-load-more inside a part when binding a
result field or load-more button, because those are declarative data bindings.
floating-combobox compatibility
<floating-combobox> is deprecated but fully supported for existing native
form workflows. Unlike <floating-query>, it owns selectedItem, input-label
replacement, getItemValue(), name, required, and form reset behavior.
Use it only when those form-associated selected-value semantics are required.
