geometry-observer

Know when an element moves.

An experiment in reporting changes to an element's position and size, including the ones caused by other elements, like a sibling growing above it. It's built on CSS anchor positioning, so nothing polls and no work happens while the page is idle. Once things move, it costs more than the alternatives: see Limits.

npm install geometry-observer
Read the docs
import { GeometryObserver } from 'geometry-observer';

const observer = new GeometryObserver((entries) => {
  for (const { target, rect, moved } of entries) {
    if (moved) placeTooltip(target, rect);
  }
});

observer.observe(button);

Try it

Resize the box from its corner, or use the buttons to change the layout around it. Every number on the overlay comes from an observer callback. The dashed outline is a visible copy of the probe the library uses.

new GeometryObserver(…)every change
Observed element. Drag the corner to resize me.

Elements moved by other elements

Drag a node or resize it from its corner. Overlapping nodes push each other apart, and the edges follow. The page only moves the node you drag. The others are pushed from the observer callback, and the edges and tooltips are drawn from the rects it reports. Hover a node for its tooltip, or click to pin it.

Node graph0 entries
The tooltips are popovers in the top layer, so they can extend past the edge of the graph without being clipped.

How it works

Anchor positioning turns a layout change into a ResizeObserver notification.

Anchor

Each target gets a hidden probe in the top layer. anchor() pins the probe's top-left corner to the target's, and the probe stretches far past the viewport.

Resize

The probe's size now encodes the target's position. When layout moves the target, the probe resizes.

Observe

A ResizeObserver on the probe, and on the target for its own size, reports it in the same frame. The observer reads the target's rect.

/* A simplified probe. The real one also has fallbacks, and sits in a popover. */
.probe {
  position: fixed;
  position-anchor: --target;
  top: anchor(top);
  left: anchor(left);
  bottom: -1000000px;
  right: -1000000px;
}

Scrolling moves targets without a layout, so the probe doesn't resize. The library covers it with one passive scroll listener for the whole page, and only re-measures targets inside the element that scrolled.

Compared with the alternatives

Change geometry-observer ResizeObserver Floating UI autoUpdate rAF loop
Element resized yes yes yes yes
A sibling above it grew yes — yes yes
A node was inserted above it yes — yes yes
An ancestor transform changed yes, except Firefox — yes yes
Scrolled yes — yes yes
Moved while scrolled out of its scroller yes — — yes
Hidden with display: none state: 'hidden' 0×0 0×0 0×0
Removed from the document state: 'detached' 0×0 0×0 0×0

Measured in Chromium and WebKit. Floating UI combines a ResizeObserver, scroll listeners and an IntersectionObserver rebuilt after every move.

Main-thread cost

Milliseconds added per frame, 100 / 1,000 elements geometry-observer rAF loop Floating UI
Nothing changes 0 / 0 0.3 / 1.0 0 / 0.2
A reflow that moves nothing 0.7 / 6.3 0.1 / 0.6 0.3 / 0.7
Every element moves every frame 1.2 / 9.0 0.1 / 1.1 0.7 / 4.5
Scrolling 0.4 / 4.5 0.1 / 0.7 0.7 / 5.4

Chromium 153, from the benchmarks. It's cheapest only when nothing changes, and beats Floating UI while scrolling. In each column: cheapest, in between, most expensive, with costs within 0.1ms tied.

Limits

Every reflow costs something

Each probe is an anchor-positioned box, and Chromium lays out all of them on every reflow, even one that moves nothing. For a single floating element, Floating UI is cheaper; for anything that moves every frame, use a rAF loop.

Safari can miss a hidden 0×0 element

WebKit doesn't restyle a probe when its anchor goes away. An element with a size still shrinks to 0×0, which its own ResizeObserver reports. For a 0×0 element the observer watches DOM mutations, so it misses one hidden by CSS alone, like a :hover rule.

Composited animations lag slightly

During a composited transform animation, the reported rect trails by about half a frame. It's exact once the animation stops.

Firefox misses ancestor transforms

Firefox resolves anchor() against an element's untransformed box, so a change to an ancestor's transform doesn't resize the probe and isn't reported. Everything else works as in the other engines.

Browser support

Chromium 125+ Safari 26+ Firefox (tested on 155)
Mechanism anchor probes anchor probes anchor probes
Position, size and layout-driven moves yes yes yes
Ancestor transform yes yes no
detached state yes yes yes
hidden state yes yes, except 0×0 by CSS alone yes
Work while the page is idle none none none