autoUpdate
computePosition is a one-shot calculation. autoUpdate subscribes to relevant DOM events and calls your update function whenever the position may have changed. It returns a cleanup function you must call when the floating element is removed.
import { computePosition, autoUpdate, offset, flip, shift } from "@domphy/floating"
function update() {
computePosition(reference, floating, {
placement: "bottom",
middleware: [offset(8), flip(), shift()],
strategy: "fixed",
}).then(({ x, y }) => {
Object.assign(floating.style, { left: `${x}px`, top: `${y}px` })
})
}
const cleanup = autoUpdate(reference, floating, update)
// Later, when the floating element is hidden or removed:
cleanup()Signature
function autoUpdate(
reference: ReferenceElement,
floating: HTMLElement | null,
update: () => void,
options?: AutoUpdateOptions,
): () => voidOptions
autoUpdate(reference, floating, update, {
ancestorScroll: true, // update on ancestor scroll events (default true)
ancestorResize: true, // update on ancestor resize events (default true)
elementResize: true, // update via ResizeObserver on both elements (default true)
layoutShift: true, // update via IntersectionObserver on layout shift (default true)
animationFrame: false, // update every rAF — for transform animations (default false)
})ancestorScroll
Listens for scroll events on all overflow ancestors of both the reference and floating elements. Enabled by default. Disable only if neither element is inside a scrollable container.
ancestorResize
Listens for resize events on overflow ancestors. Covers window resize and any resizable container.
elementResize
Uses ResizeObserver to watch both elements directly. Triggers when the reference or floating element grows or shrinks — for example when content loads inside the floating panel.
layoutShift
Uses IntersectionObserver to detect when the reference moves because content was inserted above it in the page flow. Slightly more expensive than the scroll/resize listeners.
animationFrame
Polls every requestAnimationFrame. Use this only when the reference element is animated with CSS transform — transforms do not trigger ResizeObserver or IntersectionObserver.
autoUpdate(reference, floating, update, { animationFrame: true })Domphy Pattern
Start autoUpdate in _onMount and clean up in _onBeforeRemove:
import { toState } from "@domphy/core"
import { computePosition, autoUpdate, offset, flip, shift } from "@domphy/floating"
const open = toState(false)
let reference: HTMLElement | null = null
let floating: HTMLElement | null = null
let cleanup: (() => void) | null = null
function updatePosition() {
if (!reference || !floating) return
computePosition(reference, floating, {
placement: "bottom",
middleware: [offset(8), flip(), shift({ padding: 4 })],
strategy: "fixed",
}).then(({ x, y }) => {
Object.assign(floating!.style, { left: `${x}px`, top: `${y}px` })
})
}
const FloatingEl = {
div: "Content",
style: {
position: "fixed",
visibility: (l) => open.get(l) ? "visible" : "hidden",
},
_onMount: (node) => {
floating = node.domElement as HTMLElement
if (reference) cleanup = autoUpdate(reference, floating, updatePosition)
},
_onBeforeRemove: () => {
cleanup?.()
cleanup = null
},
}
const Trigger = {
button: "Toggle",
onClick: () => open.set(!open.get()),
_onMount: (node) => {
reference = node.domElement as HTMLElement
if (floating) cleanup = autoUpdate(reference, floating, updatePosition)
},
}
const App = { div: [Trigger, FloatingEl] }Only call autoUpdate when the floating element is visible. If you are using visibility: hidden to hide (rather than removing the element from the DOM), you can start autoUpdate once on mount and leave it running — it is lightweight enough for always-mounted panels.
Virtual Elements
A virtual element is any object with a getBoundingClientRect() method. Use it when there is no real DOM element to anchor to — a mouse cursor, a canvas region, a Range selection, or a custom coordinate.
import type { VirtualElement } from "@domphy/floating"
import { computePosition, offset } from "@domphy/floating"
const virtualElement: VirtualElement = {
getBoundingClientRect() {
return {
x: 100, y: 200,
width: 0, height: 0,
top: 200, left: 100, right: 100, bottom: 200,
}
},
}
computePosition(virtualElement, floating, {
placement: "bottom",
middleware: [offset(8)],
strategy: "fixed",
})contextElement
Set contextElement to the nearest real DOM element so clipping boundary detection works correctly:
const virtualElement: VirtualElement = {
getBoundingClientRect() { return { /* ... */ } },
contextElement: containerEl,
}Follow the Cursor
Position a tooltip at the mouse pointer:
import type { VirtualElement } from "@domphy/floating"
import { computePosition, offset } from "@domphy/floating"
let mouseX = 0
let mouseY = 0
const cursor: VirtualElement = {
getBoundingClientRect() {
return {
x: mouseX, y: mouseY,
width: 0, height: 0,
top: mouseY, left: mouseX,
right: mouseX, bottom: mouseY,
}
},
}
const TooltipEl = {
div: "Cursor tooltip",
style: { position: "fixed", pointerEvents: "none" },
_onMount: (node) => {
floating = node.domElement as HTMLElement
document.addEventListener("mousemove", (event) => {
mouseX = event.clientX
mouseY = event.clientY
computePosition(cursor, floating!, {
placement: "right-start",
middleware: [offset(8)],
strategy: "fixed",
}).then(({ x, y }) => {
Object.assign(floating!.style, { left: `${x}px`, top: `${y}px` })
})
})
},
}Text Selection Range
Position a formatting toolbar above a text selection:
import type { VirtualElement } from "@domphy/floating"
import { computePosition, flip, offset } from "@domphy/floating"
const selectionEl: VirtualElement = {
getBoundingClientRect() {
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) {
return { x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0 }
}
return sel.getRangeAt(0).getBoundingClientRect()
},
}
document.addEventListener("selectionchange", () => {
computePosition(selectionEl, toolbar, {
placement: "top",
middleware: [offset(8), flip()],
strategy: "fixed",
}).then(({ x, y }) => {
Object.assign(toolbar.style, { left: `${x}px`, top: `${y}px` })
})
})hide
hide provides flags to conditionally hide the floating element when the reference is scrolled out of view or when the floating element escapes its clipping context.
import { computePosition, offset, flip, shift, hide } from "@domphy/floating"
const { middlewareData } = await computePosition(reference, floating, {
placement: "bottom",
middleware: [offset(8), flip(), shift(), hide()],
})
const { referenceHidden } = middlewareData.hide!
floating.style.visibility = referenceHidden ? "hidden" : "visible"Strategies
// "referenceHidden" (default): hide when the reference is scrolled out of its clipping boundary
hide({ strategy: "referenceHidden" })
// "escaped": hide when the floating element leaves the reference's clipping context
hide({ strategy: "escaped" })Use both strategies at once by including hide twice:
middleware: [
offset(8),
flip(),
shift(),
hide({ strategy: "referenceHidden" }),
hide({ strategy: "escaped" }),
]Then read both flags:
const { referenceHidden, escaped } = middlewareData.hide!
floating.style.visibility = (referenceHidden || escaped) ? "hidden" : "visible"Reactive Hide
Store the hidden state in reactive state so the UI updates:
import { toState } from "@domphy/core"
const isHidden = toState(false)
function updatePosition() {
computePosition(reference, floating, {
middleware: [offset(8), flip(), shift(), hide()],
strategy: "fixed",
}).then(({ x, y, middlewareData }) => {
Object.assign(floating.style, { left: `${x}px`, top: `${y}px` })
isHidden.set(!!middlewareData.hide?.referenceHidden)
})
}
const FloatingEl = {
div: "Content",
style: {
position: "fixed",
visibility: (l) => isHidden.get(l) ? "hidden" : "visible",
opacity: (l) => isHidden.get(l) ? "0" : "1",
},
}TypeScript
import type {
AutoUpdateOptions,
VirtualElement,
ReferenceElement,
HideOptions,
} from "@domphy/floating"
const updateOptions: AutoUpdateOptions = {
ancestorScroll: true,
ancestorResize: true,
elementResize: true,
layoutShift: false, // disable if reference is fixed-position
animationFrame: false,
}
const hideConfig: HideOptions = {
strategy: "referenceHidden",
padding: 4,
}