arrow
arrow computes the offset of an arrow element inside the floating element. It does not render anything — you are responsible for the markup and CSS. arrow only tells you where to place the arrow within the floating element.
Setup
Pass the arrow's DOM element via _onMount:
import { computePosition, offset, flip, shift, arrow } from "@domphy/floating"
let arrowEl: HTMLElement | null = null
computePosition(reference, floating, {
placement: "bottom",
middleware: [
offset(12), // leave room for the arrow
flip(),
shift({ padding: 4 }),
arrow({ element: arrowEl! }), // arrow goes last
],
})Capture the arrow element in a _onMount hook on the arrow node:
const Arrow = {
div: null,
style: {
position: "absolute",
width: "8px",
height: "8px",
backgroundColor: (l) => themeColor(l, "shift-11", "neutral"),
transform: "rotate(45deg)",
},
_onMount: (node) => { arrowEl = node.domElement as HTMLElement },
}Reading Arrow Position
arrow writes to middlewareData.arrow:
const { middlewareData, placement } = await computePosition(reference, floating, {
middleware: [offset(12), flip(), shift(), arrow({ element: arrowEl! })],
})
const { x, y } = middlewareData.arrow!
// x: horizontal offset within the floating element (defined for top/bottom placements)
// y: vertical offset within the floating element (defined for left/right placements)
// One of x or y is undefined depending on the placement axisApply the position based on the resolved placement. The arrow sits on the opposite side from where the floating element opens:
const staticSide: Record<string, string> = {
top: "bottom",
right: "left",
bottom: "top",
left: "right",
}
const { x: ax, y: ay } = middlewareData.arrow!
const side = placement.split("-")[0]
Object.assign(arrowEl!.style, {
left: ax != null ? `${ax}px` : "",
top: ay != null ? `${ay}px` : "",
right: "",
bottom: "",
[staticSide[side]]: "-4px", // protrude outside the floating element
})Padding
Add padding to prevent the arrow from touching the floating element's rounded corners:
arrow({ element: arrowEl!, padding: 6 })
// Asymmetric padding
arrow({ element: arrowEl!, padding: { top: 4, right: 8, bottom: 4, left: 8 } })Full Tooltip With Arrow
A tooltip that keeps the arrow pointing at the reference after a flip:
import { toState } from "@domphy/core"
import { themeColor, themeSpacing } from "@domphy/theme"
import {
computePosition,
autoUpdate,
offset,
flip,
shift,
arrow,
type Placement,
} from "@domphy/floating"
const open = toState(false)
const resolvedPlacement = toState<Placement>("bottom")
let reference: HTMLElement | null = null
let floating: HTMLElement | null = null
let arrowEl: HTMLElement | null = null
let cleanup: (() => void) | null = null
const staticSide: Record<string, string> = {
top: "bottom", right: "left", bottom: "top", left: "right",
}
function updatePosition() {
if (!reference || !floating || !arrowEl) return
computePosition(reference, floating, {
placement: "bottom",
middleware: [
offset(12),
flip(),
shift({ padding: 4 }),
arrow({ element: arrowEl, padding: 4 }),
],
strategy: "fixed",
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(floating!.style, { left: `${x}px`, top: `${y}px` })
resolvedPlacement.set(placement as Placement)
const { x: ax, y: ay } = middlewareData.arrow!
const side = placement.split("-")[0]
Object.assign(arrowEl!.style, {
left: ax != null ? `${ax}px` : "",
top: ay != null ? `${ay}px` : "",
right: "",
bottom: "",
[staticSide[side]]: "-4px",
})
})
}
function startPositioning() {
if (!reference || !floating) return
cleanup?.()
cleanup = autoUpdate(reference, floating, updatePosition)
}
const Arrow = {
div: null,
style: {
position: "absolute",
width: "8px",
height: "8px",
backgroundColor: (l) => themeColor(l, "shift-11", "neutral"),
transform: "rotate(45deg)",
},
_onMount: (node) => { arrowEl = node.domElement as HTMLElement },
}
const Tooltip = {
div: [Arrow, "Tooltip text"],
style: {
position: "fixed",
padding: themeSpacing(1),
paddingInline: themeSpacing(2),
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",
onMouseEnter: () => { open.set(true); startPositioning() },
onMouseLeave: () => { open.set(false); cleanup?.(); cleanup = null },
_onMount: (node) => {
reference = node.domElement as HTMLElement
if (floating) startPositioning()
},
},
Tooltip,
],
}centerOffset
middlewareData.arrow.centerOffset tells you how far the arrow is from the perfect center. A non-zero value means the reference is too small to center the arrow without it overlapping the floating element's edge:
const { centerOffset } = middlewareData.arrow!
// 0 = arrow is centered on the reference
// positive/negative = how many px the arrow had to move from center
// Hide the arrow if it would look misplaced
if (Math.abs(centerOffset) > 4) {
arrowEl!.style.opacity = "0"
}TypeScript
import type { ArrowOptions } from "@domphy/floating"
const arrowConfig: ArrowOptions = {
element: arrowEl!,
padding: 6,
}