Domphy

@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:

  1. computePosition — calculates the x/y coordinates to place a floating element next to a reference element.
  2. Middleware — functions that modify those coordinates. offset adds a gap, flip avoids viewport overflow, shift nudges the element back into view.
  3. 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 (not display: none) so computePosition can measure its size even when hidden.
  • The floating element has position: "fixed" — this must match the strategy: "fixed" passed to computePosition.
  • autoUpdate is 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()] }
)
FieldTypeDescription
xnumberLeft offset to apply to the floating element
ynumberTop offset to apply to the floating element
placementPlacementThe final placement after middleware resolution
strategyStrategy"absolute" or "fixed"
middlewareDataMiddlewareDataPer-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.