The shape of the problem
Most popovers do one thing. Search a list. Pick a value. Confirm an action.
A tag picker has to do four things, in sequence, on the same surface. The user starts in a list of existing tags. They might type something that exists, or something that doesn't. If it doesn't, they're now creating it — and the picker has to ask them what colour the new tag should be. After they pick, it has to confirm. After it confirms, it has to drop them back into the list with the new tag included.
You can solve this with four separate popovers. Or you can make one popover that morphs.
The four views
The state machine is small enough to write out:
list— the default. Existing tags appear under a search input. Type a query, see filtered matches; type something new, see a "Create" affordance.pick-color— entered when the user commits to creating a new tag. The list of suggestions is replaced by a list of colour swatches. The search input is locked (read-only) — the user has already named the thing, now they're picking how it looks.creating— entered when a colour is chosen. The header swaps from "Create" to "Creating," a small spinner appears. In the production version this is when the API call fires.created— the success state. The spinner becomes a check, the header reads "Created." Holds for ~700ms, then dissolves the popover entirely with the new tag now applied.
The first time the picker enters a non-list state, a thin header strip slides in above the input. That strip is the surface that does most of the animation work — its inner content cross-fades as the state changes, but the strip itself stays the same shape.
What the animations are doing
The header strip animates height from 0 to auto with a soft spring ({ type: "spring", duration: 0.4, bounce: 0.15 }). Soft, not bouncy. It needs to feel decisive — the picker has changed mode — without overshoot.
Inside the strip, three motion nodes (one per non-list state) are wrapped in <AnimatePresence mode="wait">. As state advances, the outgoing node fades to opacity 0, completes, then the incoming node fades in. The 180ms duration is short enough that the user reads the change as instant, but long enough that the eye registers the transition rather than a hard cut.
The created state's badge animates from scale(0.95) instead of from scale(0). Real things don't appear from nothing. The check icon is a small flourish — a success moment that takes a beat — and then the popover dismisses.
Removing a tag uses the same <AnimatePresence> boundary as the list mounting. Each badge has layout enabled, so the remaining tags reflow smoothly when one is removed rather than snapping.
What it's stripped of
The version in the production app talks to a companyKeys cache, fires through React Query mutations, hits a Stax API to create the tag on the user's company, validates against a label registry, and surfaces a Sonner toast on failure. None of that is here. What's left is the part that matters for the feel — the same component, same state machine, same timings, with setTimeout standing in for the network and a local array for the persistence.
That's a useful framing for a portfolio craft piece. The animation choices and the state machine were the part I cared about. Everything else was scaffolding for those choices.
The point
Multi-view popovers are unusual in production UIs because most teams don't take the time to design the morph between states. They settle for two clicks where one would have done. The work here was small — five hundred lines, two springs, three keyed motion nodes — and it makes the difference between a feature that feels like infrastructure and a feature that feels like product.
It's the kind of thing another developer would have shipped in a day. Worth taking the afternoon.