Skip to content

Follows its content’s height on a spring, measured, interruptible, auto at rest.

01Preview

Environment variables

Production
  • DATABASE_URL••••••••
  • STRIPE_SECRET_KEY••••••••
  • NEXT_PUBLIC_APP_URLhttps://app.acme.dev
3 variables

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion

03Usage

import { AnimateHeight } from "@/components/ui/animate-height";

<AnimateHeight className="rounded-xl border border-line">
  {error && <p className="text-danger">{error}</p>}
  {rows.map((row) => <Row key={row.id} {...row} />)}
</AnimateHeight>

04Source

"use client";
import { animate, motionValue, useReducedMotion, type AnimationPlaybackControlsWithThen, type Transition } from "motion/react";
import { useEffect, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/cn";

export type AnimateHeightProps = Omit<React.ComponentProps<"div">, "style"> & {
  style?: Omit<React.CSSProperties, "height">;
  /** Replace the default spring. Durations otherwise scale with the distance travelled. */
  transition?: Transition;
  /** Apply changes without animating while true, e.g. for keyboard-driven or bulk updates. */
  instant?: boolean;
  /** Classes for the inner, measured element. */
  contentClassName?: string;
  /** Called with the content's new height in px each time it changes. */
  onHeightChange?: (height: number) => void;
};

// Short moves stay quick, long ones get a little more time, never past 350ms.
const springFor = (distance: number): Transition => ({
  type: "spring",
  bounce: 0,
  visualDuration: 0.18 + (Math.min(distance, 400) / 400) * 0.17,
});

export function AnimateHeight({
  transition,
  instant = false,
  contentClassName,
  onHeightChange,
  className,
  style,
  children,
  ref,
  ...rest
}: AnimateHeightProps) {
  const reduce = useReducedMotion();
  const outerRef = useRef<HTMLDivElement>(null);
  const innerRef = useRef<HTMLDivElement>(null);
  const options = useRef({ transition, skip: false, onHeightChange });
  // The outer box is also the caller's ref; the observer needs its own handle on it.
  useImperativeHandle(ref, () => outerRef.current as HTMLDivElement, []);

  useEffect(() => {
    options.current = { transition, skip: instant || !!reduce, onHeightChange };
  });

  useEffect(() => {
    const outer = outerRef.current;
    const inner = innerRef.current;
    if (!outer || !inner) return;
    // Height is "auto" at rest, so the box follows its content with no JavaScript in
    // the loop. It becomes a number only while a change is animating.
    const height = motionValue(0);
    const unsubscribe = height.on("change", (v) => {
      if (outer.dataset.animating !== undefined) outer.style.height = `${v}px`;
    });
    let last: number | null = null;
    let hidden = false;
    let controls: AnimationPlaybackControlsWithThen | undefined;

    const settle = () => {
      delete outer.dataset.animating;
      outer.style.height = "";
    };

    const observer = new ResizeObserver(([entry]) => {
      const next = entry.borderBoxSize?.[0]?.blockSize ?? inner.offsetHeight;
      const visible = outer.getClientRects().length > 0;
      const prev = last;
      const wasHidden = hidden;
      last = next;
      hidden = !visible;
      if (prev === null || prev === next) return;
      options.current.onHeightChange?.(next);
      // First measure, a hidden box, or a box that is just being shown: no animation.
      if (!visible || wasHidden || options.current.skip) {
        controls?.stop();
        settle();
        return;
      }
      const from = outer.dataset.animating !== undefined ? height.get() : prev;
      // Hold the old height before this frame paints (the observer runs after layout,
      // before paint), then animate. Retargeting mid-flight keeps the spring's velocity.
      if (outer.dataset.animating === undefined) height.jump(from);
      outer.dataset.animating = "";
      outer.style.height = `${from}px`;
      controls = animate(height, next, options.current.transition ?? springFor(Math.abs(next - from)));
      const mine = controls;
      mine.then(() => {
        if (controls === mine) settle();
      });
    });

    observer.observe(inner);
    return () => {
      observer.disconnect();
      controls?.stop();
      unsubscribe();
    };
  }, []);

  return (
    <div
      ref={outerRef}
      className={cn(
        // Content-box, so padding and borders on this element sit outside the animated height.
        // Clipped on the vertical axis only, and only while moving: side-to-side overflow
        // (focus rings, shadows, a sideways slide) is never cut.
        "box-content data-[animating]:overflow-y-clip",
        className,
      )}
      style={style}
      {...rest}
    >
      <div ref={innerRef} className={cn("flow-root", contentClassName)}>
        {children}
      </div>
    </div>
  );
}

05Props

PropTypeDefaultDescription
childrenReactNodeAnything. Whatever changes its size, a new row, a wrapped line, an image loading, is followed.
transitionTransitionReplace the default spring. By default the spring has no bounce and its duration scales with the distance, 180–350ms.
instantbooleanfalseApply changes without animating while true, for keyboard-driven or bulk updates.
contentClassNamestringClasses for the inner element that is measured.
onHeightChange(height: number) => voidCalled with the content’s new height in px each time it changes.

06Notes

Behavior

  • Height is auto at rest and a number only while a change animates, so the box never goes stale, works in print and during SSR, and costs nothing when idle.
  • The ResizeObserver runs after layout and before paint, so the old height is pinned in that same frame: new content never flashes over what sits below it.
  • Clips its content only while moving, and only top to bottom (overflow-y: clip). At rest overflow is visible, so focus rings, shadows and popovers inside are never cut off.
  • The box is content-box: padding and borders on it sit outside the animated height. A change while hidden, or the first measure after being shown, applies without animating.

Motion

  • A spring with no bounce, visual duration 180ms for small moves up to 350ms from 400px, so a validation line and a whole panel both feel right.
  • Retargeting mid-flight keeps the spring’s velocity instead of restarting a curve, so rapid changes glide rather than stutter.
  • Reduced motion: the height changes at once. The content was never hidden, so nothing is lost.

Accessibility

  • Adds no roles or semantics: it is a plain div around your content, which keeps its own structure and focus order.
  • Content is in the DOM and readable the whole time; only its clipping animates.