# v0 or Lovable App Looks AI-Made? 5 Motion Fixes

> 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.

- URL: https://qodesign.dev/blog/v0-lovable-app-motion-details
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: AI-built UI
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/blog/make-v0-app-not-look-ai-generated) first. This one is only about motion.

## 1. Make exits faster than entrances

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:

```css
/* app/globals.css */
@keyframes pop-in {
  from { opacity: 0; transform: scale(0.97) translateY(4px); }
}
@keyframes pop-out {
  to { opacity: 0; transform: scale(0.98); }
}

.pop[data-state="open"] {
  animation: pop-in 200ms cubic-bezier(0.2, 0.9, 0.3, 1);
}
.pop[data-state="closed"] {
  animation: pop-out 130ms cubic-bezier(0.4, 0, 1, 1);
}
```

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.

## 2. Grow menus out of the thing you clicked

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.

```tsx
<DropdownMenuContent
  align="end"
  className="pop origin-[var(--radix-dropdown-menu-content-transform-origin)]"
>
```

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.

## 3. Stagger lists once, and cap the delay

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.tsx
import 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>
  );
}
```

```css
@keyframes rise {
  from { opacity: 0; transform: translateY(6px); }
}
.stagger-item {
  animation: rise 260ms cubic-bezier(0.2, 0.9, 0.3, 1) both;
  animation-delay: calc(min(var(--i), 6) * 40ms);
}
```

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.

## 4. Keep the button the same width while it loads

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:

```tsx
// components/submit-button.tsx
"use client";

import type { ReactNode } from "react";
import { useFormStatus } from "react-dom";

export function SubmitButton({ children }: { children: ReactNode }) {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      aria-busy={pending}
      className="grid rounded-lg bg-neutral-900 px-4 py-2 text-white transition-transform active:scale-[0.97] disabled:opacity-80"
    >
      <span className={`[grid-area:1/1] transition-opacity duration-150 ${pending ? "opacity-0" : "opacity-100"}`}>
        {children}
      </span>
      <span
        aria-hidden
        className={`[grid-area:1/1] grid place-items-center transition-opacity duration-150 ${pending ? "opacity-100" : "opacity-0"}`}
      >
        <span className="size-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
      </span>
    </button>
  );
}
```

`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.

## 5. Use transitions for hover, and skip hover on touch

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.

```tsx
<a
  href="/pricing"
  className="block rounded-xl border border-white/10 p-5 transition-[transform,box-shadow] duration-200 ease-out hover:-translate-y-0.5 hover:shadow-lg"
>
  Pricing
</a>
```

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`.

## Respect reduced motion

All five details are decoration, so all five can go when someone asks for less motion:

```css
@media (prefers-reduced-motion: reduce) {
  .pop, .stagger-item { animation: none; }
  * { transition-duration: 0.01ms !important; }
}
```

The loading button still works with no duration. The label and spinner just swap instantly.

## A prompt for v0, Lovable or Cursor

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.

To keep these values from drifting over the next 20 prompts, put them in a rule file. [Cursor rules for consistent UI animations](/blog/cursor-rules-consistent-ui-animations) shows how.

## If you want pieces that already move well

The [qodesign-free repo](https://github.com/teodoravadani/qodesign-free?utm_source=blog&utm_medium=article&utm_campaign=v0-lovable-motion-details) 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](/free?utm_source=blog&utm_medium=article&utm_campaign=v0-lovable-motion-details) next to the paid catalog. The paid one I'd point at for this post is [Hold to Delete](/components/hold-to-delete?utm_source=blog&utm_medium=article&utm_campaign=v0-lovable-motion-details) ($3), a delete button you press and hold before the file crumples into the bin.

## FAQ

### Why do v0 and Lovable apps look AI-generated?

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.

### What duration should UI animations use?

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.

### Do I need Framer Motion for these?

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.

### How do I stop a button from changing width while loading?

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.
