useElementSizeMap

A client-safe React hook that measures multiple keyed element sizes with ResizeObserver so animated shells can morph to active content.

animationmorph

Measure multiple layouts before they become active

useElementSizeMap stores width and height by key, so a component can animate an outer shell, highlight, toolbar, or floating panel toward the measured size of the selected item. It is useful when inactive content still needs to define the next layout target.

Inbox

Focused triage

3
Mentions12
Assigned4
Snoozed2

Code

use-element-size-map.ts
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

export type ElementSize = {
  width: number;
  height: number;
};

/**
 * Tracks the rendered size of multiple keyed elements with ResizeObserver.
 *
 * Use it when an animated shell, indicator, toolbar, or floating panel needs to
 * move toward the measured width/height of whichever keyed element is active.
 *
 * @example
 *   const { setMeasureRef, sizes } = useElementSizeMap<HTMLDivElement>();
 *   const activeSize = sizes[activeId];
 *
 *   return (
 *     <>
 *       <div aria-hidden className="invisible absolute">
 *         {items.map((item) => (
 *           <div key={item.id} ref={setMeasureRef(item.id)}>
 *             {item.content}
 *           </div>
 *         ))}
 *       </div>
 *       <motion.div animate={activeSize}>
 *         {activeItem.content}
 *       </motion.div>
 *     </>
 *   );
 */
export function useElementSizeMap<T extends HTMLElement>(threshold = 0.5) {
  const [sizes, setSizes] = useState<Record<string, ElementSize>>({});
  const observerRef = useRef<ResizeObserver | null>(null);
  const elements = useRef(new Map<string, T>());
  const idsByElement = useRef(new WeakMap<Element, string>());
  const callbacks = useRef(new Map<string, (node: T | null) => void>());
  const thresholdRef = useRef(threshold);

  useEffect(() => {
    thresholdRef.current = threshold;
  }, [threshold]);

  useEffect(() => {
    const activeElements = elements.current;

    return () => {
      observerRef.current?.disconnect();
      observerRef.current = null;
      activeElements.clear();
      idsByElement.current = new WeakMap();
    };
  }, []);

  const updateSizes = useCallback(
    (updates: ReadonlyArray<readonly [string, ElementSize]>) => {
      setSizes((current) => {
        let next = current;

        for (const [id, nextSize] of updates) {
          const currentSize = current[id];

          if (
            currentSize &&
            Math.abs(currentSize.width - nextSize.width) <=
              thresholdRef.current &&
            Math.abs(currentSize.height - nextSize.height) <=
              thresholdRef.current
          ) {
            continue;
          }

          if (next === current) {
            next = { ...current };
          }

          next[id] = nextSize;
        }

        return next;
      });
    },
    [],
  );

  const getObserver = useCallback(() => {
    if (typeof ResizeObserver === "undefined") return null;

    if (!observerRef.current) {
      observerRef.current = new ResizeObserver((entries) => {
        const updates: Array<readonly [string, ElementSize]> = [];

        for (const entry of entries) {
          const id = idsByElement.current.get(entry.target);

          if (id === undefined) continue;

          updates.push([id, readElementSize(entry.target, entry)]);
        }

        if (updates.length > 0) {
          updateSizes(updates);
        }
      });
    }

    return observerRef.current;
  }, [updateSizes]);

  const disconnect = useCallback((id: string) => {
    const element = elements.current.get(id);

    if (!element) return;

    observerRef.current?.unobserve(element);
    idsByElement.current.delete(element);
    elements.current.delete(id);
  }, []);

  const updateSize = useCallback(
    (id: string, nextSize: ElementSize) => {
      updateSizes([[id, nextSize]]);
    },
    [updateSizes],
  );

  const setMeasureRef = useCallback(
    (id: string) => {
      const current = callbacks.current.get(id);

      if (current) {
        return current;
      }

      const ref = (node: T | null) => {
        disconnect(id);

        if (!node) {
          return;
        }

        updateSize(id, readElementSize(node));

        const observer = getObserver();

        if (observer) {
          elements.current.set(id, node);
          idsByElement.current.set(node, id);
          observer.observe(node);
        }
      };

      callbacks.current.set(id, ref);

      return ref;
    },
    [disconnect, getObserver, updateSize],
  );

  return { setMeasureRef, sizes } as const;
}

function readElementSize(
  element: Element,
  entry?: ResizeObserverEntry,
): ElementSize {
  const borderBoxSize = Array.isArray(entry?.borderBoxSize)
    ? entry.borderBoxSize[0]
    : entry?.borderBoxSize;

  if (borderBoxSize) {
    return {
      width: borderBoxSize.inlineSize,
      height: borderBoxSize.blockSize,
    };
  }

  const rect = element.getBoundingClientRect();

  return {
    width: rect.width,
    height: rect.height,
  };
}

Installation

pnpm dlx shadcn@latest add @ericts/use-element-size-map