@domphy/floating
@domphy/floating places any floating element — tooltip, popover, dropdown, context menu — next to a reference element. It handles viewport clipping, scrolling, resizing, and placement flips automatically.
The package is a direct vendor of Floating UI DOM with zero extra dependencies and the same API surface.
Install
npm install @domphy/floating<script src="https://unpkg.com/@domphy/floating/dist/floating.global.js"></script>@domphy/floating is framework-agnostic and has zero dependencies — it does not depend on @domphy/core. The tooltip example below also uses @domphy/core (ElementNode, toState) and @domphy/theme; install those separately if you want to follow along, but they are not required to use @domphy/floating on its own.
Live Example
createFloating (the Popper-style manager) positioning a chip against a reference box — switch placements and scroll the preview to see flip()/shift() react:
Core Concepts
There are three things to learn:
computePosition— calculates thex/ycoordinates to place a floating element next to a reference element.- Middleware — functions that modify those coordinates.
offsetadds a gap,flipavoids viewport overflow,shiftnudges the element back into view. autoUpdate— keeps the position current while the user scrolls or resizes. Returns a cleanup function you must call when the floating element is removed.
First Example — Tooltip
A working tooltip in plain Domphy objects:
import { ElementNode, toState } from "@domphy/core"
import { themeColor, themeSpacing } from "@domphy/theme"
import {
computePosition,
autoUpdate,
offset,
flip,
shift,
type Placement,
} from "@domphy/floating"
const open = toState(false)
let reference: HTMLElement | null = null
let floating: HTMLElement | null = null
let cleanup: (() => void) | null = null
function startPositioning() {
if (!reference || !floating) return
cleanup?.()
cleanup = autoUpdate(reference, floating, () => {
computePosition(reference!, floating!, {
placement: "top",
middleware: [offset(8), flip(), shift({ padding: 4 })],
strategy: "fixed",
}).then(({ x, y }) => {
Object.assign(floating!.style, { left: `${x}px`, top: `${y}px` })
})
})
}
const Tooltip = {
div: "Tooltip text",
style: {
position: "fixed",
padding: themeSpacing(1),
backgroundColor: (l) => themeColor(l, "shift-11", "neutral"),
color: (l) => themeColor(l, "shift-1", "neutral"),
borderRadius: themeSpacing(1),
pointerEvents: "none",
visibility: (l) => open.get(l) ? "visible" : "hidden",
},
_onMount: (node) => {
floating = node.domElement as HTMLElement
if (reference) startPositioning()
},
_onBeforeRemove: () => {
cleanup?.()
cleanup = null
},
}
const App = {
div: [
{
button: "Hover me",
style: { padding: themeSpacing(2) },
onMouseEnter: () => { open.set(true); startPositioning() },
onMouseLeave: () => { open.set(false); cleanup?.(); cleanup = null },
_onMount: (node) => {
reference = node.domElement as HTMLElement
if (floating) startPositioning()
},
},
Tooltip,
],
}
new ElementNode(App).render(document.getElementById("app")!)Three things to notice:
- The floating element uses
visibility(notdisplay: none) socomputePositioncan measure its size even when hidden. - The floating element has
position: "fixed"— this must match thestrategy: "fixed"passed tocomputePosition. autoUpdateis started on mount and cleaned up in_onBeforeRemove.
What computePosition Returns
const { x, y, placement, strategy, middlewareData } = await computePosition(
reference,
floating,
{ placement: "bottom", middleware: [offset(8), flip(), shift()] }
)| Field | Type | Description |
|---|---|---|
x | number | Left offset to apply to the floating element |
y | number | Top offset to apply to the floating element |
placement | Placement | The final placement after middleware resolution |
strategy | Strategy | "absolute" or "fixed" |
middlewareData | MiddlewareData | Per-middleware output (arrow position, overflow info, etc.) |
Apply x and y directly to the element's style.left and style.top:
Object.assign(floating.style, { left: `${x}px`, top: `${y}px` })Middleware Order
Order matters. The conventional order is:
middleware: [
offset(8), // 1. add gap first
flip(), // 2. flip to opposite side if needed
shift(), // 3. nudge back inside the viewport
arrow({ element: arrowEl }), // 4. position the arrow last
]See the dedicated pages for each middleware.
Strategy
strategy controls the CSS position property of the floating element. The value you pass must match what you put on the element.
// strategy: "absolute" (default) — use when floating is inside the same scroll context
{ div: "Floating", style: { position: "absolute" } }
computePosition(reference, floating, { strategy: "absolute" })
// strategy: "fixed" — use when floating is in a portal or the root document
{ div: "Floating", style: { position: "fixed" } }
computePosition(reference, floating, { strategy: "fixed" })For most portal-based UIs (popovers, dropdowns), "fixed" is the right choice.
Additional middleware
inline
For reference elements that span multiple lines (hyperlinks, text selections, range selections), inline improves position accuracy by basing placement on the hovered/selected line boundary instead of the full bounding rect.
import { computePosition, inline, offset } from "@domphy/floating"
computePosition(reference, floating, {
middleware: [inline(), offset(8)],
})Useful when the reference is a <span> inside a paragraph that wraps.
detectOverflow
Low-level utility used internally by flip, shift, and hide. Returns a SideObject with the overflow amount (in px) on each side relative to the boundary. Positive = outside boundary, negative = inside.
import { detectOverflow } from "@domphy/floating"
import type { MiddlewareState } from "@domphy/floating"
const myMiddleware = {
name: "myMiddleware",
async fn(state: MiddlewareState) {
const overflow = await detectOverflow(state)
// overflow.top, overflow.right, overflow.bottom, overflow.left
return {}
},
}Use detectOverflow inside a custom middleware when you need raw overflow data before applying your own transform.