[
  {
    "path": ".github/workflows/CI.yml",
    "content": "name: CI\non:\n  push:\n    branches: [master]\n  pull_request:\n    branches: [master]\n\njobs:\n  build-test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - uses: actions/setup-node@v2\n\n      - name: Install dependencies\n        run: yarn install\n\n      - name: Check build health\n        run: yarn build\n\n      - name: Run tests\n        run: yarn test\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\nnode_modules\ndist\n\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"semi\": false,\n  \"trailingComma\": \"all\",\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"printWidth\": 120\n}\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2019-2025 Poimandres\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"react-use-measure\",\n  \"version\": \"2.1.7\",\n  \"description\": \"Utility to measure view bounds\",\n  \"keywords\": [\n    \"react\",\n    \"use\",\n    \"measure\",\n    \"bounds\",\n    \"hooks\"\n  ],\n  \"author\": \"Paul Henschel\",\n  \"homepage\": \"https://github.com/pmndrs/react-use-measure\",\n  \"repository\": \"https://github.com/pmndrs/react-use-measure\",\n  \"license\": \"MIT\",\n  \"files\": [\n    \"dist/*\",\n    \"src/*\"\n  ],\n  \"type\": \"module\",\n  \"types\": \"./dist/index.d.ts\",\n  \"main\": \"./dist/index.cjs\",\n  \"module\": \"./dist/index.js\",\n  \"exports\": {\n    \"require\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"default\": \"./dist/index.cjs\"\n    },\n    \"import\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"default\": \"./dist/index.js\"\n    }\n  },\n  \"sideEffects\": false,\n  \"devDependencies\": {\n    \"@testing-library/dom\": \"^10.4.0\",\n    \"@testing-library/react\": \"^16.2.0\",\n    \"@types/node\": \"^22.12.0\",\n    \"@types/react\": \"^19.0.8\",\n    \"@types/react-dom\": \"^19.0.3\",\n    \"@vitest/browser\": \"^3.0.4\",\n    \"playwright\": \"^1.50.0\",\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\",\n    \"resize-observer-polyfill\": \"^1.5.1\",\n    \"rimraf\": \"^6.0.1\",\n    \"typescript\": \"^5.7.3\",\n    \"vite\": \"^6.0.11\",\n    \"vitest\": \"^3.0.4\"\n  },\n  \"peerDependencies\": {\n    \"react\": \">=16.13\",\n    \"react-dom\": \">=16.13\"\n  },\n  \"peerDependenciesMeta\": {\n    \"react-dom\": {\n      \"optional\": true\n    }\n  },\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"rimraf dist && vite build && tsc\",\n    \"test\": \"npx playwright install && vitest run\"\n  }\n}\n"
  },
  {
    "path": "readme.md",
    "content": "<p align=\"center\">\n  <img height=\"400\" src=\"https://i.imgur.com/eMYYMla.jpg\" />\n</p>\n\n    yarn add react-use-measure\n\nThis 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.\n\n### Why do we need this hook?\n\nBecause 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.\n\nYou can try a live demo here: https://codesandbox.io/s/musing-kare-4fblz\n\n# Usage\n\n```jsx\nimport useMeasure from 'react-use-measure'\n\nfunction App() {\n  const [ref, bounds] = useMeasure()\n\n  // consider that knowing bounds is only possible *after* the view renders\n  // so you'll get zero values on the first run and be informed later\n\n  return <div ref={ref} />\n}\n```\n\n# Api\n\n```jsx\ninterface RectReadOnly {\n  readonly x: number\n  readonly y: number\n  readonly width: number\n  readonly height: number\n  readonly top: number\n  readonly right: number\n  readonly bottom: number\n  readonly left: number\n}\n\ntype Options = {\n  // Debounce events in milliseconds\n  debounce?: number | { scroll: number; resize: number }\n  // React to nested scroll changes, don't use this if you know your view is static\n  scroll?: boolean\n  // You can optionally inject a resize-observer polyfill\n  polyfill?: { new (cb: ResizeObserverCallback): ResizeObserver }\n  // Measure size using offsetHeight and offsetWidth to ignore parent scale transforms\n  offsetSize?: boolean\n}\n\nuseMeasure(\n  options: Options = { debounce: 0, scroll: false }\n): [React.MutableRefObject<HTMLElement | SVGElement>, RectReadOnly]\n```\n\n# ⚠️ Notes\n\n### Resize-observer polyfills\n\nThis 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).\n\n```jsx\nimport { ResizeObserver } from '@juggle/resize-observer'\n\nfunction App() {\n  const [ref, bounds] = useMeasure({ polyfill: ResizeObserver })\n```\n\n### Multiple refs\n\nuseMeasure 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).\n"
  },
  {
    "path": "src/index.ts",
    "content": "import { useEffect, useState, useRef, useMemo } from 'react'\n\nfunction createDebounce<T extends (...args: any[]) => void>(callback: T, ms: number) {\n  let timeoutId: number\n\n  return (...args: Parameters<T>): void => {\n    window.clearTimeout(timeoutId)\n    timeoutId = window.setTimeout(() => callback(...args), ms)\n  }\n}\n\ndeclare type ResizeObserverCallback = (entries: any[], observer: ResizeObserver) => void\ndeclare class ResizeObserver {\n  constructor(callback: ResizeObserverCallback)\n  observe(target: Element, options?: any): void\n  unobserve(target: Element): void\n  disconnect(): void\n  static toString(): string\n}\n\nexport interface RectReadOnly {\n  readonly x: number\n  readonly y: number\n  readonly width: number\n  readonly height: number\n  readonly top: number\n  readonly right: number\n  readonly bottom: number\n  readonly left: number\n  [key: string]: number\n}\n\ntype HTMLOrSVGElement = HTMLElement | SVGElement\n\ntype Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly, () => void]\n\ntype State = {\n  element: HTMLOrSVGElement | null\n  scrollContainers: HTMLOrSVGElement[] | null\n  resizeObserver: ResizeObserver | null\n  lastBounds: RectReadOnly\n  orientationHandler: null | (() => void)\n}\n\nexport type Options = {\n  debounce?: number | { scroll: number; resize: number }\n  scroll?: boolean\n  polyfill?: { new (cb: ResizeObserverCallback): ResizeObserver }\n  offsetSize?: boolean\n}\n\nfunction useMeasure(\n  { debounce, scroll, polyfill, offsetSize }: Options = { debounce: 0, scroll: false, offsetSize: false },\n): Result {\n  const ResizeObserver =\n    polyfill || (typeof window === 'undefined' ? class ResizeObserver {} : (window as any).ResizeObserver)\n\n  if (!ResizeObserver) {\n    throw new Error(\n      'This browser does not support ResizeObserver out of the box. See: https://github.com/react-spring/react-use-measure/#resize-observer-polyfills',\n    )\n  }\n\n  const [bounds, set] = useState<RectReadOnly>({\n    left: 0,\n    top: 0,\n    width: 0,\n    height: 0,\n    bottom: 0,\n    right: 0,\n    x: 0,\n    y: 0,\n  })\n\n  // keep all state in a ref\n  const state = useRef<State>({\n    element: null,\n    scrollContainers: null,\n    resizeObserver: null,\n    lastBounds: bounds,\n    orientationHandler: null,\n  })\n\n  // set actual debounce values early, so effects know if they should react accordingly\n  const scrollDebounce = debounce ? (typeof debounce === 'number' ? debounce : debounce.scroll) : null\n  const resizeDebounce = debounce ? (typeof debounce === 'number' ? debounce : debounce.resize) : null\n\n  // make sure to update state only as long as the component is truly mounted\n  const mounted = useRef(false)\n  useEffect(() => {\n    mounted.current = true\n    return () => void (mounted.current = false)\n  })\n\n  // memoize handlers, so event-listeners know when they should update\n  const [forceRefresh, resizeChange, scrollChange] = useMemo(() => {\n    const callback = () => {\n      if (!state.current.element) return\n      const { left, top, width, height, bottom, right, x, y } =\n        state.current.element.getBoundingClientRect() as unknown as RectReadOnly\n\n      const size = {\n        left,\n        top,\n        width,\n        height,\n        bottom,\n        right,\n        x,\n        y,\n      }\n\n      if (state.current.element instanceof HTMLElement && offsetSize) {\n        size.height = state.current.element.offsetHeight\n        size.width = state.current.element.offsetWidth\n      }\n\n      Object.freeze(size)\n      if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) set((state.current.lastBounds = size))\n    }\n    return [\n      callback,\n      resizeDebounce ? createDebounce(callback, resizeDebounce) : callback,\n      scrollDebounce ? createDebounce(callback, scrollDebounce) : callback,\n    ]\n  }, [set, offsetSize, scrollDebounce, resizeDebounce])\n\n  // cleanup current scroll-listeners / observers\n  function removeListeners() {\n    if (state.current.scrollContainers) {\n      state.current.scrollContainers.forEach((element) => element.removeEventListener('scroll', scrollChange, true))\n      state.current.scrollContainers = null\n    }\n\n    if (state.current.resizeObserver) {\n      state.current.resizeObserver.disconnect()\n      state.current.resizeObserver = null\n    }\n\n    if (state.current.orientationHandler) {\n      if ('orientation' in screen && 'removeEventListener' in screen.orientation) {\n        screen.orientation.removeEventListener('change', state.current.orientationHandler)\n      } else if ('onorientationchange' in window) {\n        window.removeEventListener('orientationchange', state.current.orientationHandler)\n      }\n    }\n  }\n\n  // add scroll-listeners / observers\n  function addListeners() {\n    if (!state.current.element) return\n    state.current.resizeObserver = new ResizeObserver(scrollChange)\n    state.current.resizeObserver!.observe(state.current.element)\n    if (scroll && state.current.scrollContainers) {\n      state.current.scrollContainers.forEach((scrollContainer) =>\n        scrollContainer.addEventListener('scroll', scrollChange, { capture: true, passive: true }),\n      )\n    }\n\n    // Handle orientation changes\n    state.current.orientationHandler = () => {\n      scrollChange()\n    }\n\n    // Use screen.orientation if available\n    if ('orientation' in screen && 'addEventListener' in screen.orientation) {\n      screen.orientation.addEventListener('change', state.current.orientationHandler)\n    } else if ('onorientationchange' in window) {\n      // Fallback to orientationchange event\n      window.addEventListener('orientationchange', state.current.orientationHandler)\n    }\n  }\n\n  // the ref we expose to the user\n  const ref = (node: HTMLOrSVGElement | null) => {\n    if (!node || node === state.current.element) return\n    removeListeners()\n    state.current.element = node\n    state.current.scrollContainers = findScrollContainers(node)\n    addListeners()\n  }\n\n  // add general event listeners\n  useOnWindowScroll(scrollChange, Boolean(scroll))\n  useOnWindowResize(resizeChange)\n\n  // respond to changes that are relevant for the listeners\n  useEffect(() => {\n    removeListeners()\n    addListeners()\n  }, [scroll, scrollChange, resizeChange])\n\n  // remove all listeners when the components unmounts\n  useEffect(() => removeListeners, [])\n  return [ref, bounds, forceRefresh]\n}\n\n// Adds native resize listener to window\nfunction useOnWindowResize(onWindowResize: (event: Event) => void) {\n  useEffect(() => {\n    const cb = onWindowResize\n    window.addEventListener('resize', cb)\n    return () => void window.removeEventListener('resize', cb)\n  }, [onWindowResize])\n}\nfunction useOnWindowScroll(onScroll: () => void, enabled: boolean) {\n  useEffect(() => {\n    if (enabled) {\n      const cb = onScroll\n      window.addEventListener('scroll', cb, { capture: true, passive: true })\n      return () => void window.removeEventListener('scroll', cb, true)\n    }\n  }, [onScroll, enabled])\n}\n\n// Returns a list of scroll offsets\nfunction findScrollContainers(element: HTMLOrSVGElement | null): HTMLOrSVGElement[] {\n  const result: HTMLOrSVGElement[] = []\n  if (!element || element === document.body) return result\n  const { overflow, overflowX, overflowY } = window.getComputedStyle(element)\n  if ([overflow, overflowX, overflowY].some((prop) => prop === 'auto' || prop === 'scroll')) result.push(element)\n  return [...result, ...findScrollContainers(element.parentElement)]\n}\n\n// Checks if element boundaries are equal\nconst keys: (keyof RectReadOnly)[] = ['x', 'y', 'top', 'bottom', 'left', 'right', 'width', 'height']\nconst areBoundsEqual = (a: RectReadOnly, b: RectReadOnly): boolean => keys.every((key) => a[key] === b[key])\n\nexport default useMeasure\n"
  },
  {
    "path": "tests/index.test.tsx",
    "content": "import * as React from 'react'\nimport { render, cleanup, RenderResult, fireEvent } from '@testing-library/react'\nimport Polyfill from 'resize-observer-polyfill'\nimport { afterEach, describe, it, expect } from 'vitest'\n\nimport useMeasure, { Options } from '../src/index'\n\n/**\n * Helpers\n */\n\nconst getBounds = (tools: RenderResult): DOMRect => JSON.parse(tools.getByTestId('box').innerHTML)\nconst nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))\nconst wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\nfunction ignoreWindowErrors(test: () => void) {\n  const onErrorBackup = window.onerror\n  window.onerror = () => null\n  const consoleError = console.error\n  console.error = () => null\n\n  test()\n\n  window.onerror = onErrorBackup\n  console.error = consoleError\n}\n\n/**\n * Tests\n */\n\nafterEach(() => {\n  cleanup()\n  window.scrollTo({ top: 0, left: 0 })\n})\n\ndescribe('useMeasure', () => {\n  type Props = {\n    switchRef?: boolean\n    scale?: number\n    onRender?: () => void\n    options?: Options\n    polyfill?: boolean\n    offsetSize?: boolean\n  }\n\n  function Test({ switchRef, options, onRender, polyfill, scale = 1, offsetSize = false }: Props) {\n    const [ref, bounds] = useMeasure({ ...options, polyfill: polyfill ? Polyfill : undefined, offsetSize })\n    const [big, setBig] = React.useState(false)\n\n    if (onRender) {\n      onRender()\n    }\n\n    return (\n      <>\n        <style>{'body, html { margin: 0; } body { height: 200vh; }'}</style>\n        <div\n          data-testid=\"wrapper\"\n          style={{\n            transform: `scale(${scale})`,\n            width: '500px',\n            height: '500px',\n            overflow: 'auto',\n          }}\n        >\n          <div\n            ref={!switchRef ? ref : undefined}\n            data-testid=\"box\"\n            onClick={() => setBig(!big)}\n            style={{\n              width: `${big ? 400 : 200}px`,\n              height: `${big ? 400 : 200}px`,\n              overflow: 'hidden',\n              fontSize: '8px',\n            }}\n          >\n            {JSON.stringify(bounds)}\n          </div>\n          <div style={{ width: 2000, height: 2000 }} />\n        </div>\n        <div ref={switchRef ? ref : null}>Dummy</div>\n      </>\n    )\n  }\n\n  it('gives empty initial bounds on first render', async () => {\n    const tools = render(<Test />)\n\n    expect(getBounds(tools).width).toBe(0)\n    expect(getBounds(tools).height).toBe(0)\n    expect(getBounds(tools).top).toBe(0)\n    expect(getBounds(tools).left).toBe(0)\n  })\n\n  it('renders 1 additional time after first render', async () => {\n    let count = 0\n\n    render(<Test onRender={() => count++} />)\n\n    await nextFrame()\n\n    expect(count).toBe(2)\n  })\n\n  it('gives correct dimensions and positions after initial render', async () => {\n    const tools = render(<Test />)\n\n    await nextFrame()\n\n    expect(getBounds(tools).width).toBe(200)\n    expect(getBounds(tools).height).toBe(200)\n    expect(getBounds(tools).top).toBe(0)\n    expect(getBounds(tools).left).toBe(0)\n  })\n\n  it('gives correct dimensions and positions when the tracked elements changes in size', async () => {\n    const tools = render(<Test />)\n\n    fireEvent.click(tools.getByTestId('box'))\n\n    await nextFrame()\n\n    expect(getBounds(tools).width).toBe(400)\n    expect(getBounds(tools).height).toBe(400)\n    expect(getBounds(tools).top).toBe(0)\n    expect(getBounds(tools).left).toBe(0)\n  })\n\n  it('gives correct dimensions and positions when the page is scrolled', async () => {\n    const tools = render(<Test options={{ scroll: true }} />)\n\n    window.scrollTo({ top: 200 })\n\n    await nextFrame()\n\n    expect(getBounds(tools).top).toBe(-200)\n    expect(getBounds(tools).left).toBe(0)\n  })\n  it('gives correct dimensions and positions when the wrapper is scrolled', async () => {\n    const tools = render(<Test options={{ scroll: true }} />)\n\n    tools.getByTestId('wrapper').scrollTo({ top: 200 })\n\n    await nextFrame()\n\n    expect(getBounds(tools).top).toBe(-200)\n    expect(getBounds(tools).left).toBe(0)\n  })\n\n  it('gives correct size when offsetSize: true and parent is scaled', async () => {\n    const tools = render(<Test offsetSize scale={0.8} />)\n\n    await nextFrame()\n\n    expect(getBounds(tools).width).toBe(200)\n    expect(getBounds(tools).height).toBe(200)\n  })\n\n  it('gives correct size when offsetSize: false and parent is scaled', async () => {\n    const tools = render(<Test scale={0.8} />)\n\n    await nextFrame()\n\n    expect(getBounds(tools).width).toBe(200 * 0.8)\n    expect(getBounds(tools).height).toBe(200 * 0.8)\n  })\n\n  it('debounces the scroll events', async () => {\n    const tools = render(<Test options={{ scroll: true, debounce: { scroll: 50, resize: 0 } }} />)\n\n    const wrapper = tools.getByTestId('wrapper')\n\n    wrapper.scrollTo({ top: 200 })\n    await nextFrame()\n    wrapper.scrollTo({ top: 201 })\n    await nextFrame()\n    wrapper.scrollTo({ top: 202 })\n    await nextFrame()\n\n    expect(getBounds(tools).top).toBe(0)\n\n    await wait(100)\n    expect(getBounds(tools).top).toBe(-202)\n  })\n\n  // this one fails and needs to be fixed\n  it('detects changes in ref', async () => {\n    const tools = render(<Test />)\n\n    await wait(100)\n\n    tools.rerender(<Test switchRef />)\n\n    await nextFrame()\n\n    expect(getBounds(tools).top).toBe(500)\n  })\n\n  it('throws an descriptive error when the browser does not support ResizeObserver', () => {\n    const RO = (window as any).ResizeObserver\n    ;(window as any).ResizeObserver = null\n\n    ignoreWindowErrors(() => {\n      expect(() => render(<Test />)).toThrow(\n        'This browser does not support ResizeObserver out of the box. See: https://github.com/react-spring/react-use-measure/#resize-observer-polyfills',\n      )\n    })\n    ;(window as any).ResizeObserver = RO\n  })\n\n  it('does not throw when a ResizeObserver polyfill was provided', () => {\n    const RO = (window as any).ResizeObserver\n    ;(window as any).ResizeObserver = null\n\n    ignoreWindowErrors(() => {\n      expect(() => render(<Test polyfill />)).not.toThrow(\n        'This browser does not support ResizeObserver out of the box. See: https://github.com/react-spring/react-use-measure/#resize-observer-polyfills',\n      )\n    })\n    ;(window as any).ResizeObserver = RO\n  })\n})\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es6\",\n    \"module\": \"commonjs\",\n    \"moduleResolution\": \"node\",\n    \"esModuleInterop\": true,\n    \"jsx\": \"react\",\n    \"pretty\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"declaration\": true,\n    \"removeComments\": true,\n    \"emitDeclarationOnly\": true,\n    \"outDir\": \"dist\",\n    \"resolveJsonModule\": true\n  },\n  \"include\": [\"./src\"],\n  \"exclude\": [\"./node_modules/**/*\"]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import * as path from 'node:path'\nimport * as vite from 'vite'\n\nexport default vite.defineConfig({\n  root: process.argv[2] ? undefined : 'demo',\n  resolve: {\n    alias: {\n      'use-measure': path.resolve(__dirname, './src'),\n    },\n  },\n  test: {\n    browser: {\n      provider: 'playwright',\n      enabled: true,\n      headless: true,\n      screenshotFailures: false,\n      instances: [{ browser: 'chromium' }],\n    },\n  },\n  build: {\n    target: 'es2018',\n    sourcemap: true,\n    lib: {\n      formats: ['es', 'cjs'],\n      entry: 'src/index.ts',\n      fileName: '[name]',\n    },\n    rollupOptions: {\n      external: (id: string) => !id.startsWith('.') && !path.isAbsolute(id),\n      output: {\n        sourcemapExcludeSources: true,\n      },\n    },\n  },\n  plugins: [\n    {\n      name: 'vite-minify',\n      renderChunk: {\n        order: 'post',\n        handler(code, { fileName }) {\n          return vite.transformWithEsbuild(code, fileName, { minify: true, target: 'es2018' })\n        },\n      },\n    },\n  ],\n})\n"
  }
]