···11+"use client";
22+import { useEffect, useRef } from "react";
33+import { useSpring, animated } from "@react-spring/web";
44+import useMeasure from "react-use-measure";
55+66+// Animates a replies list's height when it opens/closes instead of snapping
77+// it in and out of the DOM. Shared by document comments and Bluesky thread
88+// replies so both collapse with the same motion.
99+export const CollapsibleReplies = (props: {
1010+ open: boolean;
1111+ children: React.ReactNode;
1212+}) => {
1313+ let [ref, { height }] = useMeasure();
1414+ // Skip the spring on the first render with a real height so replies that
1515+ // are open by default don't animate in on page load.
1616+ let measured = useRef(false);
1717+ let style = useSpring({
1818+ height: props.open ? height : 0,
1919+ opacity: props.open ? 1 : 0,
2020+ immediate: !measured.current,
2121+ config: { tension: 280, friction: 30 },
2222+ });
2323+ useEffect(() => {
2424+ if (height > 0) measured.current = true;
2525+ }, [height]);
2626+2727+ // overflow:hidden is needed to animate height, but it also clips horizontally.
2828+ // Reply rows pull their avatar slightly left (negative margins) to sit on the
2929+ // thread line, so extend the clip box leftward (pl-2 + matching -ml-2 keeps the
3030+ // content in place) to keep avatars from getting shaved off.
3131+ return (
3232+ <animated.div
3333+ className="pl-2 -ml-2"
3434+ style={{ ...style, overflow: "hidden" }}
3535+ >
3636+ <div ref={ref}>{props.children}</div>
3737+ </animated.div>
3838+ );
3939+};