dqnamoexperiments / dynamic-button
Dynamic Button
A button that resizes to fit its label and crossfades the text and icon when the action or state changes.
Demo
Variant
import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion,} from "motion/react";import { forwardRef, type Key, type ReactNode, useCallback, useImperativeHandle, useLayoutEffect, useRef, useState, } from "react"; type DynamicButtonVariant = "primary" | "secondary"; const uiEaseOut = [0.23, 1, 0.32, 1] as const; export type DynamicButtonProps = Omit< HTMLMotionProps<"button">, "animate" | "children" | "initial" | "ref" | "transition"> & { children: string; icon?: ReactNode; stateKey?: Key; variant?: DynamicButtonVariant; width?: "content" | "full";};const buttonClasses: Record<DynamicButtonVariant, string> = { primary: "border-neutral-950 bg-neutral-950 text-white hover:bg-neutral-800", secondary: "border-neutral-200 bg-white text-neutral-950 hover:border-neutral-300 hover:bg-neutral-50",};function getButtonClassName({ className, variant = "primary",}: { className?: string; variant?: DynamicButtonVariant;}) { return [ "inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-lg border px-2 text-sm font-medium transition-colors", buttonClasses[variant], className, ] .filter(Boolean) .join(" ");}export const DynamicButton = forwardRef<HTMLButtonElement, DynamicButtonProps>( function DynamicButton( { children, className, icon, stateKey, type = "button", variant = "primary", width = "content", ...props }, forwardedRef, ) { const shouldReduceMotion = useReducedMotion(); const buttonRef = useRef<HTMLButtonElement>(null); const measureRef = useRef<HTMLSpanElement>(null); const measurementSignatureRef = useRef(""); const [measuredWidth, setMeasuredWidth] = useState<number | null>(null); const iconKey = stateKey ?? children; const measurementSignature = [ children, className, icon ? "icon" : "no-icon", stateKey, variant, ].join("\0"); const shouldMeasureWidth = width === "content"; const widthTransition = shouldReduceMotion ? { duration: 0 } : { bounce: 0, duration: 0.26, type: "spring" as const }; const contentTransition = { duration: shouldReduceMotion ? 0.16 : 0.18, ease: uiEaseOut, }; const contentVisible = { opacity: 1, transform: "translateY(0px)" }; const contentInitial = shouldReduceMotion ? { opacity: 0, transform: "translateY(0px)" } : { opacity: 0, transform: "translateY(8px)" }; const contentExit = shouldReduceMotion ? { opacity: 0, transform: "translateY(0px)" } : { opacity: 0, transform: "translateY(-8px)" }; useImperativeHandle( forwardedRef, () => buttonRef.current as HTMLButtonElement, ); const syncWidth = useCallback(() => { const button = buttonRef.current; const measure = measureRef.current; if (!button || !measure || !shouldMeasureWidth) { return; } const styles = window.getComputedStyle(button); const horizontalPadding = Number.parseFloat(styles.paddingLeft) + Number.parseFloat(styles.paddingRight) + Number.parseFloat(styles.borderLeftWidth) + Number.parseFloat(styles.borderRightWidth); const nextWidth = Math.ceil(measure.scrollWidth + horizontalPadding); setMeasuredWidth((currentWidth) => currentWidth === nextWidth ? currentWidth : nextWidth, ); }, [shouldMeasureWidth]); useLayoutEffect(() => { const measure = measureRef.current; if (!measure || !shouldMeasureWidth) { setMeasuredWidth(null); return; } syncWidth(); const observer = new ResizeObserver(syncWidth); observer.observe(measure); window.addEventListener("resize", syncWidth); return () => { observer.disconnect(); window.removeEventListener("resize", syncWidth); }; }, [shouldMeasureWidth, syncWidth]); useLayoutEffect(() => { if (measurementSignatureRef.current === measurementSignature) { return; } measurementSignatureRef.current = measurementSignature; syncWidth(); }, [measurementSignature, syncWidth]); return ( <motion.button {...props} animate={ width === "content" ? { width: measuredWidth ?? "auto" } : undefined } className={getButtonClassName({ className: [ "relative overflow-hidden whitespace-nowrap", "transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]", width === "full" ? "w-full" : "", className, ] .filter(Boolean) .join(" "), variant, })} initial={false} ref={buttonRef} transition={width === "content" ? { width: widthTransition } : undefined} type={type} > <span className="relative inline-flex items-center gap-1.5"> {icon ? ( <span className="relative inline-grid size-[15px] shrink-0 overflow-hidden"> <AnimatePresence initial={false} mode="popLayout"> <motion.span animate={contentVisible} className="col-start-1 row-start-1 flex size-[15px] items-center justify-center" exit={contentExit} initial={contentInitial} key={iconKey} transition={contentTransition} > {icon} </motion.span> </AnimatePresence> </span> ) : null} <span className="relative inline-grid overflow-hidden"> <AnimatePresence initial={false} mode="popLayout"> <motion.span animate={contentVisible} className="col-start-1 row-start-1 block" exit={contentExit} initial={contentInitial} key={children} transition={contentTransition} > {children} </motion.span> </AnimatePresence> </span> </span> <span aria-hidden="true" className="pointer-events-none absolute inline-flex items-center gap-1.5 opacity-0" ref={measureRef} > {icon ? ( <span className="flex size-[15px] shrink-0 items-center justify-center"> {icon} </span> ) : null} <span>{children}</span> </span> </motion.button> ); },);Want to work with me?
I run a lil design engineering studio in London where we do fractional design engineering for startups.
THEINTERFACECOMPANYOF LONDON