This is a search field that widens into a full input when you click into it, and the whole thing runs off a single spring - no CSS transitions, no timers, no measuring the DOM by hand.
One MotionConfig sets the transition for everything inside it: { duration: 0.5, type: 'spring', bounce: 0.2 }. The container animates its width between its collapsed and expanded states and carries the layout prop, so when the placeholder swaps out for the real input, Motion measures where things were and where they ended up and moves them, instead of letting the browser jump them. The bounce: 0.2 is what keeps it feeling like an object rather than a value being tweened - at 0 it slides to a dead stop, and much past 0.3 it starts wobbling like a toy.
One detail I almost missed: mode="popLayout" on the AnimatePresence that swaps Search for the input. Without it the outgoing text keeps its space in the layout until its exit finishes, so the incoming input gets shoved sideways for a few frames before it settles. popLayout pulls the exiting element out of flow right away and lets its siblings slide into place while it fades. The fade is a blur(4px) on exit and blur(0px) on enter, which reads as the old label pulling out of focus rather than just dimming.
Animating width is normally the thing you’re told to avoid, since it touches layout on every frame. Here that’s the whole point - the icon, the placeholder and the paste button all have to reflow as the container grows, and the reflow is the effect. The paste button runs on its own faster spring at 0.25s so it snaps between its two widths slightly ahead of the container rather than dragging behind it.
FluidInput.tsx
import { MagnifyingGlass, X } from '@phosphor-icons/react'import { AnimatePresence, motion, MotionConfig } from 'motion/react'import { useEffect, useRef, useState } from 'react'import { useOnClickOutside } from 'usehooks-ts'import { cn } from '@/src/utils'import styles from './styles.module.css'export function FluidInput() {const [input, setInput] = useState('')const [isActive, setIsActive] = useState(false)const ref = useRef<HTMLDivElement>(null)useOnClickOutside(ref, () => {setInput('')setIsActive(false)})useEffect(() => {function onKeyDown(event: KeyboardEvent) {if (event.key === 'Escape') {setInput('')setIsActive(false)}}window.addEventListener('keydown', onKeyDown)return () => window.removeEventListener('keydown', onKeyDown)}, [])return (<MotionConfig reducedMotion={'user'} transition={{ duration: 0.5, type: 'spring', bounce: 0.2 }}><motion.divref={ref}layouttabIndex={0}style={{ borderRadius: 16 }}onClick={() => setIsActive(true)}onFocus={() => setIsActive(true)}animate={{ width: isActive ? 300 : 116 }}className={cn(styles.container, { [styles.idle]: !isActive })}><div className={styles.iconWrap}><MagnifyingGlass size={18} className={styles.icon} /></div><AnimatePresence initial={false} mode={'popLayout'}>{isActive ? (<motion.inputtype={'text'}spellCheck={false}autoComplete={'off'}animate={{ filter: 'blur(0px)' }}exit={{ filter: 'blur(4px)' }}style={{ borderRadius: 16 }}placeholder={'Username or tag'}value={input}autoFocusonChange={e => setInput(e.target.value)}className={styles.input}/>) : (<motion.panimate={{ filter: 'blur(0px)' }}exit={{ filter: 'blur(4px)' }}className={styles.label}>{'Search'}</motion.p>)}</AnimatePresence><AnimatePresence initial={false} mode={'popLayout'}>{isActive && (<motion.divinitial={{ opacity: 0, scale: 0.25, filter: 'blur(4px)' }}animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}exit={{ opacity: 0, scale: 0.25, filter: 'blur(4px)' }}style={{ borderRadius: 9999 }}className={styles.actions}><motion.buttontype={'button'}animate={{ width: input.length > 0 ? 20 : 56 }}transition={{ duration: 0.25, type: 'spring', bounce: 0.2 }}aria-label={input.length > 0 ? 'Clear input' : 'Paste from clipboard'}onClick={() => {if (input.length > 0) {setInput('')}}}className={cn(styles.action, {[styles.actionPaste]: input.length === 0,[styles.actionClear]: input.length > 0,})}>{input.length > 0 ? <X weight={'bold'} /> : 'Paste'}</motion.button></motion.div>)}</AnimatePresence></motion.div></MotionConfig>)}
styles.module.css
.container {position: relative;display: flex;align-items: center;width: fit-content;max-width: 384px;height: 40px;overflow: hidden;cursor: pointer;outline: none;background: var(--color-neutral-200, oklch(0.922 0 0));}.idle:hover {background: var(--color-neutral-300, oklch(0.87 0 0));}.iconWrap {position: absolute;left: 16px;display: flex;align-items: center;cursor: default;pointer-events: none;}.icon {color: var(--color-neutral-800, oklch(0.269 0 0));}.input {width: 100%;height: 100%;padding-inline: 48px;color: var(--color-neutral-950, oklch(0.145 0 0));font: inherit;background: transparent;border: none;outline: none;}.input::placeholder {color: var(--color-neutral-600, oklch(0.439 0 0));}.label {margin: 0 0 0 44px;padding-right: 16px;color: var(--color-neutral-600, oklch(0.439 0 0));}.actions {position: absolute;right: 16px;display: flex;align-items: center;justify-content: flex-end;overflow: hidden;}.action {display: flex;align-items: center;justify-content: center;width: 20px;height: 20px;cursor: pointer;border: none;}.actionPaste {padding-inline: 8px;font-size: 12px;line-height: 16px;font-weight: 600;text-transform: uppercase;color: var(--color-neutral-50, oklch(0.985 0 0));background: var(--color-neutral-800, oklch(0.269 0 0));}.actionClear {padding: 4px;color: var(--color-neutral-50, oklch(0.985 0 0));background: var(--color-neutral-600, oklch(0.439 0 0));}@media (prefers-color-scheme: dark) {.container {background: var(--color-neutral-800, oklch(0.269 0 0));}.idle:hover {background: oklch(0.371 0 0 / 0.5);}.icon {color: oklch(1 0 0);}.input {color: oklch(1 0 0);}.input::placeholder {color: oklch(1 0 0 / 0.6);}.label {color: oklch(1 0 0 / 0.6);}.actionPaste {color: var(--color-neutral-800, oklch(0.269 0 0));background: var(--color-neutral-200, oklch(0.922 0 0));}}
One thing worth knowing if you lift this: Motion ignores prefers-reduced-motion unless you ask it not to. MotionConfig takes a reducedMotion prop that defaults to 'never', so passing reducedMotion={'user'} is what makes it honour the OS setting. With that in place, the transform-driven motion switches off for anyone who’s asked for less of it, and the field just changes between its two states.