101 lines · 3.1 KB · TypeScript
RawDownload
"use client"; import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; const HEIGHT_TRANSITION_FALLBACK_MS = 250; export function AnimatedHeight({  children,  holdHeight = false,}: {  readonly children: ReactNode;  /** Retain the previous content height while a replacement is loading. */  readonly holdHeight?: boolean;}) {  const contentRef = useRef<HTMLDivElement>(null);  const [heightState, setHeightState] = useState<{    readonly height: number | null;    readonly isClipping: boolean;  }>({ height: null, isClipping: false });   useEffect(() => {    if (!heightState.isClipping) return;    const timeoutId = window.setTimeout(() => {      setHeightState((currentState) =>        currentState.isClipping ? { ...currentState, isClipping: false } : currentState,      );    }, HEIGHT_TRANSITION_FALLBACK_MS);    return () => window.clearTimeout(timeoutId);  }, [heightState.height, heightState.isClipping]);   useLayoutEffect(() => {    if (holdHeight) return;    const element = contentRef.current;    if (!element) return;    let firstFrameId: number | null = null;    let secondFrameId: number | null = null;     const updateHeight = () => {      const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height);      setHeightState((currentState) => {        if (currentState.height === nextHeight) return currentState;        return {          height: nextHeight,          isClipping: currentState.height !== null,        };      });    };    const cancelPendingFrames = () => {      if (firstFrameId !== null) {        window.cancelAnimationFrame(firstFrameId);        firstFrameId = null;      }      if (secondFrameId !== null) {        window.cancelAnimationFrame(secondFrameId);        secondFrameId = null;      }    };    const updateHeightAfterPaint = () => {      cancelPendingFrames();      updateHeight();      firstFrameId = window.requestAnimationFrame(() => {        firstFrameId = null;        updateHeight();        secondFrameId = window.requestAnimationFrame(() => {          secondFrameId = null;          updateHeight();        });      });    };     updateHeightAfterPaint();    const resizeObserver = new ResizeObserver(updateHeightAfterPaint);    resizeObserver.observe(element);    return () => {      resizeObserver.disconnect();      cancelPendingFrames();    };  }, [holdHeight]);   return (    <div      data-slot="animated-height"      className="transition-[height] duration-200 ease-out motion-reduce:transition-none"      style={        heightState.height === null          ? undefined          : { height: heightState.height, overflow: heightState.isClipping ? "hidden" : "visible" }      }      onTransitionEnd={(event) => {        if (event.target !== event.currentTarget || event.propertyName !== "height") return;        setHeightState((currentState) =>          currentState.isClipping ? { ...currentState, isClipping: false } : currentState,        );      }}    >      <div ref={contentRef} style={holdHeight ? { height: "100%" } : undefined}>        {children}      </div>    </div>  );}