BackAnimatedButton
components

AnimatedButton

Smooth width transitions when button content changes.

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/animated-button.tsx
"use client"

import { useMeasure } from "@/hooks/use-measure"
import React from "react"

const EASING = "cubic-bezier(0.19,1,0.22,1)"

const transitionIn = [
	`opacity 500ms ease-in-out 50ms`,
	`filter 400ms ${EASING} 50ms`,
	`scale 400ms ${EASING} 50ms`,
].join(", ")

export function AnimatedButton({ children, className, ...props }: React.ComponentProps<"button">) {
	const [ref, bounds] = useMeasure()
	const [width, setWidth] = React.useState("auto")
	const [displayedChildren, setDisplayedChildren] = React.useState(children)
	const [visible, setVisible] = React.useState(true)
	const isFirstRender = React.useRef(true)

	React.useEffect(() => {
		if (bounds.width > 0) setWidth(`${bounds.width}px`)
	}, [bounds.width])

	React.useEffect(() => {
		if (isFirstRender.current) {
			isFirstRender.current = false
			return
		}

		setDisplayedChildren(children)
		setVisible(false)

		requestAnimationFrame(() => {
			requestAnimationFrame(() => {
				setVisible(true)
			})
		})
	}, [children])

	return (
		<button
			style={{ width }}
			className={`bg-primary flex cursor-pointer items-center justify-center rounded-lg border py-1.5 transition-all duration-300 ease-[cubic-bezier(0.19,1,0.22,1)] active:scale-95 ${className ?? ""}`}
			{...props}
		>
			<div ref={ref}>
				<span
					style={{
						display: "inline-block",
						opacity: visible ? 1 : 0,
						filter: visible ? "blur(0px)" : "blur(8px)",
						scale: visible ? 1 : 0.95,
						transition: visible ? transitionIn : "none",
					}}
					className="text-primary-foreground px-4 text-sm font-medium whitespace-nowrap"
				>
					{displayedChildren}
				</span>
			</div>
		</button>
	)
}
components/examples/animated-button.tsx
"use client"

import { AnimatedButton } from "@/components/ui/animated-button"
import React from "react"

const LABELS = ["Click me", "I'm clicked!", "Clicked again!"]

export function AnimatedButtonExample() {
	const [index, setIndex] = React.useState(0)

	return <AnimatedButton onClick={() => setIndex((prev) => (prev + 1) % LABELS.length)}>{LABELS[index]}</AnimatedButton>
}