Floating UI elements for the browser - dropdowns, popovers, tooltips, context menus, modals, pickers and more. One file, no dependencies, no build step.
Install as floater-ui; the library exposes a global called Floater.
Live demo - every type and feature, interactive.
Early build. This was extracted from a WIP feature on dbd-randomizer.com to be shared and reused across other projects. The API is not stable - a lot will likely change before a 1.0 release. Use with that in mind.
Most floating-element libraries give you positioning and leave the rest to you. floater-ui manages the whole lifecycle: a central registry, viewport-aware positioning that survives scrolling and transformed ancestors, trigger wiring (click, hover, focus, right-click, press-and-drag), stacking and focus classes, animations, events, and 17 ready-made content types. Custom types are first-class citizens.
transform/filter ancestors that break position: fixedmodal: true to a single open() call and it behaves as a modal just that oncenpm
npm install floater-ui
CDN
<script src="https://unpkg.com/floater-ui@0.2.4/floater.js"></script>
Manual - download floater.js and include it directly:
<script src="floater.js"></script>
const menu = Floater.create('main-menu', 'dropdown', {
items: [
{ value: 'cut', label: 'Cut' },
{ value: 'copy', label: 'Copy' },
{ value: 'paste', label: 'Paste' },
],
});
menu.on('change', ({ value }) => console.log(value));
Floater.bind('#menu-btn', menu, { trigger: 'click' });
That’s a working dropdown: positioned against the button, flips when near the viewport edge, follows the anchor on scroll, closes on outside click and Escape.
| Type | Description |
|---|---|
dropdown |
List of selectable options |
number-picker |
Preset number values |
text-suggestions |
Autocomplete suggestions for a text input |
popover |
Generic content popover |
chips |
Multi-select chip group |
slider |
One or more range sliders |
input-group |
Form panel with mixed inputs (text, select, checkbox, nested floater types) |
date |
Spinner date picker (YYYY-MM-DD) |
time |
Spinner time picker (HH:MM or HH:MM:SS) |
datetime |
Combined date + time spinner |
fetch |
Fetches a URL and renders the response (auto-detects JSON/image/audio/video) |
audio |
Audio player |
video |
Video player |
image |
Image viewer |
color |
Colour picker: saturation/brightness area, hue and opacity sliders, text field, swatches, recent colours |
persist |
Persistent panel - stays open until explicitly closed; implies persistOnOutsideClick |
modal |
Modal with backdrop; implies modal: true |
context-menu |
Right-click context menu with icons, shortcuts, separators |
Floater.create(id, type, opts)Creates a floater and registers it. If a floater with the same id already exists it is destroyed first. Returns a FloaterInstance.
const tip = Floater.create('my-tip', 'popover', { content: '<p>Hello</p>' });
Floater.attach(id, el, opts)Registers an existing DOM element as a floater instead of building one. The element is wrapped in place, moved into the floater container while open, and returned to its original position on close. destroy() unwraps it back into the page exactly where it started.
const panel = Floater.attach('side-panel', document.querySelector('#panel'), { type: 'dropdown' });
Pass type to get a built-in type’s behaviour wired onto your markup; omit it for arbitrary content. A pre-set hidden attribute on the element is respected, and an element that was visible inline is visible again once the floater closes.
Floater.bind(anchor, instance, opts)Wires a trigger between an anchor and a floater. anchor can be an element, a CSS selector string, a NodeList, or an array; multi-element anchors all share the one floater.
Returns a cleanup function (or an array of them for multiple anchors). Cleanup is also run automatically on instance.destroy().
Floater.bind('#open-btn', menu, { trigger: 'click' });
Floater.bind(document.querySelectorAll('.tip-anchor'), tooltip, { trigger: 'hover' });
Bind opts:
| Opt | Default | Description |
|---|---|---|
trigger |
'click' |
'click' / 'hover' / 'focus' / 'contextmenu' / 'press' / 'none'. 'click' also opens on Enter and Space when the anchor is focused |
hoverDelay |
200 |
ms before hover closes (hover only) |
hoverOpenDelay |
0 |
ms before hover opens (hover only) |
scrollSelect |
- | true to use the type’s built-in scroll handler, or (instance, delta, e) => {} for custom behaviour |
Any positioning/behaviour opts passed to bind() are forwarded to open(), so per-trigger gap, centerX, arrowEl etc. work here too.
trigger: 'press' - opens on mousedown/touchstart; dragging highlights [data-value] items under the pointer; releasing selects the highlighted item.
trigger: 'none' - no open trigger at all; useful for scrollSelect-only bindings or floaters you open programmatically.
scrollSelect - fires on wheel over the anchor or the floater, even while closed. Built-in support: dropdown cycles items, number-picker cycles presets or steps a number (scrollMode: 'step'), date/time/datetime change per-column when the wheel is over a spinner column.
| Method | Description |
|---|---|
Floater.get(id) |
The FloaterInstance for id, or null |
Floater.has(id) |
true if id is registered |
Floater.remove(id) |
Destroys and unregisters the floater |
Floater.getAll([filter]) |
All registered instances matching the filter |
Floater.getOpen([filter]) |
All currently open instances matching the filter |
Floater.closeAll([filter], [opts]) |
Closes matching open floaters |
Filters accept a predicate function (instance) => bool, a type-name string, or an object with any of { type, id, idPrefix, open }:
Floater.getOpen('dropdown');
Floater.getAll({ idPrefix: 'toolbar-' });
Floater.closeAll(i => i.type !== 'modal');
closeAll skips modal and protected floaters unless opts.includeProtected is true.
Floater.setDefaults(opts) / Floater.setDefaults(type, opts)Sets default opts globally or for one type. Applied at create()/attach() time; the full priority chain (lowest to highest) is global defaults → type defaults → instance opts → open-time opts.
Floater.setDefaults({ gap: 8, animateIn: 'fade', animateOut: 'fade' });
Floater.setDefaults('dropdown', { minWidth: 120 });
Floater.on(event, fn) / Floater.off(event, fn)Global event hooks that fire for every instance, lifecycle and custom events alike. fn receives (instance, payload).
Floater.on('show', (instance, { trigger }) => console.log(`${instance.id} opened via ${trigger}`));
Floater.registerType(name, def)Registers a custom type. See Custom types.
Floater.registerAnimation(name, def)Registers a custom animation. See Animations.
Floater.logger(fn, [filter])Attaches a logger. fn receives (level, message, data) where level is 'event', 'info', 'warn', or 'error'. filter narrows it: a level string, an array of levels, or a predicate (level, msg, data) => bool.
Floater.logger(console.log); // everything
Floater.logger(console.log, 'event'); // lifecycle + custom events only
Floater.logger(console.log, ['warn', 'error']);
Independent of any logger, warnings and errors (API misuse, crashed event handlers, bad animation names, a fetch floater with no URL) always go to the browser console with a [Floater] prefix. Misuse is never silent.
| Property | Description |
|---|---|
id |
The id the floater was created with |
el |
The floater DOM element (.fs-floater wrapper) |
type |
The type name |
isOpen |
true while open |
opts |
Current instance opts object |
instance.open(anchor, [openOpts])Opens the floater positioned relative to anchor: an element, a MouseEvent, or a {x, y} / {clientX, clientY} / {left, top} point. No anchor positions at the viewport origin.
openOpts layer over the instance opts for this open only; open(btn, { modal: true }) gives one modal open of an otherwise normal floater, including the backdrop and outside-click immunity.
instance.close([closeOpts])Closes the floater. No-op if already closed.
instance.toggle(anchor, [opts])Opens if closed, closes if open.
instance.reposition()Re-fits an open floater against the anchor and opts it was opened with, for when its content has changed size. Emits nothing, replays no animation and keeps the floater’s scroll position. No-op while closed. Chainable.
instance.update(opts)Merges opts into the instance opts and calls the type’s onUpdate hook; built-in types rebuild their content when you pass new items/presets/value etc. Marker highlights are reapplied automatically after a rebuild. Chainable.
instance.on(event, fn) / instance.off(event, fn)Subscribe/unsubscribe. fn receives (payload, instance). A handler that throws is logged as an error and does not stop later handlers. Chainable.
instance.emit(event, data)Emits a custom event on the instance; instance handlers, global Floater.on() handlers, and the logger all see it. Chainable.
instance.destroy()Closes (skipping any exit animation), runs all registered cleanups, unregisters, and removes the element from the DOM. Attached floaters are unwrapped back into their original position. Calling open() on a destroyed instance warns instead of half-working.
Full lifecycle, in firing order:
| Event | Emitted when | Data |
|---|---|---|
create |
Instance created via create()/attach() |
{} |
trigger |
An open is initiated | { anchor, trigger } |
show |
Opened and positioned | { anchor, opts, trigger } |
shown |
Enter animation finished (immediately if none) | { trigger } |
update |
update() called |
the opts passed to update() |
hide |
Close initiated | { opts, trigger } |
hidden |
Exit animation finished, element hidden | { trigger } |
destroy |
Destroyed | {} |
Type-specific events:
| Event | Emitted when | Data |
|---|---|---|
change |
Value selected/changed | { value, ... } (type-dependent) |
input |
Field input in input-group |
{ name, value, type, el } |
input |
Live colour while dragging or typing in color |
{ value, hex, color, format, source } |
loaded |
Content loaded (fetch/image) |
{ url } or { src } |
error |
Load error (fetch/image) |
{ url/src, error } |
timeupdate |
Playback progress (audio/video) |
{ currentTime, duration } |
ended |
Playback finished (audio/video) |
{} |
Every payload also carries id, type, opts, and trigger. The trigger is how the event came about: 'click', 'hover', 'focus', 'contextmenu', 'press', 'keyboard', 'escape', 'scroll', or 'programmatic'.
menu.on('change', ({ value }) => console.log('selected:', value));
menu.on('hidden', ({ trigger }) => console.log('closed via', trigger));
Set onChange: false on an instance to suppress its change/input emissions entirely.
These can be passed to create(), attach(), setDefaults(), or per-call to open()/toggle()/bind().
| Opt | Default | Description |
|---|---|---|
gap |
4 |
px gap between anchor and floater |
preferAbove |
false |
Open above the anchor when there is room |
preferRight |
false |
Open to the right (side mode) |
preferLeft |
false |
Open to the left (side mode) |
centerX |
false |
Centre horizontally on the anchor |
centerY |
false |
Centre vertically on the anchor (side mode) |
minWidth |
anchor width | Minimum width in px |
fitContent |
false |
Size to content instead of matching the anchor width |
arrowEl |
- | An element to rotate to point at the floater (e.g. a ▶ in the anchor) |
boundary |
'viewport' |
What the floater has to stay inside. 'clip' uses the anchor’s scrollable ancestors, so a floater in a scroll box shifts and shrinks to stay within it instead of spilling over the edge; an element or array of elements names the box yourself |
boundaryMinSize |
24 |
With a boundary, the floater hides rather than shrink below this many px |
Positioning is viewport-aware: the floater flips to the other side when space runs out, gets capped and scrollable when taller than the viewport, and accounts for ancestor transform/filter/backdrop-filter that would otherwise break position: fixed.
Boundary: floaters render in a fixed container at body level, so by default nothing but the viewport limits them and one anchored inside a small scroll box can overhang it. That is usually what you want for a dropdown in a cramped panel. boundary: 'clip' is for the opposite case: it stays inside the box, and hides when the box has no room for it. hideOnAnchorClip still applies, so with it off the floater stays pinned inside the box after its anchor has scrolled away.
Scroll behaviour: by default an open floater follows its anchor as the page (or any scrollable ancestor) scrolls, even if the anchor is removed from the DOM mid-scroll. Set closeOnScroll: true to close instead. modal and protected floaters ignore scroll entirely.
| Opt | Description |
|---|---|
modal |
Shows backdrop; immune to outside-click and scroll close; backdrop click and Escape close the topmost non-protected modal |
blockScroll |
Locks body scroll while open; ref-counted across stacked modals; restores the previous overflow value |
blockClicks |
Backdrop absorbs clicks entirely and Escape is ignored; dismiss via the floater’s own UI only |
persistOnOutsideClick |
Outside clicks and clicks inside other floaters do nothing; scroll still repositions |
protected |
Same immunity as modal but no backdrop; Escape ignored |
closeOnOutsideClick: false |
Outside clicks don’t close, but scroll still repositions |
closeOnScroll: true |
Closes on scroll instead of repositioning |
hideOnAnchorClip: false |
Keeps the floater rendering after its anchor scrolls out of a clipping ancestor; by default it hides until the anchor scrolls back |
closeOthers: false |
Don’t close other floaters when this one opens |
closeOthersImmune |
Skip this floater when another floater’s closeOthers runs |
closeOnFloaterClick: false |
Stay open when a click lands inside a different floater |
closeOnEscape: false |
Don’t close on Escape |
closeButton |
Injects an × button into the floater (closeButtonLabel, closeButtonTitle to customise) |
toFrontOn |
'click' (default) / 'hover' / 'none' - z-index bump on interaction |
noContextMenu |
Blocks a global context menu from opening over this floater |
onChange: false |
Suppress change/input events for this instance |
Escape closes the topmost eligible floater by z-index, one per press, so stacked floaters unwind in visual order.
The topmost open floater gets the class fs-focused; every other open one gets fs-blurred. Both are unstyled hooks: style them yourself (dim blurred floaters, glow the focused one, whatever fits).
currentValue / preferenceValue highlight matching [data-value] items with layout-inert classes (fs-opt-current / fs-opt-preferred: inset box-shadows, no reflow) plus a tooltip. Works in dropdown, number-picker, text-suggestions, context-menu, and nested input-group types.
| Opt | Default | Description |
|---|---|---|
currentValue |
- | Marks the currently active item |
preferenceValue |
- | Marks the preferred/default item |
currentTitle |
'Active' |
Tooltip for the current marker |
preferenceTitle |
'Default' |
Tooltip for the preference marker |
valueAttr |
'data-value' |
Attribute matched against |
Markers survive content rebuilds; update({ items }) reapplies them.
The essentials per type; see the demo source for every combination in use.
| Type | Key opts |
|---|---|
dropdown |
items: [{ value, label, selected? }] |
number-picker |
presets: [], scrollMode: 'presets' \| 'step', step, min, max |
text-suggestions |
suggestions: [] - strings or { value, label } |
popover / persist / modal |
content - an HTML string |
chips |
items, selectedValues, preferenceValues, maxSelect, closeOnMax |
slider |
sliders: [{ name, label, min, max, step, value }] |
input-group |
inputs: [{ type, name, label, value, ... }] - native input types, select, textarea, checkbox, or any registered floater type nested as a sub-field |
date / time / datetime |
value, step (seconds; < 60 shows a seconds column), showArrows, showPicker, allowTyping, scrollPanel, scrollCols |
fetch |
url, fetchMethod: 'fetch' \| 'xhr', responseType: 'auto' \| 'json' \| 'image' \| 'video' \| 'audio', transform(text, contentType) |
audio / video |
src, autoplay, rememberProgress, resetOnOpen, resetOnClose (+ poster, muted, width for video) |
image |
src, alt, caption, width |
color |
see Colour picker |
context-menu |
items: [{ value, label, icon?, shortcut?, danger?, disabled? } \| { separator: true }], global: true to take over right-click for the whole page, excludeSelectors |
Notes:
showPicker), and columns support typing, arrow buttons, and scroll-wheel changes with proper carry (minutes roll hours, month lengths respected).fetch escapes text responses before rendering; only your own transform output is treated as trusted HTML. Load failures render an error panel and emit error.input-group nests other floater types as form fields; their events are re-emitted through the parent with name and subType attached.Floater.create(id, 'color') with no opts is a complete picker: drag the area, slide hue and opacity, or type any colour the browser understands (#rgb, rgb(), hsl(), hwb(), names). Everything else is opt-in.
const picker = Floater.create('accent', 'color', { value: '#3b82f6', swatches: ['#ef4444', '#22c55e', '#3b82f6'] });
picker.on('change', ({ value }) => document.body.style.setProperty('--accent', value));
Floater.bind('#accent-btn', picker);
// Bound to a text input it reads the field on open, writes back as you pick and follows what you type
Floater.bind('#accent-input', Floater.create('accent-field', 'color'), { trigger: 'focus' });
| Opt | Default | Description |
|---|---|---|
value |
'#ff0000' |
Starting colour, in any syntax the browser parses; '' starts cleared |
format |
'hex' |
Output format: 'hex' / 'rgb' / 'hsl', 'auto' (follow the format of whatever was last set or typed) or 'mixed' (hex while opaque, rgba once there is opacity) |
formatToggle |
false |
Button that cycles the output format |
formats |
['hex', 'rgb', 'hsl'] |
What the toggle cycles through |
colorSyntax |
'legacy' |
'modern' writes rgb(r g b / a) instead of rgba(r, g, b, a) |
alpha |
true |
Opacity slider; false forces every colour opaque |
forceAlpha |
false |
Always write the alpha channel, even at full opacity |
swatches |
[] |
Preset colours: strings or { value, label } |
swatchesOnly |
false |
Swatches with none of the picking controls |
closeOnSwatch |
swatchesOnly |
Close once a swatch is picked |
recent |
false |
Row of recently picked colours: true keeps 8, a number sets the count. A colour is added when the picker closes on a new value |
recentKey |
- | localStorage key to keep recent colours across page loads and share them between pickers |
showInput / showPreview |
true |
The text field / the new-vs-original preview (click the original half to go back to it) |
focusInput / selectInput |
false |
Focus, or focus and select, the text field on open |
closeOnEnter |
true |
Enter in the text field commits and closes |
eyedropper |
false |
Screen colour picker button, where the browser has EyeDropper (Chromium) |
clearButton / doneButton |
false |
Footer buttons; clearLabel, doneLabel and eyedropperLabel set their text |
closeOnClear |
true |
Close after Clear |
changeOn |
'release' |
When change fires: 'release' (drag end, swatch, Enter), 'input' (every step) or 'close' (once, if the colour differs from when it opened). Wheeling a closed picker counts as a release under 'close', since no close is coming |
inputOn |
'input' |
When input fires, with the same values as changeOn plus 'none'; inputOn: 'close' keeps a picker silent until it closes while its own preview still moves live |
revertOnEscape |
true |
Escape puts back the colour the picker opened with; close({ revert: true }) does the same from code |
syncInput |
true |
Two-way sync with the anchor when it is a text input, including native input/change events on it |
anchorVar |
- | Custom property to keep set to the current colour on the anchor, e.g. '--fs-color' to drive a thumbnail |
scrollChannel |
'auto' |
What the wheel moves with scrollSelect: 'hue', 'saturation', 'brightness', 'lightness', 'alpha', or 'auto' to follow the control under the pointer (hue slider, opacity slider, the area turns brightness and a sideways swipe over it saturation, anywhere else hue). Saturation and brightness are the area’s own axes (HSV), lightness is HSL’s. A function (event, instance) => channel picks per notch |
scrollStep |
5 |
Per wheel notch, in degrees for hue and percent for the rest; a number for every channel or an object such as { hue: 10, alpha: 2 }, where missing channels fall back to 5 |
scrollStepShift |
4 |
Multiplier for the step while Shift is held |
scrollInvert |
false |
Flip the wheel direction (up raises by default) |
scrollWrap |
true |
Hue wraps round past 0/360; false stops at the ends. The other channels always stop at 0 and 100% |
labels |
- | Accessible names: area, hue, alpha, input, format, eyedropper, revert, swatch, recent |
input fires live while dragging or typing unless inputOn says otherwise, change per changeOn. Both carry { value, hex, color: { r, g, b, a, h, s, v }, format, source }, where source names what moved it (area, hue, alpha, input, swatch, recent, eyedropper, format, clear, revert, anchor, scroll, update, close). A cleared picker reports value: '' and color: null.
The area takes arrow keys (Shift for bigger steps) and the sliders are native range inputs, so the whole picker works from the keyboard. Sizing is by custom property: --fs-color-width, --fs-color-area-height, --fs-color-swatch-size, and --fs-color-checker for the transparency pattern.
Opt-in per instance or via defaults. Built-in presets: fade, scale, slide-down, slide-up, slide-left, slide-right, spring, flip, unfold, shift-away, shift-toward.
shift-away and shift-toward are directional: they read the side the floater actually opened on and shift on that axis, away from the anchor or toward it. unfold grows from the top edge.
| Opt | Description |
|---|---|
animateIn / animateOut |
Preset or registered animation name |
animationDuration / animationEasing |
Shared duration (ms) / easing |
animateInDuration, animateInEasing, animateOutDuration, animateOutEasing |
Per-direction overrides |
Floater.create('tip', 'popover', {
content: '<p>Hi</p>',
animateIn: 'slide-down',
animateOut: 'fade',
animateOutDuration: 100,
});
Custom animations are a pair of CSS classes you own. The library applies the class, forces a reflow so the start frame renders, transitions to the resting state, and cleans up on transitionend:
.my-swing {
opacity: 0;
transform: rotate(-7deg) translateY(-16px);
}
Floater.registerAnimation('swing', {
inClass: 'my-swing',
outClass: 'my-swing',
duration: 260,
easing: 'cubic-bezier(.2,.8,.2,1)',
});
Animations can be swapped at runtime with instance.update({ animateIn: 'scale' }), no recreate needed.
Custom types get everything the built-ins get: positioning, triggers, events, markers, animations, scroll handling.
Floater.registerType('my-type', {
// Required: build and return the floater's content. The outer .fs-floater
// wrapper (id, className, hidden, noContextMenu) is handled by the system -
// don't create it yourself.
build(opts) {
const content = document.createElement('div');
content.textContent = opts.message || '';
return content;
},
// Optional: called once after the instance is created; wire events here.
// Delegate to instance.el rather than child elements so listeners survive
// content rebuilds from update().
init(instance, opts) {},
// Optional: called before open(); return false to cancel
onOpen(instance, anchor, opts) {},
// Optional: called on close
onClose(instance, opts) {},
// Optional: called on instance.update()
onUpdate(instance, updateOpts) {},
// Optional: called on outside click (default behaviour is instance.close())
onClickOutside(instance) { instance.close(); },
// Optional: called when scrollSelect fires; delta is +1 (down) or -1 (up)
onScrollSelect(instance, delta, event) {},
// Optional: called on press-trigger item release (default is el.click())
onPressSelect(instance, el) {},
// Optional type-level flags
zPriority: 0, // z-index boost on top of the global counter
closeOthersImmune: false, // skip this type when other floaters close others
});
All library classes are prefixed fs- and custom properties --fs- to stay out of your page’s way.
This library is licensed under the MIT.