5 個動畫細節,讓 v0、Cursor 或 Lovable 製作的應用程式更有手工打造感
5 animated touches that make a v0, Cursor or Lovable app feel hand-built
文章示範如何為 v0、Cursor 或 Lovable 生成的頁面加入 5 種動畫細節,令導覽列、捲動卡片、數字、主題切換及檔案上載回應用戶操作。示例提供可複用程式碼,並説明如何設定 Reduce Motion。
Free kit: animated pieces + 8 AI prompts → https://qodesign.dev/free?utm_source=devto&utm_medium=tutorial&utm_campaign=tutorial3&utm_content=top
Pages generated by v0, Cursor or Lovable usually look fine in a screenshot. Click around, though, and nothing moves. The nav jumps from one link to the next, the cards are already sitting there when you scroll down, the numbers are just numbers, the theme switch is a checkbox with an icon, and an upload shows a spinner until it's done or broken.
Motion is the cheapest way I know to make a page feel like someone spent time on it. It's also easy to overdo, so this post sticks to five small things, each tied to something the visitor did: hovered, scrolled, clicked or dropped a file.
Earlier I wrote about the five design tells of an AI-built landing page (gradients, fonts, centring, fake product shots, dead buttons). This one is the motion half.
Setup (1 minute)
Everything below was run in a fresh create-next-app project (Next.js 16.4, React 19.3, Tailwind v4) after npx shadcn@latest init, which is the setup a v0 export already has. tsc and next build pass, and every page prerenders as static.
You need Framer Motion. Touches 4 and 5 install it for you through the shadcn CLI; if you only want 1 to 3:
npm i framer-motion
One more thing before any animation. Wrap the app in MotionConfig so every Framer Motion animation follows the visitor's Reduce Motion setting. Transforms and layout animations get skipped, opacity still fades:
// app/providers.tsx
'use client';
import { MotionConfig } from 'framer-motion';
export function Providers({ children }: { children: React.ReactNode }) {
return <MotionConfig reducedMotion="user">{children}</MotionConfig>;
}
// app/layout.tsx
<body>
<Providers>{children}</Providers>
</body>
1. A nav highlight that slides (2 minutes)
The pill behind the active link follows your cursor and glides back to the current page when you leave. Framer Motion does the hard part: two elements with the same layoutId animate between each other's positions, so only one pill is ever rendered.
// components/site-nav.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { motion } from 'framer-motion';
import { useState } from 'react';
const links = [
{ href: '/', label: 'Product' },
{ href: '/pricing', label: 'Pricing' },
{ href: '/changelog', label: 'Changelog' },
];
export function SiteNav() {
const pathname = usePathname();
const [hovered, setHovered] = useState<string | null>(null);
const highlighted = hovered ?? pathname;
return (
<nav onMouseLeave={() => setHovered(null)} className="flex gap-1 rounded-full border p-1">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
onMouseEnter={() => setHovered(link.href)}
aria-current={pathname === link.href ? 'page' : undefined}
className="relative rounded-full px-4 py-1.5 text-sm"
>
{highlighted === link.href && (
<motion.span
layoutId="nav-pill"
className="absolute inset-0 rounded-full bg-foreground/10"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
<span className="relative">{link.label}</span>
</Link>
))}
</nav>
);
}
Swap in your own routes. aria-current tells screen readers which page you're on, and the pill is only decoration on top of it.
2. Cards that come in when you scroll to them (2 minutes)
Generated feature grids are fully rendered before anyone sees them. Staggering the cards by 80 ms as the grid enters the viewport makes the section feel like it arrived for you.
// components/feature-grid.tsx
'use client';
import { motion, type Variants } from 'framer-motion';
const list: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.08 } },
};
const card: Variants = {
hidden: { opacity: 0, y: 24 },
show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 260, damping: 24 } },
};
type Feature = { title: string; body: string };
export function FeatureGrid({ features }: { features: Feature[] }) {
return (
<motion.ul
variants={list}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.3 }}
className="grid gap-4 sm:grid-cols-3"
>
{features.map((f) => (
<motion.li
key={f.title}
variants={card}
whileHover={{ y: -4 }}
className="rounded-xl border p-6"
>
<h3 className="font-medium">{f.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{f.body}</p>
</motion.li>
))}
</motion.ul>
);
}
viewport={{ once: true }} plays it once. Replaying every time someone scrolls up and down gets old by the second time. amount: 0.3 waits until 30% of the grid is on screen, so the first card isn't already done by the time it's visible. The cards are in the server HTML, so search engines and people without JavaScript still get the text.
3. Stats that count up (2 minutes)
"12,400+ checks per minute" is easy to skim past. A number that counts up as its row scrolls in gets looked at.
// components/count-up.tsx
'use client';
import { animate, useInView, useReducedMotion } from 'framer-motion';
import { useEffect, useRef } from 'react';
const format = (n: number) => new Intl.NumberFormat('en-US').format(Math.round(n));
export function CountUp({ to, suffix = '' }: { to: number; suffix?: string }) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
// The server renders the real number. After hydration, reset it to 0
// so it can count up when it scrolls into view.
useEffect(() => {
if (!reduce && ref.current) ref.current.textContent = format(0) + suffix;
}, [reduce, suffix]);
useEffect(() => {
const el = ref.current;
if (!inView || reduce || !el) return;
const controls = animate(0, to, {
duration: 1.2,
ease: [0.16, 1, 0.3, 1],
onUpdate: (v) => { el.textContent = format(v) + suffix; },
});
return () => controls.stop();
}, [inView, reduce, to, suffix]);
return (
<span ref={ref} className="tabular-nums">
{format(to)}{suffix}
</span>
);
}
<p className="text-4xl font-semibold"><CountUp to={12400} suffix="+" /></p>
A few details:
- The server renders the real number. The reset to 0 happens after hydration, while the row is usually still below the fold, so nobody sees the swap.
-
tabular-numsgives every digit the same width. Without it the number wobbles sideways as it counts. - The text is written straight to the DOM in
onUpdate, so React doesn't re-render 60 times a second.
With Reduce Motion on, the number never resets and simply sits there.
4. A theme switch people click twice (2 minutes)
Generated apps usually switch themes with a sun/moon icon button. The free Day/Night Toggle from my qodesign-free repo rolls the sun across into a moon, drops the clouds and brings in the stars.
It installs with the shadcn CLI, so the source lands in your project and you can edit it:
npx shadcn@latest add https://teodoravadani.github.io/qodesign-free/r/day-night-toggle.json
If you'd rather keep it as a package than as source, it's also on npm. Framer Motion is a peer dependency, and you already have it from step 1:
npm i qodesign-free
Then import it with import { DayNightToggle } from 'qodesign-free/day-night-toggle'; in place of the @/components/... line below. The npm build brings its own CSS, so it works without Tailwind too.
Wire it to the dark class that shadcn's theme already uses (@custom-variant dark in globals.css):
// components/theme-toggle.tsx
'use client';
import { useEffect, useState } from 'react';
import DayNightToggle from '@/components/day-night-toggle/DayNightToggle';
export function ThemeToggle() {
const [night, setNight] = useState(false);
// read what the inline script in layout.tsx already applied
useEffect(() => {
setNight(document.documentElement.classList.contains('dark'));
}, []);
return (
<DayNightToggle
width={72}
night={night}
onChange={(next) => {
setNight(next);
document.documentElement.classList.toggle('dark', next);
localStorage.setItem('theme', next ? 'dark' : 'light');
}}
/>
);
}
To avoid a white flash on reload for people who picked dark, set the class before the page paints. Put this in app/layout.tsx:
const themeScript = `try{if(localStorage.theme==='dark')document.documentElement.classList.add('dark')}catch(e){}`;
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
...
suppressHydrationWarning is needed because the script changes <html> before React hydrates. The switch is a real role="switch" button with aria-checked, so keyboards and screen readers handle it. If your project already uses next-themes, call its setTheme in onChange instead.
5. An upload that shows real progress (3 minutes)
A spinner tells people to wait. A progress ring per file tells them how long. The free Upload Dropzone lights up on drag, gives each file its own ring, turns a finished ring into a check and offers Retry when a file fails.
npx shadcn@latest add https://teodoravadani.github.io/qodesign-free/r/upload-dropzone.json
(npm route: import { UploadDropzone, type Uploader } from 'qodesign-free/upload-dropzone';)
Give it an uploader. It uses XHR because fetch still can't report upload progress:
// components/release-upload.tsx
'use client';
import { UploadDropzone, type Uploader } from '@/components/upload-dropzone/UploadDropzone';
// XHR, because fetch() still can't report upload progress
const upload: Uploader = (file, onProgress, signal) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100);
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`Server said ${xhr.status}`)));
xhr.onerror = () => reject(new Error('Network error'));
signal.addEventListener('abort', () => xhr.abort());
const body = new FormData();
body.append('file', file);
xhr.send(body);
});
export function ReleaseUpload() {
return (
<UploadDropzone
upload={upload}
maxSize={20 * 1024 * 1024}
hint="up to 20 MB each"
accept="image/*,.pdf,.zip"
/>
);
}
And a route for it to post to:
// app/api/upload/route.ts
export async function POST(req: Request) {
const form = await req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return Response.json({ error: 'No file' }, { status: 400 });
}
// Store it wherever you keep files (S3, Vercel Blob, Supabase Storage...)
return Response.json({ name: file.name, size: file.size });
}
If the promise rejects, the row shows your error message and a Retry button. Without an upload prop the uploads are simulated, which is handy while you build the screen. Its colours are CSS variables at the top of upload-dropzone.css, so restyle it there.
I tested it with a 3 MB file: the route answered 200 and the row ended on "uploaded" with a check.
Where to stop
Five animations is plenty for one page. What I'd leave out:
- Animating text that people are trying to read.
- Anything that plays on page load and delays the content. Touches 2 and 3 wait for the scroll.
- Spring settings copied between components. A nav pill wants to be quick (stiffness 500) and cards can be softer (260).
And check the page once with Reduce Motion on (System Settings → Accessibility → Display on a Mac). Everything above still works, just without the movement.
If you'd rather hand it to Cursor
I haven't compared how each tool does with this, so read the diff before you accept it:
In this Next.js App Router + Tailwind project, using framer-motion:
1. Wrap the app in <MotionConfig reducedMotion="user"> in a client providers file.
2. In the header nav, add a highlight pill behind the hovered/active link using a shared layoutId and a spring (stiffness 500, damping 35). Keep aria-current on the active link.
3. Make the feature grid stagger in on scroll: whileInView, viewport once with amount 0.3, staggerChildren 0.08, each card from opacity 0 / y 24 with a spring.
4. Add a CountUp component for the stats row: server-render the final number, reset to 0 after mount, animate to the value with animate() when 60% in view, tabular-nums, skip all of it under reduced motion.
Don't change any copy. Don't add other dependencies.
For touches 4 and 5, every folder in the repo has a PROMPT.md with prompts for Cursor, v0 and Lovable.
About me, and the links. I'm Teodor, and I make animated UI components at qodesign, so touches 4 and 5 are my own components (the Day/Night Toggle and the Upload Dropzone). They're free and MIT, along with two full dashboards, in github.com/teodoravadani/qodesign-free (live demos). Touches 1 to 3 are plain Framer Motion and yours to copy. If you want more to start from, there's a free kit on my site: the Day/Night Toggle, Toast Stack and Pricing Switch plus eight copy-paste prompts for Cursor, v0, Lovable or Claude Code. It asks for an email and sends a few follow-up emails about the paid pieces, so you know what you're signing up for: the free kit. The paid components each come with a React + Tailwind version and a PROMPT.md for AI coding tools, at qodesign.dev/components.
Which of the five would you add first? And if you've got a motion detail that always makes a page feel finished, I'd like to hear it.
I wrote this post with help from an AI assistant. The code above is copied from a test project: a fresh Next.js 16.4 app with shadcn initialised, where tsc and next build passed and each touch was checked in a browser (including with Reduce Motion on) before this was saved.
來源:DEV Community · dev.to

