1"use client";
2
3import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
4import { observeResize } from "../lib/observeResize";
5
6const HEIGHT_TRANSITION_FALLBACK_MS = 250;
7
8export function AnimatedHeight({
9 children,
10 holdHeight = false,
11}: {
12 readonly children: ReactNode;
13 /** Retain the previous content height while a replacement is loading. */
14 readonly holdHeight?: boolean;
15}) {
16 const contentRef = useRef<HTMLDivElement>(null);
17 const [heightState, setHeightState] = useState<{
18 readonly height: number | null;
19 readonly isClipping: boolean;
20 }>({ height: null, isClipping: false });
21
22 useEffect(() => {
23 if (!heightState.isClipping) return;
24 const timeoutId = window.setTimeout(() => {
25 setHeightState((currentState) =>
26 currentState.isClipping ? { ...currentState, isClipping: false } : currentState,
27 );
28 }, HEIGHT_TRANSITION_FALLBACK_MS);
29 return () => window.clearTimeout(timeoutId);
30 }, [heightState.height, heightState.isClipping]);
31
32 useLayoutEffect(() => {
33 if (holdHeight) return;
34 const element = contentRef.current;
35 if (!element) return;
36 let firstFrameId: number | null = null;
37 let secondFrameId: number | null = null;
38
39 const updateHeight = () => {
40 const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height);
41 setHeightState((currentState) => {
42 if (currentState.height === nextHeight) return currentState;
43 return {
44 height: nextHeight,
45 isClipping: currentState.height !== null,
46 };
47 });
48 };
49 const cancelPendingFrames = () => {
50 if (firstFrameId !== null) {
51 window.cancelAnimationFrame(firstFrameId);
52 firstFrameId = null;
53 }
54 if (secondFrameId !== null) {
55 window.cancelAnimationFrame(secondFrameId);
56 secondFrameId = null;
57 }
58 };
59 const updateHeightAfterPaint = () => {
60 cancelPendingFrames();
61 updateHeight();
62 firstFrameId = window.requestAnimationFrame(() => {
63 firstFrameId = null;
64 updateHeight();
65 secondFrameId = window.requestAnimationFrame(() => {
66 secondFrameId = null;
67 updateHeight();
68 });
69 });
70 };
71
72 updateHeightAfterPaint();
73 const stopObserving = observeResize(element, updateHeightAfterPaint);
74 return () => {
75 stopObserving();
76 cancelPendingFrames();
77 };
78 }, [holdHeight]);
79
80 return (
81 <div
82 data-slot="animated-height"
83 className="transition-[height] duration-200 ease-out motion-reduce:transition-none [overflow-clip-margin:2px]"
84 style={
85 heightState.height === null
86 ? undefined
87 : { height: heightState.height, overflow: heightState.isClipping ? "clip" : "visible" }
88 }
89 onTransitionEnd={(event) => {
90 if (event.target !== event.currentTarget || event.propertyName !== "height") return;
91 setHeightState((currentState) =>
92 currentState.isClipping ? { ...currentState, isClipping: false } : currentState,
93 );
94 }}
95 >
96 <div ref={contentRef} style={holdHeight ? { height: "100%" } : undefined}>
97 {children}
98 </div>
99 </div>
100 );
101}