源代码
"use client";
import { useState } from "react";
import Link from "next/link";
import { Search, Package, Copy, CheckCircle } from "lucide-react";
import { motion, stagger } from "motion/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
getAllComponents,
getCategories,
getComponentsByCategory,
searchComponents,
type ComponentInfo,
} from "@/lib/registry";
import { useClipboard } from "use-clipboard-copy";
import { toast } from "sonner";
export default function ComponentsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("all");
const clipboard = useClipboard();
const allComponents = getAllComponents();
const categories = getCategories();
// 根据搜索和分类过滤组件
const filteredComponents = searchQuery
? searchComponents(searchQuery)
: selectedCategory === "all"
? allComponents
: getComponentsByCategory(selectedCategory);
const handleCopyCommand = (componentId: string) => {
const command = `npx shadcn@latest add @qiuye-ui/${componentId}`;
clipboard.copy(command);
toast.success("复制成功!", {
description: `已复制命令: ${command}`,
});
};
const cardWrapperVariants = {
show: {
opacity: 1,
y: 0,
type: "spring",
stiffness: 120,
damping: 40,
transition: {
delay: 0.75,
duration: 0.25,
when: "beforeChildren",
delayChildren: stagger(0.25),
// staggerChildren: 1, // 效果不太一样, 后续父元素不动, 子元素再次出现时只会同时出现
},
},
};
return (
<div className="container mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 120,
damping: 40,
duration: 0.25,
}}
>
<h1 className="text-4xl font-bold tracking-tight mb-4">QiuYe UI</h1>
<p className="text-xl text-muted-foreground mb-6">
精心设计的自定义UI组件,让您的应用更加出色
</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Package className="h-4 w-4" />
<span>{allComponents.length} 个组件</span>
</div>
<div className="flex items-center gap-2">
<span>•</span>
<span>{categories.length} 个分类</span>
</div>
<div className="flex items-center gap-2">
<span>•</span>
<span>一键复制CLI命令</span>
</div>
</div>
</motion.div>
</div>
{/* Search and Filter */}
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 120,
damping: 40,
duration: 0.5,
delay: 0.45,
}}
>
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索组件..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-1">
<TabsTrigger value="all" className="text-xs">
全部
</TabsTrigger>
{categories.map((category) => (
<TabsTrigger key={category} value={category} className="text-xs">
{category}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</motion.div>
{/* Components Grid */}
<div>
{filteredComponents.length === 0 ? (
<motion.div
className="text-center py-12"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 120,
damping: 40,
duration: 0.35,
delay: 0.75,
}}
>
<Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">没有找到匹配的组件</h3>
<p className="text-muted-foreground">
尝试修改搜索关键词或选择其他分类
</p>
</motion.div>
) : (
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
initial={{ opacity: 0, y: 20 }}
variants={cardWrapperVariants}
animate="show"
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// transition={{
// type: "spring",
// stiffness: 120,
// damping: 40,
// duration: 0.35,
// delay: 0.75,
// when: "beforeChildren",
// // delayChildren: stagger(2),
// // delayChildren: 2,
// }}
>
{filteredComponents.map((component, index) => (
<ComponentCard
key={component.cliName + index}
component={component}
index={index}
onCopyCommand={handleCopyCommand}
/>
))}
</motion.div>
)}
</div>
</div>
);
}
interface ComponentCardProps {
component: ComponentInfo;
index: number;
onCopyCommand: (componentId: string) => void;
}
function ComponentCard({
component,
index,
onCopyCommand,
}: ComponentCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
onCopyCommand(component.cliName);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const cardVariants = {
show: {
opacity: 1,
y: 0,
type: "spring",
stiffness: 120,
damping: 40,
transition: {
duration: 0.35,
},
},
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
variants={cardVariants}
// initial={{ opacity: 0, y: 20 }}
// animate={{ opacity: 1, y: 0 }}
// transition={{
// type: "spring",
// stiffness: 120,
// damping: 40,
// duration: 0.75,
// delay: (index + 1) * 0.2,
// }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
>
<Card className="h-full hover:shadow-lg transition-all duration-300">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg mb-2">{component.name}</CardTitle>
<CardDescription className="text-sm line-clamp-2">
{component.description}
</CardDescription>
</div>
<Badge variant="secondary" className="ml-2">
{component.category}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Tags */}
<div className="flex flex-wrap gap-1">
{component.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{component.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{component.tags.length - 3}
</Badge>
)}
</div>
{/* CLI Command */}
<div className="bg-muted/50 rounded-md p-3">
<div className="flex items-center justify-between">
<code className="text-sm font-mono">
npx shadcn@latest add @qiuye-ui/{component.cliName}
</code>
<Button
size="sm"
variant="ghost"
onClick={handleCopy}
className="h-6 w-6 p-0"
>
{copied ? (
<CheckCircle className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button asChild variant="outline" size="sm" className="flex-1">
<Link href={`/components/${component.cliName}`}>查看详情</Link>
</Button>
<Button onClick={handleCopy} size="sm" className="flex-1">
{copied ? "已复制" : "复制命令"}
</Button>
</div>
{/* Metadata */}
<div className="flex justify-between text-xs text-muted-foreground">
<span>v{component.version}</span>
<span>by {component.author}</span>
</div>
</CardContent>
</Card>
</motion.div>
);
}
个人理解笔记
- 需要像官方文档示例React Transitions中写的一样,给父子组件创建两个
variants
,里面用同一个命名空间(如本例中的show
)配置想要实现的动画效果(为了能有动画当然还要给父子组件都设置和show
里面状态不一样的initial
配置,以便动画从initial
过渡到show
的状态)。然后在父组件的variants
的transition
中配置when: "beforeChildren"
,给父组件设置animate="show"
即可实现“先父后子”的动画序列效果。如果有多个子元素,需要子元素间隔一定的时间依次执行动画,可以给父元素的transition
中设置delayChildren: stagger(0.25)
来实现这样的效果。如果需要设置spring
动画配置,则这里不能放在transition
下,而应该放在show
下。
你贴的示例已经用到了
motion/react
(v12 的导入路径)以及when: "beforeChildren"
和delayChildren: stagger(...)
。本文就基于这段代码,系统讲讲在 Motion v12(原 Framer Motion)里如何实现“父完 → 子启”的编排,并给出几种常见写法与坑点。顺手也把你当前写法做了些优化建议。
0. 先搞清楚 v12 的几个要点
- v12 包名从
framer-motion
变成了motion
,React 里改为import { motion, stagger } from "motion/react"
;对 React 用户几乎无破坏升级。(motion.dev) - 编排核心仍是 variants 的
transition.when
、delayChildren
,以及配合stagger()
做子元素的级联延时。(motion.dev)
1. 写法一(最常用):when: "beforeChildren"
+ delayChildren
+ stagger()
这个写法就是你代码的思路:父容器先进场,结束后再依次让子项出现。
import { motion, stagger } from "motion/react";
const wrapper = {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1, y: 0,
transition: {
// ① 先让父级做完
when: "beforeChildren",
// ② 再开始播放子级;这里也可以加 startDelay
delayChildren: stagger(0.15, { startDelay: 0.2 }),
type: "spring", stiffness: 120, damping: 40,
},
},
};
const item = {
hidden: { opacity: 0, y: 12 },
show: {
opacity: 1, y: 0,
transition: { type: "spring", stiffness: 120, damping: 40 },
},
};
export function Grid({ children }) {
return (
<motion.div
variants={wrapper}
initial="hidden"
animate="show"
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
{children.map((child) => (
<motion.div key={child.key} variants={item} whileHover={{ y: -4 }} />
))}
</motion.div>
);
}
要点与坑:
- 孩子不要再单独写
initial/animate
,让它们继承父级的同名 variant key(如上hidden
/show
),否则会覆盖掉编排。(motion.dev) delayChildren
在 v12 中可直接接收stagger(...)
,还能用from
、startDelay
、ease
做更精细的队列控制。(motion.dev)- 你原代码里父级
type: "spring"
同时设置了duration
。当 spring 使用stiffness/damping/mass
时会覆盖duration/bounce
;如果你需要“按时长对齐”,要么改type: "tween"
,要么用 v12 的visualDuration
(或给 spring 配duration + bounce
,但别再同时设刚度参数)。(motion.dev)
2. 写法二:只用 delayChildren
/stagger()
(不强制父完再子)
如果你不需要等父级完全结束,而是希望“父先动一会儿,子再陆续进来”,可以不设 when
,只用 delayChildren: stagger(...)
;那子级会基于父级动画开始时刻延迟启动。(motion.dev)
const wrapper = {
hidden: { opacity: 0, y: 12 },
show: {
opacity: 1, y: 0,
transition: {
delayChildren: stagger(0.12, { startDelay: 0.2 }),
},
},
};
3. 写法三(最强控制):useAnimate
顺序执行(await 承诺)
当你需要严格时序(比如:父 -> 第1列卡片 -> 第2列卡片 -> …)或跨层级/跨组件编排,直接用 useAnimate
做命令式时间线,它的 animate
返回 Promise,「await 前一个动画完成再播下一个」即可。(motion.dev)
import { useEffect } from "react";
import { useAnimate, stagger } from "motion/react";
export function StrictSequence() {
const [scope, animate] = useAnimate();
useEffect(() => {
(async () => {
// 1) 父级
await animate(scope.current, { opacity: 1, y: 0 }, { duration: 0.3 });
// 2) 子级(选择器会自动作用到 scope 内)
await animate(".card", { opacity: 1, y: 0 }, { delay: stagger(0.12) });
})();
}, [animate]);
return (
<div ref={scope}>
<div className="card" />
<div className="card" />
<div className="card" />
</div>
);
}
animate()
本身就是 Promise-like:你可以await animation
等它结束。(motion.dev)
4. 进阶:退出/切换场景用 AnimatePresence
+ when
当列表增删(或路由切换)时,AnimatePresence
能让出场动画也被编排。你仍然可以在 variants 里用 when: "beforeChildren"
/"afterChildren"
去约束父子出场的相对顺序。(motion.dev)
5. 回到你的代码:几处可以直接改进
-
Card 容器的 variants
你已经把when: "beforeChildren"
和delayChildren: stagger(0.25)
写在父网格上,这很好。建议把 spring 的时长控制也统一一下——如果仍用stiffness/damping
,就别再写duration
;若要时间对齐,建议换type: "tween"
或在 spring 里用visualDuration
。(motion.dev) -
子项别重复声明
initial/animate
你用的是variants
+animate="show"
的父传子模式,子卡片组件只需要variants={cardVariants}
即可,避免在子级再写initial/animate
破坏编排。(motion.dev) -
更灵活的队列
如果想让最后一列先出现,可以用stagger(0.25, { from: "last" })
;或者卡片多的时候加ease
让队列分布更自然。(motion.dev)
6. 一个“融合你代码风格”的改写片段
const cardWrapperVariants = {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1, y: 0,
transition: {
when: "beforeChildren",
// 从左到右依次出现;也可 { from: "last" } 或 { startDelay: 0.3 }
delayChildren: stagger(0.18),
type: "spring", stiffness: 120, damping: 40,
},
},
};
const cardVariants = {
hidden: { opacity: 0, y: 12 },
show: {
opacity: 1, y: 0,
transition: { type: "spring", stiffness: 120, damping: 40 },
},
};
// 父
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
variants={cardWrapperVariants}
initial="hidden"
animate="show"
>
{filteredComponents.map((c, i) => (
// 子
<motion.div key={c.cliName + i} variants={cardVariants} whileHover={{ y: -4 }}>
<ComponentCard ... />
</motion.div>
))}
</motion.div>
7. 速查清单(遇事先看它)
- 父先、子后:
transition.when: "beforeChildren"
。(motion.dev) - 延迟并队列化子动画:
delayChildren: stagger(step, options)
。(motion.dev) - 严格时序:
useAnimate
+await animate(...)
。(motion.dev) - Spring 时长注意:设了
stiffness/damping/mass
会覆盖duration/bounce
;想按时长对齐可用tween
或visualDuration
。(motion.dev) - 让子级继承 variants:尽量别在子级重复
initial/animate
。(motion.dev)
如果你愿意,我也可以把你整段页面代码按上面的建议直接改一版(保留现有 UI/动画风格),再帮你补一份「进入视口才播放」的 whileInView
版本,适配长列表场景。
最终优化版(含exit和layout效果)
"use client";
import { useState } from "react";
import Link from "next/link";
import { Search, Package, Copy, CheckCircle } from "lucide-react";
import { motion, stagger, AnimatePresence } from "motion/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
getAllComponents,
getCategories,
getComponentsByCategory,
searchComponents,
type ComponentInfo,
} from "@/lib/registry";
import { useClipboard } from "use-clipboard-copy";
import { toast } from "sonner";
export default function ComponentsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("all");
const clipboard = useClipboard();
const allComponents = getAllComponents();
const categories = getCategories();
// 根据搜索和分类过滤组件
const filteredComponents = searchQuery
? searchComponents(searchQuery)
: selectedCategory === "all"
? allComponents
: getComponentsByCategory(selectedCategory);
const handleCopyCommand = (componentId: string) => {
const command = `npx shadcn@latest add @qiuye-ui/${componentId}`;
clipboard.copy(command);
toast.success("复制成功!", {
description: `已复制命令: ${command}`,
});
};
// 父级网格:先进场,再播放子项;离场时等子项先退场
const gridVariants = {
hidden: { opacity: 0, y: 20 },
show: {
opacity: 1,
y: 0,
transition: {
when: "beforeChildren",
delayChildren: stagger(0.18),
type: "spring",
visualDuration: 0.25,
bounce: 0.12,
},
},
// 只有在整个网格卸载时才会触发(例如外层条件渲染切换)
exit: {
opacity: 0,
y: 12,
transition: {
when: "afterChildren", // 先等子项完成 exit
duration: 0.2, // 这里用 tween,让离场更可控
},
},
} as const;
// 子卡片:进入 / 离开动画
const cardVariants = {
hidden: { opacity: 0, y: 20, scale: 0.98 },
show: {
opacity: 1,
y: 0,
scale: 1,
transition: {
type: "spring",
visualDuration: 0.45,
bounce: 0.48,
},
},
exit: {
opacity: 0,
y: -8,
scale: 0.98,
transition: { duration: 0.2 },
},
} as const;
return (
<div className="container mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 120,
damping: 40,
duration: 0.25,
}}
>
<h1 className="text-4xl font-bold tracking-tight mb-4">QiuYe UI</h1>
<p className="text-xl text-muted-foreground mb-6">
精心设计的自定义UI组件,让您的应用更加出色
</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Package className="h-4 w-4" />
<span>{allComponents.length} 个组件</span>
</div>
<div className="flex items-center gap-2">
<span>•</span>
<span>{categories.length} 个分类</span>
</div>
<div className="flex items-center gap-2">
<span>•</span>
<span>一键复制CLI命令</span>
</div>
</div>
</motion.div>
</div>
{/* Search and Filter */}
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 120,
damping: 40,
duration: 0.45,
delay: 0.25,
}}
>
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索组件..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-1">
<TabsTrigger value="all" className="text-xs">
全部
</TabsTrigger>
{categories.map((category) => (
<TabsTrigger key={category} value={category} className="text-xs">
{category}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</motion.div>
{/* Components Grid / Empty State 切换:带离场 */}
<AnimatePresence mode="wait">
{filteredComponents.length === 0 ? (
// 空态:等网格离场完再出现
<motion.div
key="empty"
className="text-center py-12"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ type: "tween", duration: 0.25 }}
>
<Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">没有找到匹配的组件</h3>
<p className="text-muted-foreground">
尝试修改搜索关键词或选择其他分类
</p>
</motion.div>
) : (
// 网格:父先、子后;移除子项时播放 exit
<motion.div
key="grid"
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
variants={gridVariants}
initial="hidden"
animate="show"
exit="exit"
>
<AnimatePresence mode="popLayout">
{filteredComponents.map((component) => (
<motion.div
key={component.cliName} // 使用稳定 key,确保正确的出场动画
variants={cardVariants}
layout // 位置变化时做流畅过渡
// exit="exit" // 设置 exit 后, 父先子后的 stagger 入场动画就失效了
whileHover={{ y: -4, transition: { duration: 0.2 } }}
>
<ComponentCard
component={component}
onCopyCommand={handleCopyCommand}
/>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
interface ComponentCardProps {
component: ComponentInfo;
onCopyCommand: (componentId: string) => void;
}
// 精简后的子组件:不再包一个 motion.div,动画交给父级
function ComponentCard({ component, onCopyCommand }: ComponentCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
onCopyCommand(component.cliName);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Card className="h-full hover:shadow-lg transition-all duration-300">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg mb-2">{component.name}</CardTitle>
<CardDescription className="text-sm line-clamp-2">
{component.description}
</CardDescription>
</div>
<Badge variant="secondary" className="ml-2">
{component.category}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Tags */}
<div className="flex flex-wrap gap-1">
{component.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{component.tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{component.tags.length - 3}
</Badge>
)}
</div>
{/* CLI Command */}
<div className="bg-muted/50 rounded-md p-3">
<div className="flex items-center justify-between">
<code className="text-sm font-mono">
npx shadcn@latest add @qiuye-ui/{component.cliName}
</code>
<Button
size="sm"
variant="ghost"
onClick={handleCopy}
className="h-6 w-6 p-0"
>
{copied ? (
<CheckCircle className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button asChild variant="outline" size="sm" className="flex-1">
<Link href={`/components/${component.cliName}`}>查看详情</Link>
</Button>
<Button onClick={handleCopy} size="sm" className="flex-1">
{copied ? "已复制" : "复制命令"}
</Button>
</div>
{/* Metadata */}
<div className="flex justify-between text-xs text-muted-foreground">
<span>v{component.version}</span>
<span>by {component.author}</span>
</div>
</CardContent>
</Card>
);
}
Comments NOTHING