useElementBounding
Creates a signal that tracks an element's bounding box (position and dimensions). The signal automatically updates when the element is resized or when the page is scrolled/resized.
MDN References:
Usage
Basic Usage
typescript
import { useElementBounding } from 'ng-reactive-utils';
import { Component, viewChild, ElementRef } from '@angular/core';
@Component({
template: `
<div #box class="box">Drag me around</div>
<div class="info">
<p>Position: {{ bounding().x }}, {{ bounding().y }}</p>
<p>Size: {{ bounding().width }} × {{ bounding().height }}</p>
<p>Top: {{ bounding().top }}, Left: {{ bounding().left }}</p>
</div>
`,
})
class BoxTrackerComponent {
boxRef = viewChild<ElementRef>('box');
bounding = useElementBounding(this.boxRef);
}With Custom Configuration
typescript
@Component({
template: `<div #element>Content</div>`,
})
class CustomConfigComponent {
elementRef = viewChild<ElementRef>('element');
// Custom throttle for less frequent updates
bounding = useElementBounding(this.elementRef, {
throttleMs: 200,
windowResize: true,
windowScroll: true,
});
}Manual Updates Only
typescript
@Component({
template: `
<div #element>Content</div>
<button (click)="bounding().update()">Update Position</button>
`,
})
class ManualUpdateComponent {
elementRef = viewChild<ElementRef>('element');
// Disable automatic updates
bounding = useElementBounding(this.elementRef, {
windowResize: false,
windowScroll: false,
});
}With Element Signal
typescript
@Component({
template: `
<div #div1>Element 1</div>
<div #div2>Element 2</div>
<button (click)="switchElement()">Switch</button>
<p>Width: {{ bounding().width }}</p>
`,
})
class SwitchableElementComponent {
div1Ref = viewChild<ElementRef>('div1');
div2Ref = viewChild<ElementRef>('div2');
currentElement = signal<ElementRef | null>(null);
bounding = useElementBounding(this.currentElement);
ngAfterViewInit() {
this.currentElement.set(this.div1Ref()!);
}
switchElement() {
const current = this.currentElement();
const newElement = current === this.div1Ref() ? this.div2Ref() : this.div1Ref();
this.currentElement.set(newElement!);
}
}Sticky Header Detection
typescript
@Component({
template: ` <header #header [class.stuck]="isStuck()">Sticky Header</header> `,
})
class StickyHeaderComponent {
headerRef = viewChild<ElementRef>('header');
bounding = useElementBounding(this.headerRef);
isStuck = computed(() => this.bounding().top <= 0);
}Intersection Detection
typescript
@Component({
template: `
<div #box1>Box 1</div>
<div #box2>Box 2</div>
<p>Boxes overlapping: {{ areOverlapping() }}</p>
`,
})
class OverlapDetectionComponent {
box1Ref = viewChild<ElementRef>('box1');
box2Ref = viewChild<ElementRef>('box2');
box1Bounding = useElementBounding(this.box1Ref);
box2Bounding = useElementBounding(this.box2Ref);
areOverlapping = computed(() => {
const b1 = this.box1Bounding();
const b2 = this.box2Bounding();
return !(b1.right < b2.left || b1.left > b2.right || b1.bottom < b2.top || b1.top > b2.bottom);
});
}Viewport Visibility Detection
typescript
@Component({
template: `
<div #element>Content</div>
<p>Visible: {{ isInViewport() }}</p>
`,
})
class ViewportVisibilityComponent {
elementRef = viewChild<ElementRef>('element');
bounding = useElementBounding(this.elementRef);
isInViewport = computed(() => {
const { top, bottom, left, right } = this.bounding();
return top >= 0 && left >= 0 && bottom <= window.innerHeight && right <= window.innerWidth;
});
}Parameters
| Parameter | Type | Description |
|---|---|---|
elementSignal | Signal<Element | ElementRef | null | undefined> | Signal containing the element or ElementRef to track |
config | object | Optional configuration object |
Configuration Object
| Property | Type | Default | Description |
|---|---|---|---|
throttleMs | number | 100 | Throttle delay for scroll/resize events (ms) |
windowResize | boolean | true | Whether to update on window resize |
windowScroll | boolean | true | Whether to update on window scroll |
Returns
Signal<ElementBounding> - A readonly signal containing the element's bounding information
ElementBounding Type
typescript
type ElementBounding = {
x: number; // X position relative to viewport (same as left)
y: number; // Y position relative to viewport (same as top)
top: number; // Distance from top of viewport
right: number; // Distance from right of viewport
bottom: number; // Distance from bottom of viewport
left: number; // Distance from left of viewport
width: number; // Width of the element
height: number; // Height of the element
update: () => void; // Force an immediate update
};Notes
- Returned signal is readonly to prevent direct manipulation
- Uses ResizeObserver to efficiently track element size changes
- Listens to window scroll and resize events for position updates (configurable)
- All measurements are in pixels relative to the viewport
xis equivalent toleft, andyis equivalent totop- Throttles updates by default (100ms) to prevent excessive recalculations
- Event listeners and observers are automatically cleaned up on component destruction
- On the server, returns default values (all zeros) and updates to actual values once hydrated on the client
- Each component instance gets its own tracking (not shared like
useMousePosition) - The
update()method can be used to force an immediate update of the bounding box - Disabling
windowResizeandwindowScrollwill only track size changes via ResizeObserver
Common Use Cases
- Position tracking: Track element position for tooltips, popovers, or custom dropdowns
- Scroll animations: Trigger animations based on element position in viewport
- Sticky elements: Detect when elements become stuck/unsticky
- Collision detection: Check if elements overlap or intersect
- Lazy loading: Load content when elements enter viewport
- Responsive layouts: Adjust layout based on element dimensions
- Drag and drop: Track element bounds during drag operations
Source
ts
import { signal, inject, PLATFORM_ID, effect, DestroyRef, Signal, ElementRef } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import throttle from 'lodash-es/throttle';
export type ElementBounding = {
/** X position relative to the viewport (same as left) */
x: number;
/** Y position relative to the viewport (same as top) */
y: number;
/** Distance from the top of the viewport */
top: number;
/** Distance from the right of the viewport */
right: number;
/** Distance from the bottom of the viewport */
bottom: number;
/** Distance from the left of the viewport */
left: number;
/** Width of the element */
width: number;
/** Height of the element */
height: number;
/** Forces an update of the bounding box */
update: () => void;
};
const defaultBounding: Omit<ElementBounding, 'update'> = {
x: 0,
y: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
width: 0,
height: 0,
};
/**
* Creates a signal that tracks an element's bounding box (position and dimensions).
* The signal updates when the element is resized or when the page is scrolled/resized.
*
* @param elementSignal - A signal containing the element or ElementRef to track, or null
* @param config - Optional configuration
* @param config.throttleMs - Throttle delay for scroll/resize events (default: 100ms)
* @param config.windowResize - Whether to update on window resize (default: true)
* @param config.windowScroll - Whether to update on window scroll (default: true)
*
* @example
* ```ts
* // Track a div element
* class MyComponent {
* divRef = viewChild<ElementRef<HTMLDivElement>>('myDiv');
* bounding = useElementBounding(this.divRef);
*
* logPosition() {
* const { x, y, width, height } = this.bounding();
* console.log(`Position: (${x}, ${y}), Size: ${width}x${height}`);
* }
* }
* ```
*
* @example
* ```ts
* // With custom throttle
* class MyComponent {
* elementRef = viewChild<ElementRef>('element');
* bounding = useElementBounding(this.elementRef, { throttleMs: 200 });
* }
* ```
*
* @example
* ```ts
* // Manual updates only (no scroll/resize listeners)
* class MyComponent {
* elementRef = viewChild<ElementRef>('element');
* bounding = useElementBounding(this.elementRef, {
* windowResize: false,
* windowScroll: false,
* });
*
* manualUpdate() {
* this.bounding().update();
* }
* }
* ```
*/
export function useElementBounding(
elementSignal: Signal<Element | ElementRef | null | undefined>,
config: {
throttleMs?: number;
windowResize?: boolean;
windowScroll?: boolean;
} = {},
): Signal<ElementBounding> {
const { throttleMs = 100, windowResize = true, windowScroll = true } = config;
const platformId = inject(PLATFORM_ID);
const destroyRef = inject(DestroyRef);
const isBrowser = isPlatformBrowser(platformId);
const boundingSignal = signal<ElementBounding>({
...defaultBounding,
update: () => {},
});
let resizeObserver: ResizeObserver | null = null;
let windowListenersActive = false;
const updateBounding = () => {
const elementOrRef = elementSignal();
const element = elementOrRef instanceof ElementRef ? elementOrRef.nativeElement : elementOrRef;
if (!element || !isBrowser) {
boundingSignal.update((prev) => ({ ...defaultBounding, update: prev.update }));
return;
}
const rect = element.getBoundingClientRect();
boundingSignal.update((prev) => ({
x: rect.x,
y: rect.y,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
update: prev.update,
}));
};
const throttledUpdate = throttle(updateBounding, throttleMs);
// Set the update function
boundingSignal.update((prev) => ({
...prev,
update: updateBounding,
}));
if (isBrowser) {
// Watch for element changes
effect(() => {
const elementOrRef = elementSignal();
const element =
elementOrRef instanceof ElementRef ? elementOrRef.nativeElement : elementOrRef;
// Clean up previous observer
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
// Clean up window event listeners
if (windowListenersActive) {
if (windowResize) {
window.removeEventListener('resize', throttledUpdate);
}
if (windowScroll) {
window.removeEventListener('scroll', throttledUpdate, true);
}
windowListenersActive = false;
}
if (element) {
// Initial update
updateBounding();
// Set up ResizeObserver for size changes
resizeObserver = new ResizeObserver(throttledUpdate);
resizeObserver.observe(element);
// Set up window event listeners if enabled
if (windowResize) {
window.addEventListener('resize', throttledUpdate);
}
if (windowScroll) {
window.addEventListener('scroll', throttledUpdate, true); // Use capture to catch all scroll events
}
windowListenersActive = true;
} else {
boundingSignal.update((prev) => ({ ...defaultBounding, update: prev.update }));
}
});
}
// Cleanup
destroyRef.onDestroy(() => {
throttledUpdate.cancel();
if (resizeObserver) {
resizeObserver.disconnect();
}
if (isBrowser && windowListenersActive) {
if (windowResize) {
window.removeEventListener('resize', throttledUpdate);
}
if (windowScroll) {
window.removeEventListener('scroll', throttledUpdate, true);
}
}
});
return boundingSignal.asReadonly();
}