TL;DR Give exits a shorter duration than entrances, set transform-origin from Radix's --radix-*-content-transform-origin variable, stagger lists with a CSS index capped at about 6 items, stack the button label and spinner in one grid cell so loading never changes width, and write hover as transitions on named properties. Turn the decoration off under prefers-reduced-motion.
Apps built with v0 or Lovable tend to move the same way. Every menu zooms out of its center, every dialog fades in and out at the same speed, every list item appears at once, and the submit button turns into a spinner and changes width while you watch. None of it is broken. It just reads as the default, and people who build with these tools have learned to spot the default.
You don't need a new animation library to fix it. The five details below are small edits to the files your tool already wrote, mostly CSS and a few Tailwind classes. Each one is something a person would notice without being able to name it.
I make qodesign, a catalog of animated React components, so I spend a lot of time on this stuff. The code in this post was written for the post and doesn't depend on anything we sell. If you want the broader checklist (theme, fonts, layout), read how to make UI not look AI-generated first. This one is only about motion.
When something opens, the user asked for it and is watching, so a bit of travel helps them follow it. When something closes, they're already done with it. Generated code usually gives both directions the same duration, and the close feels sluggish.
A good starting point is about 200ms in and 120 to 150ms out. shadcn components built on Radix put data-state="open" or data-state="closed" on the content element, so you can set each direction separately:
The two curves differ on purpose. The entrance slows down at the end so it settles softly, and the exit speeds up so it gets out of the way. Add pop to your DropdownMenuContent or PopoverContent and remove the stock animate-in and animate-out classes so the two don't fight.
Hold to Delete running live. A delete button you press and hold, then the file crumples into the bin.
A dropdown that scales from its own center looks like it came from nowhere. Real objects grow out of where they're attached. Radix already measures this for you and exposes it as a CSS variable on the content: --radix-dropdown-menu-content-transform-origin, --radix-popover-content-transform-origin, and the same pattern for tooltips, select and hover cards.
Newer shadcn versions ship this already, so check before you add it. If your project was generated a while ago, it probably has plain zoom-in-95 with no origin. With a small scale like 0.97 the effect is subtle, and you mostly notice it when it's missing.
Lists that pop in all at once look like a page that loaded. Lists that cascade look like an interface someone designed. The usual mistake goes the other way: a stagger on every item, every render, with the 40th row waiting two seconds to show up.
Give each item its index as a CSS variable and cap the delay:
tsx
// components/stagger-list.tsximport type { CSSProperties, ReactNode } from "react";export function StaggerList({ items }: { items: { id: string; content: ReactNode }[] }) { return ( <ul className="space-y-2"> {items.map((item, i) => ( <li key={item.id} className="stagger-item" style={{ "--i": i } as CSSProperties} > {item.content} </li> ))} </ul> );}
After the sixth item everything comes in together, so the whole list is visible in about half a second. A CSS animation runs when the element is mounted, so rows already on screen don't replay it when the list re-renders. New rows get it, which is usually what you want.
The generated pattern is {pending ? <Spinner /> : "Save changes"}. The label disappears, the button shrinks to the width of the spinner, and anything next to it jumps. On a form with two buttons side by side, it's very visible.
Render both and stack them in one grid cell. The cell keeps the width of the wider child, and you swap them with opacity:
useFormStatus reads the pending state of the parent form, so this works with a server action and no extra state. The active:scale-[0.97] is the other half: the button answers the finger right away, before any network request finishes.
Hover effects written as keyframe animations can't be interrupted. Move the mouse off halfway and the card snaps back or plays to the end anyway. A CSS transition reverses from wherever it is, so hover should always be a transition.
List the properties you animate instead of using transition-all, which also animates color, padding and anything else that changes later. On phones, a hover lift can stay stuck after a tap. Tailwind v4 already wraps hover: in @media (hover: hover), so it only applies on devices that can hover. On Tailwind v3, add that media query yourself or turn on future.hoverOnlyWhenSupported.
If you'd rather have the tool do it, paste something like this:
Go through the dropdowns, popovers and dialogs. Exits must be faster than entrances (about 200ms in with ease-out, 130ms out with ease-in). Set transform-origin from the Radix --radix-*-content-transform-origin variable. Stagger list items with a CSS variable index, capped at 6 items. Loading buttons must keep their width by stacking the label and spinner in one grid cell. Hover effects use transitions on named properties, never transition-all. Add a prefers-reduced-motion block.
The qodesign-free repo has 5 open-source (MIT) components: a toast stack, a pricing switch, an upload dropzone, a day/night toggle and two dashboards. Each installs with one shadcn command and comes with a PROMPT.md for your AI tool. On the site there's 1 free piece next to the paid catalog. The paid one I'd point at for this post is Hold to Delete ($3), a delete button you press and hold before the file crumples into the bin.
They share the same component defaults: menus that zoom from the center, equal open and close timing, lists that appear at once, and loading states that change size. Changing a few of these makes the app feel designed.
For menus, popovers and small dialogs, about 150 to 250ms to open and 100 to 150ms to close. Large panels and page transitions can run a little longer. Anything over 400ms for a common action starts to feel slow.
No. All five details here are plain CSS plus Tailwind classes. Motion is worth adding for layout animations and shared elements, which CSS doesn't handle well.
Render the label and the spinner at the same time in one grid cell with grid-area 1/1, and toggle their opacity. The button keeps the width of the label.