Repository: react-spring/react-use-measure Branch: master Commit: 2df0ee8dfad4 Files: 10 Total size: 20.9 KB Directory structure: gitextract_0msf38bd/ ├── .github/ │ └── workflows/ │ └── CI.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── package.json ├── readme.md ├── src/ │ └── index.ts ├── tests/ │ └── index.test.tsx ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/CI.yml ================================================ name: CI on: push: branches: [master] pull_request: branches: [master] jobs: build-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 - name: Install dependencies run: yarn install - name: Check build health run: yarn build - name: Run tests run: yarn test ================================================ FILE: .gitignore ================================================ .DS_Store node_modules dist # Editor directories and files .idea .vscode *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: .prettierrc ================================================ { "semi": false, "trailingComma": "all", "singleQuote": true, "tabWidth": 2, "printWidth": 120 } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019-2025 Poimandres Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: package.json ================================================ { "name": "react-use-measure", "version": "2.1.7", "description": "Utility to measure view bounds", "keywords": [ "react", "use", "measure", "bounds", "hooks" ], "author": "Paul Henschel", "homepage": "https://github.com/pmndrs/react-use-measure", "repository": "https://github.com/pmndrs/react-use-measure", "license": "MIT", "files": [ "dist/*", "src/*" ], "type": "module", "types": "./dist/index.d.ts", "main": "./dist/index.cjs", "module": "./dist/index.js", "exports": { "require": { "types": "./dist/index.d.ts", "default": "./dist/index.cjs" }, "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, "sideEffects": false, "devDependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", "@types/node": "^22.12.0", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitest/browser": "^3.0.4", "playwright": "^1.50.0", "react": "^19.0.0", "react-dom": "^19.0.0", "resize-observer-polyfill": "^1.5.1", "rimraf": "^6.0.1", "typescript": "^5.7.3", "vite": "^6.0.11", "vitest": "^3.0.4" }, "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "peerDependenciesMeta": { "react-dom": { "optional": true } }, "scripts": { "dev": "vite", "build": "rimraf dist && vite build && tsc", "test": "npx playwright install && vitest run" } } ================================================ FILE: readme.md ================================================

yarn add react-use-measure This small tool will measure the boundaries (for instance width, height, top, left) of a view you reference. It is reactive and responds to changes in size, window-scroll and nested-area-scroll. ### Why do we need this hook? Because there is [no simple way](https://stackoverflow.com/questions/442404/retrieve-the-position-x-y-of-an-html-element) to just get relative view coordinates. Yes, there is getBoundingClientRect, but it does not work when your content sits inside scroll areas whose offsets are simply neglected (as well as page scroll). Worse, mouse coordinates are relative to the viewport (the visible rect that contains the page). There is no easy way, for instance, to know that the mouse hovers over the upper/left corner of an element. This hook solves it for you. You can try a live demo here: https://codesandbox.io/s/musing-kare-4fblz # Usage ```jsx import useMeasure from 'react-use-measure' function App() { const [ref, bounds] = useMeasure() // consider that knowing bounds is only possible *after* the view renders // so you'll get zero values on the first run and be informed later return
} ``` # Api ```jsx interface RectReadOnly { readonly x: number readonly y: number readonly width: number readonly height: number readonly top: number readonly right: number readonly bottom: number readonly left: number } type Options = { // Debounce events in milliseconds debounce?: number | { scroll: number; resize: number } // React to nested scroll changes, don't use this if you know your view is static scroll?: boolean // You can optionally inject a resize-observer polyfill polyfill?: { new (cb: ResizeObserverCallback): ResizeObserver } // Measure size using offsetHeight and offsetWidth to ignore parent scale transforms offsetSize?: boolean } useMeasure( options: Options = { debounce: 0, scroll: false } ): [React.MutableRefObject, RectReadOnly] ``` # ⚠️ Notes ### Resize-observer polyfills This lib relies on resize-observers. If you need a polyfill you can either polute the `window` object or inject it cleanly using the config options. We recommend [@juggle/resize-observer](https://github.com/juggle/resize-observer). ```jsx import { ResizeObserver } from '@juggle/resize-observer' function App() { const [ref, bounds] = useMeasure({ polyfill: ResizeObserver }) ``` ### Multiple refs useMeasure currently returns its own ref. We do this because we are using functional refs for unmount tracking. If you need to have a ref of your own on the same element, use [react-merge-refs](https://github.com/smooth-code/react-merge-refs). ================================================ FILE: src/index.ts ================================================ import { useEffect, useState, useRef, useMemo } from 'react' function createDebounce void>(callback: T, ms: number) { let timeoutId: number return (...args: Parameters): void => { window.clearTimeout(timeoutId) timeoutId = window.setTimeout(() => callback(...args), ms) } } declare type ResizeObserverCallback = (entries: any[], observer: ResizeObserver) => void declare class ResizeObserver { constructor(callback: ResizeObserverCallback) observe(target: Element, options?: any): void unobserve(target: Element): void disconnect(): void static toString(): string } export interface RectReadOnly { readonly x: number readonly y: number readonly width: number readonly height: number readonly top: number readonly right: number readonly bottom: number readonly left: number [key: string]: number } type HTMLOrSVGElement = HTMLElement | SVGElement type Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly, () => void] type State = { element: HTMLOrSVGElement | null scrollContainers: HTMLOrSVGElement[] | null resizeObserver: ResizeObserver | null lastBounds: RectReadOnly orientationHandler: null | (() => void) } export type Options = { debounce?: number | { scroll: number; resize: number } scroll?: boolean polyfill?: { new (cb: ResizeObserverCallback): ResizeObserver } offsetSize?: boolean } function useMeasure( { debounce, scroll, polyfill, offsetSize }: Options = { debounce: 0, scroll: false, offsetSize: false }, ): Result { const ResizeObserver = polyfill || (typeof window === 'undefined' ? class ResizeObserver {} : (window as any).ResizeObserver) if (!ResizeObserver) { throw new Error( 'This browser does not support ResizeObserver out of the box. See: https://github.com/react-spring/react-use-measure/#resize-observer-polyfills', ) } const [bounds, set] = useState({ left: 0, top: 0, width: 0, height: 0, bottom: 0, right: 0, x: 0, y: 0, }) // keep all state in a ref const state = useRef({ element: null, scrollContainers: null, resizeObserver: null, lastBounds: bounds, orientationHandler: null, }) // set actual debounce values early, so effects know if they should react accordingly const scrollDebounce = debounce ? (typeof debounce === 'number' ? debounce : debounce.scroll) : null const resizeDebounce = debounce ? (typeof debounce === 'number' ? debounce : debounce.resize) : null // make sure to update state only as long as the component is truly mounted const mounted = useRef(false) useEffect(() => { mounted.current = true return () => void (mounted.current = false) }) // memoize handlers, so event-listeners know when they should update const [forceRefresh, resizeChange, scrollChange] = useMemo(() => { const callback = () => { if (!state.current.element) return const { left, top, width, height, bottom, right, x, y } = state.current.element.getBoundingClientRect() as unknown as RectReadOnly const size = { left, top, width, height, bottom, right, x, y, } if (state.current.element instanceof HTMLElement && offsetSize) { size.height = state.current.element.offsetHeight size.width = state.current.element.offsetWidth } Object.freeze(size) if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) set((state.current.lastBounds = size)) } return [ callback, resizeDebounce ? createDebounce(callback, resizeDebounce) : callback, scrollDebounce ? createDebounce(callback, scrollDebounce) : callback, ] }, [set, offsetSize, scrollDebounce, resizeDebounce]) // cleanup current scroll-listeners / observers function removeListeners() { if (state.current.scrollContainers) { state.current.scrollContainers.forEach((element) => element.removeEventListener('scroll', scrollChange, true)) state.current.scrollContainers = null } if (state.current.resizeObserver) { state.current.resizeObserver.disconnect() state.current.resizeObserver = null } if (state.current.orientationHandler) { if ('orientation' in screen && 'removeEventListener' in screen.orientation) { screen.orientation.removeEventListener('change', state.current.orientationHandler) } else if ('onorientationchange' in window) { window.removeEventListener('orientationchange', state.current.orientationHandler) } } } // add scroll-listeners / observers function addListeners() { if (!state.current.element) return state.current.resizeObserver = new ResizeObserver(scrollChange) state.current.resizeObserver!.observe(state.current.element) if (scroll && state.current.scrollContainers) { state.current.scrollContainers.forEach((scrollContainer) => scrollContainer.addEventListener('scroll', scrollChange, { capture: true, passive: true }), ) } // Handle orientation changes state.current.orientationHandler = () => { scrollChange() } // Use screen.orientation if available if ('orientation' in screen && 'addEventListener' in screen.orientation) { screen.orientation.addEventListener('change', state.current.orientationHandler) } else if ('onorientationchange' in window) { // Fallback to orientationchange event window.addEventListener('orientationchange', state.current.orientationHandler) } } // the ref we expose to the user const ref = (node: HTMLOrSVGElement | null) => { if (!node || node === state.current.element) return removeListeners() state.current.element = node state.current.scrollContainers = findScrollContainers(node) addListeners() } // add general event listeners useOnWindowScroll(scrollChange, Boolean(scroll)) useOnWindowResize(resizeChange) // respond to changes that are relevant for the listeners useEffect(() => { removeListeners() addListeners() }, [scroll, scrollChange, resizeChange]) // remove all listeners when the components unmounts useEffect(() => removeListeners, []) return [ref, bounds, forceRefresh] } // Adds native resize listener to window function useOnWindowResize(onWindowResize: (event: Event) => void) { useEffect(() => { const cb = onWindowResize window.addEventListener('resize', cb) return () => void window.removeEventListener('resize', cb) }, [onWindowResize]) } function useOnWindowScroll(onScroll: () => void, enabled: boolean) { useEffect(() => { if (enabled) { const cb = onScroll window.addEventListener('scroll', cb, { capture: true, passive: true }) return () => void window.removeEventListener('scroll', cb, true) } }, [onScroll, enabled]) } // Returns a list of scroll offsets function findScrollContainers(element: HTMLOrSVGElement | null): HTMLOrSVGElement[] { const result: HTMLOrSVGElement[] = [] if (!element || element === document.body) return result const { overflow, overflowX, overflowY } = window.getComputedStyle(element) if ([overflow, overflowX, overflowY].some((prop) => prop === 'auto' || prop === 'scroll')) result.push(element) return [...result, ...findScrollContainers(element.parentElement)] } // Checks if element boundaries are equal const keys: (keyof RectReadOnly)[] = ['x', 'y', 'top', 'bottom', 'left', 'right', 'width', 'height'] const areBoundsEqual = (a: RectReadOnly, b: RectReadOnly): boolean => keys.every((key) => a[key] === b[key]) export default useMeasure ================================================ FILE: tests/index.test.tsx ================================================ import * as React from 'react' import { render, cleanup, RenderResult, fireEvent } from '@testing-library/react' import Polyfill from 'resize-observer-polyfill' import { afterEach, describe, it, expect } from 'vitest' import useMeasure, { Options } from '../src/index' /** * Helpers */ const getBounds = (tools: RenderResult): DOMRect => JSON.parse(tools.getByTestId('box').innerHTML) const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) function ignoreWindowErrors(test: () => void) { const onErrorBackup = window.onerror window.onerror = () => null const consoleError = console.error console.error = () => null test() window.onerror = onErrorBackup console.error = consoleError } /** * Tests */ afterEach(() => { cleanup() window.scrollTo({ top: 0, left: 0 }) }) describe('useMeasure', () => { type Props = { switchRef?: boolean scale?: number onRender?: () => void options?: Options polyfill?: boolean offsetSize?: boolean } function Test({ switchRef, options, onRender, polyfill, scale = 1, offsetSize = false }: Props) { const [ref, bounds] = useMeasure({ ...options, polyfill: polyfill ? Polyfill : undefined, offsetSize }) const [big, setBig] = React.useState(false) if (onRender) { onRender() } return ( <>
setBig(!big)} style={{ width: `${big ? 400 : 200}px`, height: `${big ? 400 : 200}px`, overflow: 'hidden', fontSize: '8px', }} > {JSON.stringify(bounds)}
Dummy
) } it('gives empty initial bounds on first render', async () => { const tools = render() expect(getBounds(tools).width).toBe(0) expect(getBounds(tools).height).toBe(0) expect(getBounds(tools).top).toBe(0) expect(getBounds(tools).left).toBe(0) }) it('renders 1 additional time after first render', async () => { let count = 0 render( count++} />) await nextFrame() expect(count).toBe(2) }) it('gives correct dimensions and positions after initial render', async () => { const tools = render() await nextFrame() expect(getBounds(tools).width).toBe(200) expect(getBounds(tools).height).toBe(200) expect(getBounds(tools).top).toBe(0) expect(getBounds(tools).left).toBe(0) }) it('gives correct dimensions and positions when the tracked elements changes in size', async () => { const tools = render() fireEvent.click(tools.getByTestId('box')) await nextFrame() expect(getBounds(tools).width).toBe(400) expect(getBounds(tools).height).toBe(400) expect(getBounds(tools).top).toBe(0) expect(getBounds(tools).left).toBe(0) }) it('gives correct dimensions and positions when the page is scrolled', async () => { const tools = render() window.scrollTo({ top: 200 }) await nextFrame() expect(getBounds(tools).top).toBe(-200) expect(getBounds(tools).left).toBe(0) }) it('gives correct dimensions and positions when the wrapper is scrolled', async () => { const tools = render() tools.getByTestId('wrapper').scrollTo({ top: 200 }) await nextFrame() expect(getBounds(tools).top).toBe(-200) expect(getBounds(tools).left).toBe(0) }) it('gives correct size when offsetSize: true and parent is scaled', async () => { const tools = render() await nextFrame() expect(getBounds(tools).width).toBe(200) expect(getBounds(tools).height).toBe(200) }) it('gives correct size when offsetSize: false and parent is scaled', async () => { const tools = render() await nextFrame() expect(getBounds(tools).width).toBe(200 * 0.8) expect(getBounds(tools).height).toBe(200 * 0.8) }) it('debounces the scroll events', async () => { const tools = render() const wrapper = tools.getByTestId('wrapper') wrapper.scrollTo({ top: 200 }) await nextFrame() wrapper.scrollTo({ top: 201 }) await nextFrame() wrapper.scrollTo({ top: 202 }) await nextFrame() expect(getBounds(tools).top).toBe(0) await wait(100) expect(getBounds(tools).top).toBe(-202) }) // this one fails and needs to be fixed it('detects changes in ref', async () => { const tools = render() await wait(100) tools.rerender() await nextFrame() expect(getBounds(tools).top).toBe(500) }) it('throws an descriptive error when the browser does not support ResizeObserver', () => { const RO = (window as any).ResizeObserver ;(window as any).ResizeObserver = null ignoreWindowErrors(() => { expect(() => render()).toThrow( 'This browser does not support ResizeObserver out of the box. See: https://github.com/react-spring/react-use-measure/#resize-observer-polyfills', ) }) ;(window as any).ResizeObserver = RO }) it('does not throw when a ResizeObserver polyfill was provided', () => { const RO = (window as any).ResizeObserver ;(window as any).ResizeObserver = null ignoreWindowErrors(() => { expect(() => render()).not.toThrow( 'This browser does not support ResizeObserver out of the box. See: https://github.com/react-spring/react-use-measure/#resize-observer-polyfills', ) }) ;(window as any).ResizeObserver = RO }) }) ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es6", "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, "jsx": "react", "pretty": true, "strict": true, "skipLibCheck": true, "declaration": true, "removeComments": true, "emitDeclarationOnly": true, "outDir": "dist", "resolveJsonModule": true }, "include": ["./src"], "exclude": ["./node_modules/**/*"] } ================================================ FILE: vite.config.ts ================================================ import * as path from 'node:path' import * as vite from 'vite' export default vite.defineConfig({ root: process.argv[2] ? undefined : 'demo', resolve: { alias: { 'use-measure': path.resolve(__dirname, './src'), }, }, test: { browser: { provider: 'playwright', enabled: true, headless: true, screenshotFailures: false, instances: [{ browser: 'chromium' }], }, }, build: { target: 'es2018', sourcemap: true, lib: { formats: ['es', 'cjs'], entry: 'src/index.ts', fileName: '[name]', }, rollupOptions: { external: (id: string) => !id.startsWith('.') && !path.isAbsolute(id), output: { sourcemapExcludeSources: true, }, }, }, plugins: [ { name: 'vite-minify', renderChunk: { order: 'post', handler(code, { fileName }) { return vite.transformWithEsbuild(code, fileName, { minify: true, target: 'es2018' }) }, }, }, ], })