BackExpandable
components

Expandable

Smooth animations for expandable content.

Containers on the web snap to their new size instantly when content changes. By measuring the bounds of a container and animating to those values, we can make these transitions feel smooth and intentional.

This technique uses grid template rows to create smooth height animations and the grid transitions between states, allowing the content to naturally expand and collapse without JavaScript measurement.

hooks/use-measure.ts
import React from "react"

interface Bounds {
	width: number
	height: number
}

type UseMeasureReturn<T extends HTMLElement> = [(node: T | null) => void, Bounds]

export function useMeasure<T extends HTMLElement = HTMLElement>(): UseMeasureReturn<T> {
	const [element, setElement] = React.useState<T | null>(null)
	const [bounds, setBounds] = React.useState<Bounds>({ width: 0, height: 0 })

	const ref = React.useCallback((node: T | null) => {
		setElement(node)
	}, [])

	React.useEffect(() => {
		if (!element) return

		const observer = new ResizeObserver(([entry]) => {
			if (!entry) return

			setBounds({
				width: entry.contentRect.width,
				height: entry.contentRect.height,
			})
		})

		observer.observe(element)
		return () => observer.disconnect()
	}, [element])

	return [ref, bounds]
}
components/ui/expandable.tsx
"use client"

import React from "react"

interface Props extends React.ComponentProps<"div"> {
	expandedContent: React.ReactNode
	expandTrigger?: [React.ReactNode, React.ReactNode]
	onExpandedChange?: (expanded: boolean) => void
}

export function Expandable({ children, expandedContent, onExpandedChange, expandTrigger, className, ...props }: Props) {
	const [expanded, setExpanded] = React.useState(false)

	const handleToggle = () => {
		const next = !expanded
		setExpanded(next)
		onExpandedChange?.(next)
	}

	return (
		<div className={`bg-background flex w-full max-w-md flex-col rounded-xl border p-5 ${className ?? ""}`} {...props}>
			{children}
			<div
				className="grid overflow-hidden transition-[grid-template-rows,opacity,filter] duration-300 ease-out"
				style={{
					gridTemplateRows: expanded ? "1fr" : "0fr",
					opacity: expanded ? 1 : 0,
					filter: expanded ? "blur(0px)" : "blur(6px)",
				}}
			>
				<div className="min-h-0">{expandedContent}</div>
			</div>

			{expandTrigger ? (
				React.cloneElement(expandTrigger[expanded ? 1 : 0] as React.ReactElement<React.ComponentProps<"button">>, {
					onClick: handleToggle,
				})
			) : (
				<button
					type="button"
					onClick={handleToggle}
					className="bg-primary text-primary-foreground mt-3 flex cursor-pointer items-center justify-center rounded-lg border px-4 py-1.5 text-sm font-medium whitespace-nowrap active:scale-95"
				>
					{expanded ? "See less" : "See more"}
				</button>
			)}
		</div>
	)
}
components/examples/expandable.tsx
"use client"

import { Expandable } from "@/components/ui/expandable"

export function ExpandableExample() {
	return (
		<Expandable
			expandedContent={
				<p className="mt-2 text-sm leading-relaxed text-pretty">
					This technique uses grid template rows to create smooth height animations and the grid transitions between
					states, allowing the content to naturally expand and collapse without JavaScript measurement.
				</p>
			}
		>
			<p className="text-sm leading-relaxed text-pretty">
				Containers on the web snap to their new size instantly when content changes. By measuring the bounds of a
				container and animating to those values, we can make these transitions feel smooth and intentional.
			</p>
		</Expandable>
	)
}