@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 @domphy/core@domphy/core is a peer dependency.
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),
fontSize: "0.875rem",
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.