Skip to content
Source
Sign inSign up
Open History to see the last commit

AnimatedHeight.tsx

107 lines3.6 KB

1import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
2import { observeResize } from "../lib/observeResize";
3​
4const HEIGHT_TRANSITION_FALLBACK_MS = 250;
5​
6export function AnimatedHeight({
7 children,
8 holdHeight = false,
9}: {
10 readonly children: ReactNode;
11 readonly holdHeight?: boolean;
12}) {
13 const contentRef = useRef<HTMLDivElement>(null);
14 const [heightState, setHeightState] = useState<{
15 readonly height: number | null;
16 readonly isClipping: boolean;
17 }>({ height: null, isClipping: false });
18​
19 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- fallback timer that ends clipping when the height transition never reports its end
20 useEffect(() => {
21 if (!heightState.isClipping) return;
22 const timeoutId = window.setTimeout(() => {
23 setHeightState((currentState) =>
24 currentState.isClipping ? { ...currentState, isClipping: false } : currentState,
25 );
26 }, HEIGHT_TRANSITION_FALLBACK_MS);
27 return () => window.clearTimeout(timeoutId);
28 }, [
29 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A height change mid-transition starts a new transition, so the fallback timer restarts.
30 heightState.height,
31 heightState.isClipping,
32 ]);
33​
34 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the content element and observes its size before paint to animate height
35 useLayoutEffect(() => {
36 if (holdHeight) return;
37 const element = contentRef.current;
38 if (!element) return;
39 let firstFrameId: number | null = null;
40 let secondFrameId: number | null = null;
41​
42 const updateHeight = () => {
43 const nextHeight = Math.ceil(element.scrollHeight || element.getBoundingClientRect().height);
44 setHeightState((currentState) => {
45 if (currentState.height === nextHeight) return currentState;
46 return {
47 height: nextHeight,
48 isClipping: currentState.height !== null,
49 };
50 });
51 };
52 const cancelPendingFrames = () => {
53 if (firstFrameId !== null) {
54 window.cancelAnimationFrame(firstFrameId);
55 firstFrameId = null;
56 }
57 if (secondFrameId !== null) {
58 window.cancelAnimationFrame(secondFrameId);
59 secondFrameId = null;
60 }
61 };
62 const updateHeightAfterPaint = () => {
63 cancelPendingFrames();
64 updateHeight();
65 firstFrameId = window.requestAnimationFrame(() => {
66 firstFrameId = null;
67 updateHeight();
68 secondFrameId = window.requestAnimationFrame(() => {
69 secondFrameId = null;
70 updateHeight();
71 });
72 });
73 };
74​
75 updateHeightAfterPaint();
76 const stopObserving = observeResize(element, updateHeightAfterPaint);
77 return () => {
78 stopObserving();
79 cancelPendingFrames();
80 };
81 }, [holdHeight]);
82​
83 return (
84 <div
85 data-slot="animated-height"
86 className="transition-[height] duration-200 ease-out motion-reduce:transition-none [overflow-clip-margin:2px]"
87 style={
88 heightState.height === null
89 ? undefined
90 : {
91 height: heightState.height,
92 overflow: holdHeight || heightState.isClipping ? "clip" : "visible",
93 }
94 }
95 onTransitionEnd={(event) => {
96 if (event.target !== event.currentTarget || event.propertyName !== "height") return;
97 setHeightState((currentState) =>
98 currentState.isClipping ? { ...currentState, isClipping: false } : currentState,
99 );
100 }}
101 >
102 <div ref={contentRef} style={holdHeight ? { height: "100%" } : undefined}>
103 {children}
104 </div>
105 </div>
106 );