Follows its content’s height on a spring, measured, interruptible, auto at rest.
Motion primitivesmotion
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 motion03Usage
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
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Anything. Whatever changes its size, a new row, a wrapped line, an image loading, is followed. |
| transition | Transition | — | Replace the default spring. By default the spring has no bounce and its duration scales with the distance, 180–350ms. |
| instant | boolean | false | Apply changes without animating while true, for keyboard-driven or bulk updates. |
| contentClassName | string | — | Classes for the inner element that is measured. |
| onHeightChange | (height: number) => void | — | Called 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.