Auto Height
A box that follows the height of its content when the content changes, on a spring, instead of jumping.
$npx shadcn@latest add https://framebrick.co/r/auto-height.jsonFrom the Compose Digi template, see it in context.
SEO package
- Keyword and competitor audit
- Weekly ranking report
$450 per week
"use client";
import * as React from "react";
import { AutoHeight } from "@/components/ui/auto-height";
import { SegmentedControl } from "@/components/ui/segmented-control";
const plans = {
weekly: { price: "$450", unit: "per week", items: ["Keyword and competitor audit", "Weekly ranking report"] },
monthly: {
price: "$1,500",
unit: "per month",
items: ["Everything in the weekly plan", "Monthly strategy review", "Link building", "Technical fixes", "Content briefs", "Priority support"],
},
} as const;
/* The plan changes and the card follows: its height moves on a spring instead of jumping. */
export default function AutoHeightDemo() {
const [period, setPeriod] = React.useState<keyof typeof plans>("weekly");
const plan = plans[period];
return (
<AutoHeight className="w-full max-w-xs rounded-xl border bg-card p-5 text-card-foreground">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<h3 className="font-medium">SEO package</h3>
<SegmentedControl
label="Billing period"
size="sm"
options={[
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
]}
value={period}
onValueChange={setPeriod}
/>
</div>
<ul className="flex flex-col gap-2 text-sm text-muted-foreground">
{plan.items.map((item) => (
<li key={item} className="flex gap-2">
<span aria-hidden className="text-foreground">
✓
</span>
{item}
</li>
))}
</ul>
<p className="text-2xl font-semibold tracking-tight">
{plan.price} <span className="text-sm font-normal text-muted-foreground">{plan.unit}</span>
</p>
</div>
</AutoHeight>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/auto-height.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/auto-height.tsx."use client"; import * as React from "react"; import { animate, motion, useMotionValue, type AnimationPlaybackControls } from "motion/react"; import { cn } from "@/lib/utils"; /* * Auto Height — a box that grows or shrinks to the height of its content when the content changes, instead of jumping * (from the Compose Digi plan card: its Weekly / Monthly switch swaps the list and the price, and the card follows on * spring S2, bounce 0.2, duration 0.4s, with a slight overshoot). * * Put the box's own look on it (`className`: background, border, radius, padding): the height it animates includes its padding * and border, which are read once the box is on screen. It animates only when the content changes. When the width changes (a * window being resized, a column re-flowing) the height follows at once, because a spring chasing a drag would lag. * While it moves the box clips its content; at rest it clips nothing, so a focus ring or a shadow at its edge shows. * * The server renders the box at its natural height, with no inline height, so nothing is wrong without JavaScript or before * hydration; the height is measured before the first paint and the box never moves on its own at that point. Reduced motion * jumps: `animate()` is imperative, so MotionConfig would not stop it, and this reads the preference itself, in the * effect, never while rendering. */ export type AutoHeightProps = { className?: string; /** Spring bounce: 0 settles without overshoot, 0.2 (Compose Digi's) overshoots a little. */ bounce?: number; /** Seconds the move takes. */ duration?: number; children: React.ReactNode; }; export function AutoHeight({ className, bounce = 0.2, duration = 0.4, children }: AutoHeightProps) { const box = React.useRef<HTMLDivElement>(null); const content = React.useRef<HTMLDivElement>(null); const height = useMotionValue(0); const [ready, setReady] = React.useState(false); const [moving, setMoving] = React.useState(false); /* The height the box is heading for, the width it was measured at, and the move under way. */ const target = React.useRef(0); const width = React.useRef(0); const controls = React.useRef<AnimationPlaybackControls | null>(null); React.useLayoutEffect(() => { const outer = box.current; const inner = content.current; if (!outer || !inner) return; /* What the box adds to its content: its padding and border, since the height is the border box's. */ const extra = () => { const style = getComputedStyle(outer); return ["paddingTop", "paddingBottom", "borderTopWidth", "borderBottomWidth"].reduce((sum, key) => sum + parseFloat(style[key as "paddingTop"]), 0); }; /* The content's height, in layout pixels: the observer's own figure when it has one (fractional), else offsetHeight. Neither is changed by a transform on an ancestor, which getBoundingClientRect would be (a scaled preview, a page transition). */ const measure = (entry?: ResizeObserverEntry) => (entry ? entry.contentRect.height : inner.offsetHeight) + extra(); const settle = (next: number, immediate: boolean) => { controls.current?.stop(); target.current = next; if (immediate) { height.jump(next); setMoving(false); return; } setMoving(true); controls.current = animate(height, next, { type: "spring", bounce, duration, onComplete: () => setMoving(false) }); }; height.jump(measure()); target.current = height.get(); width.current = inner.offsetWidth; setReady(true); const observer = new ResizeObserver(([entry]) => { const next = measure(entry); const wide = inner.offsetWidth; const resized = Math.abs(wide - width.current) > 0.5; width.current = wide; if (Math.abs(next - target.current) < 0.5) return; settle(next, resized || window.matchMedia("(prefers-reduced-motion: reduce)").matches); }); observer.observe(inner); return () => { observer.disconnect(); controls.current?.stop(); }; }, [height, bounce, duration]); return ( <motion.div ref={box} style={ready ? { height } : undefined} className={cn("flow-root", moving ? "overflow-hidden" : "overflow-visible", className)}> <div ref={content} className="flow-root"> {children} </div> </motion.div> ); }
Usage
import { AutoHeight } from "@/components/ui/auto-height";
// the box's own look goes on it; its content can change height
<AutoHeight className="rounded-xl border bg-card p-5">
{period === "weekly" ? <WeeklyPlan /> : <MonthlyPlan />}
</AutoHeight>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | The box: background, border, radius, padding. The height it animates includes its padding and border. |
| bounce | number | 0.2 | Spring bounce: 0 settles without overshoot, 0.2 overshoots a little (Compose Digi's). |
| duration | number | 0.4 | Seconds the move takes. |
| children | ReactNode | — | The content. Whatever changes its height — a swapped list, an opened panel, text loading in — moves the box. |
- It is the Compose Digi plan card: the Weekly / Monthly switch swaps the list and the price, and the card's height follows on spring S2 (bounce 0.2, 0.4 s, a slight overshoot — for example 437.6 → 515 px, peaking at 516.0).
- It animates only when the content changes. When the width changes (a window being resized, a column re-flowing) the height follows at once, because a spring that chased a drag would lag behind it.
- While it moves the box clips its content; at rest it clips nothing, so a focus ring or a shadow at its edge shows.
- The server renders the box at its natural height with no inline height, so nothing is wrong without JavaScript or before hydration. The height is measured before the first paint, and the box does not move on its own then.
- Reduced motion makes it jump: the height is animated with the imperative animate(), which <MotionConfig> does not stop, so the component reads the preference itself, in an effect and never while rendering.
- It measures layout pixels (the observer's own figure), not getBoundingClientRect, so a scaled ancestor does not change the answer.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called AutoHeight — a box that animates its height when its content changes.
- Props: className (the box's look: background, border, radius, padding), bounce (0.2), duration (0.4), children.
- A motion.div (flow-root) holding one inner div (flow-root) that carries the children. The motion.div has no inline height on the server
and until it has been measured.
- In a layout effect: measure the inner div (offsetHeight) plus what the outer adds (its padding-top and -bottom and border-top-
and -bottom widths, from getComputedStyle), put that in a motion value with jump(), then set a ready state so the box takes
style={{ height: thatMotionValue }}. Observe the inner div with a ResizeObserver and use the entry's contentRect.height plus the same extra.
- When the observer reports a new height (more than 0.5px from the one it is heading for): if the inner width changed since the last
report or prefers-reduced-motion: reduce matches (read it here), jump to it; otherwise animate(height, next, { type: "spring",
bounce, duration }).
- Clip only while moving: overflow-hidden during a move, overflow-visible at rest (a state set on start and in onComplete).










