Shadcn Hooks

useElementSize

A hook to track an element's width and height

Loading...

Installation

npx shadcn@latest add @shadcnhooks/use-element-size
pnpm dlx shadcn@latest add @shadcnhooks/use-element-size
yarn dlx shadcn@latest add @shadcnhooks/use-element-size
bun x shadcn@latest add @shadcnhooks/use-element-size

Copy and paste the following code into your project.

create-effect-with-target.ts
import { isBrowser, isEqual, isFunction } from 'es-toolkit'
import { useRef } from 'react'
import { useUnmount } from '@/registry/hooks/use-unmount'
import type {
  DependencyList,
  EffectCallback,
  RefObject,
  useEffect,
  useLayoutEffect,
} from 'react'

type TargetValue<T> = T | undefined | null

type TargetType = HTMLElement | Element | Window | Document

export type BasicTarget<T extends TargetType = Element> =
  | (() => TargetValue<T>)
  | TargetValue<T>
  | RefObject<TargetValue<T>>

export function getTargetElement<T extends TargetType>(
  target: BasicTarget<T>,
  defaultElement?: T,
) {
  if (!isBrowser) {
    return undefined
  }

  if (!target) {
    return defaultElement
  }

  let targetElement: TargetValue<T>

  if (isFunction(target)) {
    targetElement = target()
  } else if ('current' in target) {
    targetElement = target.current
  } else {
    targetElement = target
  }

  return targetElement
}

export function createEffectWithTarget(
  useEffectType: typeof useEffect | typeof useLayoutEffect,
) {
  /**
   *
   * @param effect
   * @param deps
   * @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom
   */
  const useEffectWithTarget = (
    effect: EffectCallback,
    deps: DependencyList,
    target: BasicTarget<any> | BasicTarget<any>[],
  ) => {
    const hasInitRef = useRef(false)

    const lastElementRef = useRef<(Element | null)[]>([])
    const lastDepsRef = useRef<DependencyList>([])

    const unLoadRef = useRef<any>(undefined)

    useEffectType(() => {
      const targets = Array.isArray(target) ? target : [target]
      const els = targets.map((item) => getTargetElement(item))

      // init run
      if (!hasInitRef.current) {
        hasInitRef.current = true
        lastElementRef.current = els
        lastDepsRef.current = deps

        unLoadRef.current = effect()
        return
      }

      if (
        els.length !== lastElementRef.current.length ||
        !isEqual(lastElementRef.current, els) ||
        !isEqual(lastDepsRef.current, deps)
      ) {
        unLoadRef.current?.()

        lastElementRef.current = els
        lastDepsRef.current = deps
        unLoadRef.current = effect()
      }
    })

    useUnmount(() => {
      unLoadRef.current?.()
      // for react-refresh
      hasInitRef.current = false
    })
  }

  return useEffectWithTarget
}

use-effect-with-target.ts
import { useEffect } from 'react'
import { createEffectWithTarget } from '@/registry/lib/create-effect-with-target'

export const useEffectWithTarget = createEffectWithTarget(useEffect)

use-element-size.ts
import { useState } from 'react'
import { useEffectWithTarget } from '@/registry/hooks/use-effect-with-target'
import { getTargetElement } from '@/registry/lib/create-effect-with-target'
import type { BasicTarget } from '@/registry/lib/create-effect-with-target'

export interface ElementSize {
  width: number
  height: number
}

export interface UseElementSizeOptions {
  /**
   * ResizeObserver box model to use for measuring.
   *
   * @default 'content-box'
   */
  box?: ResizeObserverBoxOptions
}

const defaultInitialSize: ElementSize = {
  width: 0,
  height: 0,
}

type ResizeObserverBoxSize =
  | ResizeObserverSize
  | ReadonlyArray<ResizeObserverSize>

function toBoxSizeArray(
  boxSize: ResizeObserverBoxSize | undefined,
): readonly ResizeObserverSize[] {
  if (!boxSize) {
    return []
  }

  return Array.isArray(boxSize)
    ? boxSize
    : ([boxSize] as readonly ResizeObserverSize[])
}

function getSizeFromEntry(
  entry: ResizeObserverEntry,
  box: ResizeObserverBoxOptions,
): ElementSize {
  const boxSize =
    box === 'border-box'
      ? entry.borderBoxSize
      : box === 'device-pixel-content-box'
        ? entry.devicePixelContentBoxSize
        : entry.contentBoxSize

  const sizes = toBoxSizeArray(boxSize)

  if (sizes.length > 0) {
    return {
      width: sizes.reduce((sum, size) => sum + size.inlineSize, 0),
      height: sizes.reduce((sum, size) => sum + size.blockSize, 0),
    }
  }

  return {
    width: entry.contentRect.width,
    height: entry.contentRect.height,
  }
}

function isSvgElement(element: Element): element is SVGElement {
  return typeof SVGElement !== 'undefined' && element instanceof SVGElement
}

export function useElementSize(
  target: BasicTarget<Element>,
  initialSize: ElementSize = defaultInitialSize,
  options: UseElementSizeOptions = {},
): ElementSize {
  const { box = 'content-box' } = options
  const [size, setSize] = useState<ElementSize>(initialSize)

  const updateSize = (nextSize: ElementSize) => {
    setSize((currentSize) => {
      if (
        currentSize.width === nextSize.width &&
        currentSize.height === nextSize.height
      ) {
        return currentSize
      }

      return nextSize
    })
  }

  useEffectWithTarget(
    () => {
      const element = getTargetElement(target)

      if (!element) {
        updateSize({ width: 0, height: 0 })
        return
      }

      updateSize(initialSize)

      if (typeof ResizeObserver === 'undefined') {
        const rect = element.getBoundingClientRect()
        updateSize({
          width: rect.width,
          height: rect.height,
        })

        return
      }

      const observer = new ResizeObserver((entries) => {
        const entry = entries[0]
        if (!entry) {
          return
        }

        if (isSvgElement(element)) {
          const rect = element.getBoundingClientRect()
          updateSize({
            width: rect.width,
            height: rect.height,
          })
          return
        }

        updateSize(getSizeFromEntry(entry, box))
      })

      try {
        observer.observe(element, { box })
      } catch {
        observer.observe(element)
      }

      return () => {
        observer.disconnect()
      }
    },
    [box, initialSize.width, initialSize.height],
    target,
  )

  return size
}

API

export interface ElementSize {
  width: number
  height: number
}

export interface UseElementSizeOptions {
  /**
   * ResizeObserver box model to use for measuring.
   *
   * @default 'content-box'
   */
  box?: ResizeObserverBoxOptions
}

/**
 * A hook to track an element's width and height.
 *
 * @param target - The target element to observe
 * @param initialSize - Initial width and height
 * @param options - Measurement options
 * @returns Current element size
 */
export function useElementSize(
  target: BasicTarget<Element>,
  initialSize?: ElementSize,
  options?: UseElementSizeOptions,
): ElementSize

Credits

Last updated on

On this page