useElementBounding is a React hook that tracks an element's size and its position in the viewport as a live { width, height, top, left, right, bottom, x, y } object, re-measuring whenever the page scrolls, the window resizes, or the element itself changes size. It is the reactive wrapper around getBoundingClientRect: you attach a ref, and the hook keeps a fresh measurement in state.
You reach for it to anchor a tooltip or popover to its trigger, draw a highlight box over a target, or position a floating menu — anything that needs to know where an element is on screen right now, not just how big it is. VueUse ships this hook under the same name; you are building the React equivalent.
// `ref` is a React ref you attach to the element you want to measure.
function useElementBounding(ref) {
// returns a live object that re-measures on scroll, resize, and size changes:
// { width, height, top, left, right, bottom, x, y }
}
function Box() {
const ref = useRef(null);
const rect = useElementBounding(ref);
return <div ref={ref}>{rect.width} wide, top at {rect.top}px</div>;
}
// getBoundingClientRect is VIEWPORT-relative. Take an element sitting 300px
// down the page, with the page scrolled to the very top:
// { top: 300, left: 0, ... }
// Now scroll the page down 200px. The element has NOT moved in the document,
// but its reported position has:
// { top: 100, left: 0, ... } // top fell by the 200px you scrolled
top, left, x, y, right, and bottom all come from getBoundingClientRect, so they change on every scroll — of the window or any scrollable ancestor — even though the element's place in the document has not changed.ResizeObserver fires when the element's box changes size but never when the page scrolls. Keeping position fresh needs its own scroll and resize listeners.getBoundingClientRect reports sub-pixel values, and an element scrolled above the top of the viewport has a negative top. Do not round or clamp them.You will keep a getBoundingClientRect measurement in React state and refresh it on every signal that can move or resize the element: the element's own size changes, and — the part that is easy to miss — scrolling and window resizes.
A tooltip has to sit right on top of its trigger. To place it you need the trigger's box: how wide and tall it is, and where its top-left corner is on the screen. getBoundingClientRect hands you all of that in one call. The catch is that the answer goes stale the instant anything scrolls — the box you measured a moment ago is now in a different place on screen, and the tooltip drifts away from its trigger.
Here is the one fact that drives the whole question: getBoundingClientRect returns coordinates relative to the viewport, not the document. Picture the page as a long strip of paper and the viewport as a window you slide up and down over it. An element is pinned to the paper — it never moves. But top is measured from the top edge of the window, so scrolling the window down makes the element's top shrink even though the element sat perfectly still. Width and height belong to the box itself, so they do not care where the window is. Position and size answer to different things.
Reach for the tool you already know: a ResizeObserver. Measure once on mount, then let the observer re-measure whenever the element changes size.
const { useState, useLayoutEffect } = require('react');
function useElementBounding(ref) {
const [bounds, setBounds] = useState({
width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0,
});
useLayoutEffect(() => {
const el = ref.current;
const update = () => {
const r = el.getBoundingClientRect();
setBounds({
width: r.width, height: r.height,
top: r.top, left: r.left, right: r.right, bottom: r.bottom,
x: r.x, y: r.y,
});
};
update(); // measure on mount
const ro = new ResizeObserver(update);
ro.observe(el); // re-measure when the box resizes
return () => ro.disconnect();
}, [ref]);
return bounds;
}
Grow or shrink the element and the numbers update perfectly, so it looks finished. Then scroll the page and the tooltip slides off its trigger. A ResizeObserver watches the element's size — it fires when the box grows or shrinks, and never when the page scrolls. Nothing is listening for scroll, so top and left keep reporting the position from mount. Size stays right; position rots.
const { useState, useLayoutEffect } = require('react');
const INITIAL = {
width: 0,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
};
function useElementBounding(ref) {
const [bounds, setBounds] = useState(INITIAL);
useLayoutEffect(() => {
const el = ref.current;
const update = () => {
const node = ref.current;
if (!node) return;
const r = node.getBoundingClientRect();
setBounds({
width: r.width,
height: r.height,
top: r.top,
left: r.left,
right: r.right,
bottom: r.bottom,
x: r.x,
y: r.y,
});
};
update(); // measure once on mount
// Content-driven size changes: fires when the element's own box resizes.
const ro = new ResizeObserver(update);
if (el) ro.observe(el);
// Position changes: getBoundingClientRect is viewport-relative, so any
// scroll moves it. capture:true is REQUIRED — scroll does not bubble, but it
// DOES travel the capture phase, so a window capture listener catches scroll
// from any scrollable ancestor. passive:true says we never preventDefault.
window.addEventListener('scroll', update, { capture: true, passive: true });
window.addEventListener('resize', update, { passive: true });
return () => {
ro.disconnect();
window.removeEventListener('scroll', update, { capture: true });
window.removeEventListener('resize', update);
};
}, [ref]);
return bounds;
}
module.exports = { useElementBounding };
The ResizeObserver still handles size. The new part is two window listeners that re-run the exact same measurement on scroll and on resize. The detail that is easy to get wrong is capture: true on the scroll listener: a scroll event does not bubble, so a plain listener on window would never hear an inner scrollable container scroll. In the capture phase the event travels down from window toward the target, so a capturing listener on window catches scroll from any ancestor. passive: true promises we will not call preventDefault, which lets the browser scroll smoothly without waiting on our handler.
Everything is set up in one layout effect and undone in its cleanup. On mount you take the first measurement, start the observer, and add both window listeners. On unmount you must reverse all three — disconnect the observer and remove both listeners — passing the same function reference you added, so the browser can find and detach it. Skip the cleanup and every mount stacks another scroll handler on window; after a few navigations you are re-measuring dead elements on every scroll frame.
Say the trigger sits 500px down the page inside a scrollable panel, and the panel has not been scrolled yet. On mount the effect runs:
getBoundingClientRect reports top: 500, and we store the full rect in state. The tooltip renders at top: 500.observe the element and add the capturing scroll and resize listeners on window.scroll event. It does not bubble, but our capturing window listener catches it on the way down.update runs again. getBoundingClientRect now reports top: 150 — that is 500 minus the 350px of scroll. State updates, the component re-renders, and the tooltip follows the trigger to its new spot.capture: true — without it, a plain window scroll listener misses scrolling inside any nested scroll container, because scroll does not bubble. Capture phase is the only way window hears a descendant's scroll.removeEventListener matches by reference. If update is redefined every render and you add one copy but remove another, the old listener lingers forever. Define it once inside the effect (or memoize it) and add and remove that same reference.useEffect instead of useLayoutEffect — the element paints at its old or zero position for one frame before the measurement lands, so a freshly mounted tooltip visibly jumps into place. useLayoutEffect measures before the browser paints.{ ...el.getBoundingClientRect() } gives you an empty object in most browsers, because a DOMRect's properties live on its prototype, not as own enumerable keys. Copy the fields out by name.ResizeObserver catches size changes but not a reposition caused by a sibling's content changing or a class/style swap. VueUse also wires a MutationObserver with attributeFilter: ['style', 'class'] to re-measure on those. Add one if your layout can shift without a scroll, resize, or size change.getBoundingClientRect forces a synchronous layout. On a heavy page, coalesce updates into one requestAnimationFrame per frame instead of measuring on every scroll event.const [ref, rect] = useElementBounding() — so the caller just spreads it onto an element. Same machinery; only the wiring at the call site changes.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useElementBounding is a React hook that tracks an element's size and its position in the viewport as a live { width, height, top, left, right, bottom, x, y } object, re-measuring whenever the page scrolls, the window resizes, or the element itself changes size. It is the reactive wrapper around getBoundingClientRect: you attach a ref, and the hook keeps a fresh measurement in state.
You reach for it to anchor a tooltip or popover to its trigger, draw a highlight box over a target, or position a floating menu — anything that needs to know where an element is on screen right now, not just how big it is. VueUse ships this hook under the same name; you are building the React equivalent.
// `ref` is a React ref you attach to the element you want to measure.
function useElementBounding(ref) {
// returns a live object that re-measures on scroll, resize, and size changes:
// { width, height, top, left, right, bottom, x, y }
}
function Box() {
const ref = useRef(null);
const rect = useElementBounding(ref);
return <div ref={ref}>{rect.width} wide, top at {rect.top}px</div>;
}
// getBoundingClientRect is VIEWPORT-relative. Take an element sitting 300px
// down the page, with the page scrolled to the very top:
// { top: 300, left: 0, ... }
// Now scroll the page down 200px. The element has NOT moved in the document,
// but its reported position has:
// { top: 100, left: 0, ... } // top fell by the 200px you scrolled
top, left, x, y, right, and bottom all come from getBoundingClientRect, so they change on every scroll — of the window or any scrollable ancestor — even though the element's place in the document has not changed.ResizeObserver fires when the element's box changes size but never when the page scrolls. Keeping position fresh needs its own scroll and resize listeners.getBoundingClientRect reports sub-pixel values, and an element scrolled above the top of the viewport has a negative top. Do not round or clamp them.You will keep a getBoundingClientRect measurement in React state and refresh it on every signal that can move or resize the element: the element's own size changes, and — the part that is easy to miss — scrolling and window resizes.
A tooltip has to sit right on top of its trigger. To place it you need the trigger's box: how wide and tall it is, and where its top-left corner is on the screen. getBoundingClientRect hands you all of that in one call. The catch is that the answer goes stale the instant anything scrolls — the box you measured a moment ago is now in a different place on screen, and the tooltip drifts away from its trigger.
Here is the one fact that drives the whole question: getBoundingClientRect returns coordinates relative to the viewport, not the document. Picture the page as a long strip of paper and the viewport as a window you slide up and down over it. An element is pinned to the paper — it never moves. But top is measured from the top edge of the window, so scrolling the window down makes the element's top shrink even though the element sat perfectly still. Width and height belong to the box itself, so they do not care where the window is. Position and size answer to different things.
Reach for the tool you already know: a ResizeObserver. Measure once on mount, then let the observer re-measure whenever the element changes size.
const { useState, useLayoutEffect } = require('react');
function useElementBounding(ref) {
const [bounds, setBounds] = useState({
width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0,
});
useLayoutEffect(() => {
const el = ref.current;
const update = () => {
const r = el.getBoundingClientRect();
setBounds({
width: r.width, height: r.height,
top: r.top, left: r.left, right: r.right, bottom: r.bottom,
x: r.x, y: r.y,
});
};
update(); // measure on mount
const ro = new ResizeObserver(update);
ro.observe(el); // re-measure when the box resizes
return () => ro.disconnect();
}, [ref]);
return bounds;
}
Grow or shrink the element and the numbers update perfectly, so it looks finished. Then scroll the page and the tooltip slides off its trigger. A ResizeObserver watches the element's size — it fires when the box grows or shrinks, and never when the page scrolls. Nothing is listening for scroll, so top and left keep reporting the position from mount. Size stays right; position rots.
const { useState, useLayoutEffect } = require('react');
const INITIAL = {
width: 0,
height: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
x: 0,
y: 0,
};
function useElementBounding(ref) {
const [bounds, setBounds] = useState(INITIAL);
useLayoutEffect(() => {
const el = ref.current;
const update = () => {
const node = ref.current;
if (!node) return;
const r = node.getBoundingClientRect();
setBounds({
width: r.width,
height: r.height,
top: r.top,
left: r.left,
right: r.right,
bottom: r.bottom,
x: r.x,
y: r.y,
});
};
update(); // measure once on mount
// Content-driven size changes: fires when the element's own box resizes.
const ro = new ResizeObserver(update);
if (el) ro.observe(el);
// Position changes: getBoundingClientRect is viewport-relative, so any
// scroll moves it. capture:true is REQUIRED — scroll does not bubble, but it
// DOES travel the capture phase, so a window capture listener catches scroll
// from any scrollable ancestor. passive:true says we never preventDefault.
window.addEventListener('scroll', update, { capture: true, passive: true });
window.addEventListener('resize', update, { passive: true });
return () => {
ro.disconnect();
window.removeEventListener('scroll', update, { capture: true });
window.removeEventListener('resize', update);
};
}, [ref]);
return bounds;
}
module.exports = { useElementBounding };
The ResizeObserver still handles size. The new part is two window listeners that re-run the exact same measurement on scroll and on resize. The detail that is easy to get wrong is capture: true on the scroll listener: a scroll event does not bubble, so a plain listener on window would never hear an inner scrollable container scroll. In the capture phase the event travels down from window toward the target, so a capturing listener on window catches scroll from any ancestor. passive: true promises we will not call preventDefault, which lets the browser scroll smoothly without waiting on our handler.
Everything is set up in one layout effect and undone in its cleanup. On mount you take the first measurement, start the observer, and add both window listeners. On unmount you must reverse all three — disconnect the observer and remove both listeners — passing the same function reference you added, so the browser can find and detach it. Skip the cleanup and every mount stacks another scroll handler on window; after a few navigations you are re-measuring dead elements on every scroll frame.
Say the trigger sits 500px down the page inside a scrollable panel, and the panel has not been scrolled yet. On mount the effect runs:
getBoundingClientRect reports top: 500, and we store the full rect in state. The tooltip renders at top: 500.observe the element and add the capturing scroll and resize listeners on window.scroll event. It does not bubble, but our capturing window listener catches it on the way down.update runs again. getBoundingClientRect now reports top: 150 — that is 500 minus the 350px of scroll. State updates, the component re-renders, and the tooltip follows the trigger to its new spot.capture: true — without it, a plain window scroll listener misses scrolling inside any nested scroll container, because scroll does not bubble. Capture phase is the only way window hears a descendant's scroll.removeEventListener matches by reference. If update is redefined every render and you add one copy but remove another, the old listener lingers forever. Define it once inside the effect (or memoize it) and add and remove that same reference.useEffect instead of useLayoutEffect — the element paints at its old or zero position for one frame before the measurement lands, so a freshly mounted tooltip visibly jumps into place. useLayoutEffect measures before the browser paints.{ ...el.getBoundingClientRect() } gives you an empty object in most browsers, because a DOMRect's properties live on its prototype, not as own enumerable keys. Copy the fields out by name.ResizeObserver catches size changes but not a reposition caused by a sibling's content changing or a class/style swap. VueUse also wires a MutationObserver with attributeFilter: ['style', 'class'] to re-measure on those. Add one if your layout can shift without a scroll, resize, or size change.getBoundingClientRect forces a synchronous layout. On a heavy page, coalesce updates into one requestAnimationFrame per frame instead of measuring on every scroll event.const [ref, rect] = useElementBounding() — so the caller just spreads it onto an element. Same machinery; only the wiring at the call site changes.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.