A search field that widens into a full input when you click it. The whole thing runs off a single spring, with no CSS transitions, no timers and no measuring the DOM by hand. I saw a similar one in a few SaaS apps, where a search button turns into a full input. Nothing new here. I only wanted to add the animation. The full code is at the end.
What went wrong first
The detail that took me longest was the swap between the closed and open states. My first version was broken. When the field started to open, the Paste button did not leave the layout. It kept its space until its exit animation finished, so everything next to it was pushed around for a few frames before it settled. The fix was mode="popLayout"1 on the AnimatePresence components.
Here is what popLayout does. A normal exit keeps the element in the layout until the animation ends. In popLayout mode, the exiting element is set to position: absolute as soon as it starts to leave. Its siblings no longer count it, so they move into their new place right away, while the old element fades out. It needs two things from you. The parent must have a position other than static, because the exiting element is positioned against it. And the siblings should carry the layout prop, so they animate to their new place instead of jumping.
I use it on both swaps. The Search label and the input exchange places the same way, with a blur(4px) on exit and blur(0px) on enter. It reads as the old label pulling out of focus, rather than just dimming.
How it works
One MotionConfig sets the transition for everything inside it: { duration: 0.5, type: 'spring', bounce: 0.2 }. The container animates its width between the collapsed and expanded states, and it carries the layout prop. 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.
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 instead of dragging behind it.
If you're about to build one
Motion ignores prefers-reduced-motion unless you ask it not to.2 MotionConfig takes a reducedMotion prop that defaults to 'never'. Passing reducedMotion={'user'} is what makes it honour the OS setting. With that in place, the transform-driven motion switches off for anyone who has asked for less of it, and the field just changes between its two states. I like how it feels, but I wouldn't ship it in a production app. It is a nice interaction to show, and I would not use it for more than that.
The code
FluidInput.tsx
styles.module.css
References
- 1.AnimatePresence · Motion
- 2.Accessibility · Motion