Manual UI Behaviors float

float

elements man ui/behaviors/floating

Places a floating element (a popover, menu, tooltip or listbox) against an anchor, keeps it there while the page scrolls or resizes, and flips or shifts it to stay on screen. float positions and follows until stopped; computeFloat finds the position once without moving anything.

Signatures
function float(anchor: Anchor, floating: HTMLElement, opts?: FloatOptions): FloatStop; function computeFloat(anchor: Anchor, floating: HTMLElement, opts?: FloatOptions, onSize?: (size: FloatSize) => void): FloatPlacement; type Anchor = Element | { getBoundingClientRect(): DOMRect; contextElement?: Element }; type FloatStop = (() => void) & { update(): void }; type Side = "top" | "right" | "bottom" | "left"; type LogicalSide = Side | "inline-start" | "inline-end"; type Align = "start" | "center" | "end";

The anchor may be any object with getBoundingClientRect(), such as a point under the pointer, with an optional contextElement that scroll containers and direction are read from. The returned stop also has update(), which places the element again at once: a pointer anchor that moved, content the observers cannot see.

Options

Option Type Default What it does
side LogicalSide "bottom" Preferred side. inline-start and inline-end follow the anchor's direction.
align Align "center" Alignment along that side. start and end follow the writing direction on the top and bottom sides.
offset number | OffsetFn 4 Gap between anchor and floating element, px, or a function of the placement being tried.
alignOffset number | OffsetFn 0 Shift along the side, px, or a function of the placement. Ignored when align is center.
flip boolean true Move to the opposite side when there is no room.
crossAxis boolean false Flip also when the box overflows along its side: try the other alignment first, so a context menu near the bottom edge ends at the press instead of sliding over it.
crossAxisFallback boolean true When nothing fits on the preferred side or its opposite, try the two sides of the other axis too. false keeps the box on its axis, where the size step shrinks it, as a menu does.
crossAxisFallbackSide "start" | "end" "end" Which side of the other axis that fallback tries first: "end" tries right before left (left first under rtl) and below before above; "start" the reverse.
sideShift boolean false Keep the side and slide the box along the side axis back into the boundary, over the anchor if it must. Pair it with flip: false.
shift boolean true Slide along the side to stay in view.
padding number | Partial<Record<Side, number>> 8 Distance kept from the boundary edges, px, or per side; a side left out keeps 8.
matchWidth boolean false Set min-width to the anchor's width.
arrow HTMLElement | null none An arrow element inside the floating element to point at the anchor.
arrowPadding number 6 px the arrow keeps from the floating element's corners.
sticky "partial" | "always" "partial" "partial" lets shift stop when the anchor leaves; "always" keeps the element in the boundary on both axes, so an anchor scrolled past an edge leaves its box at that edge.
boundary Element[] the viewport Elements whose edges count as collision edges, besides the viewport.
updatePositionStrategy "optimized" | "always" "optimized" "optimized" updates on scroll, resize and layout changes; "always" every frame.
hideWhenDetached boolean true Hide (visibility) when the anchor scrolls out of view.
strategy "fixed" | "absolute" "fixed" The CSS position used.
onPlaced (side: Side, align: Align) => void none Called after each placement with the final side and align.
autoPlacement boolean | { allowedSides?: Side[] } false Pick the side with the most room instead of a preferred one. allowedSides limits the choice; flip is skipped, shift still applies.
inline boolean | { x?: number; y?: number } false The anchor is inline text over several lines: place against the line facing the side, not the whole block. Top uses the first line, bottom the last; a point picks the line under it.
middleware FloatMiddleware[] none Own steps run after shift on every pass, in order. Each gets a MiddlewareState (x, y, side, align, anchor and floating boxes in viewport px) and may return a new x or y.

An OffsetFn gets OffsetData: the side and align being tried and the anchor and floating sizes in CSS px, and returns px, so a gap can differ per side.

How a position is found

Each step sees the previous one's result. Base: the box beside the anchor on side, aligned by align, pushed offset away and alignOffset along. Flip: the preferred placement, then the same side with the other alignment, the opposite side, and the two sides of the other axis; the first that fits wins, else the one that overflows least. Shift: slide along the side into the boundary, keeping padding. Size: the room left on each axis, written as CSS variables; when that changes the box's size the steps run again. Arrow: centered on the anchor, kept arrowPadding from the corners. Hidden: whether the anchor is clipped out of every scroll container.

In the top layer (popover="manual", <dialog>) left and top are viewport coordinates; under a transformed or contained ancestor they are converted into that ancestor's box, and under a scaled ancestor the gap, room and arrow are corrected for the scale.

Writes

On the floating element What it is
position, left, top The placement, in the strategy given.
data-side, data-align The side and alignment it ended up on, after flip.
data-anchor-hidden Present while the anchor is scrolled out of view; visibility: hidden goes with it.
--anchor-width, --anchor-height The anchor's size, for min-width: var(--anchor-width) on a listbox.
--available-width, --available-height Room left before the boundary edge, for max-height: var(--available-height) on a menu.
--transform-origin The anchor-facing edge, for a scale-in animation.

On the arrow: left or top, data-side, and data-uncentered when the arrow cannot point at the anchor's middle.

computeFloat returns a FloatPlacement instead of writing: x, y, side, align, availableWidth, availableHeight, anchorWidth, anchorHeight, arrow (its offset, or null), uncentered and anchorHidden. The one thing it sets is position on the floating element and on the arrow, so the measure is right.

In a template of your own

app/shared/templates/hint/index.ehtml
import { float, type FloatStop } from "@elements/ui/behaviors"; interface HintState { anchor?: HTMLElement; stop?: FloatStop; } <Hint (text: string, private open: boolean = false, private ui: HintState = {})> <span class="hint-anchor" oninsert={(e) => ui.anchor = e.target as HTMLElement} onpointerenter={() => open = true} onpointerleave={() => open = false}><slot/></span> <div e:if={open} class="hint" role="tooltip" oninsert={(e) => ui.stop = float(ui.anchor!, e.target as HTMLElement, { side: "top", offset: 6 })} onremove={() => ui.stop?.()}>{text}</div> </Hint>
app/shared/templates/hint/style.css
.hint { transform-origin: var(--transform-origin); max-width: var(--available-width); }

Used by

float: DropdownMenu (opens in a new tab), ContextMenu (opens in a new tab), Menubar (opens in a new tab), Message (opens in a new tab), NavigationMenu (opens in a new tab), Popover (opens in a new tab), RichText (opens in a new tab), Select (opens in a new tab), TagsInput (opens in a new tab), Toast (opens in a new tab). computeFloat: Select (opens in a new tab), to line the selected option up over the trigger.