TL;DR:不要直接给
TabsContent
做动画(隐藏时会display: none
,退出动画放不出来)。把 Tabs 做成受控组件,用AnimatePresence
只渲染一个当前面板;根据“上一个激活值”和“当前激活值”的顺序差决定左右方向。本文提供一套通用组件<AnimatedPanels />
与完整示例,可直接复用到 shadcn/ui、Radix、Headless UI 或自研 Tabs。
背景 & 问题
很多 UI 库(包括 shadcn/ui 基于的 Radix Tabs)在非激活时会把面板 display: none
。这会导致两个问题:
- 退出动画失效:面板一旦
display: none
,exit
动画根本没机会播放。 - 多实例切换撕裂:如果你把所有
TabsContent
都渲染出来,再用条件类控制显示/隐藏,会带来复杂度和不必要的 DOM 重量。
解决策略:用 “单实例面板切换” 模式(Single-instance switching)
- Tabs 改为受控(
value
/onValueChange
),记录“上一个 tab”。 - 只渲染一个面板(当前激活的),放进
AnimatePresence
。 - 面板使用
position: absolute
做进场,position: relative
时居中。 - 根据“方向”(左/右)设置
enter
/exit
的x
位移与opacity
。 - 容器设
position: relative; min-height
,避免高度跳动。
通用实现
组件 API 设计
type Key = string | number;
interface AnimatedPanelsProps {
/** 当前激活面板的 key */
active: Key;
/** 用于判定方向的顺序列表(从左到右) */
order: Key[];
/** 面板内容,Record 形式可直接按 key 渲染 */
panels: Record<Key, React.ReactNode>;
/** 进出场位移,默认 48(px) */
distance?: number;
/** 自定义过渡 */
transition?: {
type?: "spring" | "tween";
stiffness?: number;
damping?: number;
mass?: number;
duration?: number;
};
/** 当系统偏好“减少动效”时仅做淡入淡出(默认自动检测) */
respectReducedMotion?: boolean;
/** 可选:容器类名/面板类名 */
className?: string;
panelClassName?: string;
/** 可选:为可访问性设置 aria-labelledby 的 id 前缀,如 tab-installation */
labelledByPrefix?: string;
}
可复用组件源码(拷贝即用)
依赖:React、Framer Motion v11(
motion/react
导入路径)
若你仍在使用 v10,请把import { motion, AnimatePresence } from "motion/react"
改为from "framer-motion"
。
"use client";
import React from "react";
import { motion, AnimatePresence } from "motion/react";
/** 安全的“上一次值” */
function usePrevious<T>(value: T) {
const ref = React.useRef<T>(value);
React.useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
/** SSR 安全的 prefers-reduced-motion 检测 */
function usePrefersReducedMotion() {
const [reduced, setReduced] = React.useState(false);
React.useEffect(() => {
if (typeof window === "undefined") return;
const m = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
setReduced(m.matches);
if (m.addEventListener) m.addEventListener("change", handler);
else m.addListener(handler);
return () => {
if (m.removeEventListener) m.removeEventListener("change", handler);
else m.removeListener(handler);
};
}, []);
return reduced;
}
type Key = string | number;
interface AnimatedPanelsProps {
active: Key;
order: Key[];
panels: Record<Key, React.ReactNode>;
distance?: number;
transition?: {
type?: "spring" | "tween";
stiffness?: number;
damping?: number;
mass?: number;
duration?: number;
};
respectReducedMotion?: boolean;
className?: string;
panelClassName?: string;
labelledByPrefix?: string;
}
export function AnimatedPanels({
active,
order,
panels,
distance = 48,
transition = { type: "spring", stiffness: 220, damping: 28, mass: 0.8 },
respectReducedMotion = true,
className,
panelClassName,
labelledByPrefix,
}: AnimatedPanelsProps) {
const prev = usePrevious(active);
const prefersReduced = usePrefersReducedMotion();
const reduce = respectReducedMotion && prefersReduced;
// 计算方向:向后(右侧)为 +1,向前(左侧)为 -1,相同时为 0
const currIdx = order.indexOf(active);
const prevIdx = order.indexOf(prev);
const hasPrev = prev !== undefined && prev !== null && prevIdx !== -1;
const diff = hasPrev ? currIdx - prevIdx : 0;
const direction = diff === 0 ? 0 : Math.sign(diff); // -1 | 0 | 1
// 注意:只渲染当前激活面板,保证“单实例切换”
const content = panels[active];
const variants = {
enter: (dir: number) =>
reduce
? { opacity: 0, x: 0, position: "absolute" as const, width: "100%" }
: {
opacity: 0,
x: dir >= 0 ? distance : -distance,
position: "absolute" as const,
width: "100%",
},
center: {
opacity: 1,
x: 0,
position: "relative" as const,
width: "100%",
},
exit: (dir: number) =>
reduce
? { opacity: 0, x: 0, position: "absolute" as const, width: "100%" }
: {
opacity: 0,
x: dir >= 0 ? -distance : distance,
position: "absolute" as const,
width: "100%",
},
};
// a11y:如果提供了 labelledByPrefix,就把 aria-labelledby 指到相应的 Trigger id
const ariaLabelledBy =
labelledByPrefix && typeof active !== "object"
? `${labelledByPrefix}-${String(active)}`
: undefined;
return (
<div
className={["relative", className].filter(Boolean).join(" ")}
// 你也可以传入最小高度,避免高度抖动:style={{ minHeight: 480 }}
>
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={String(active)}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={transition}
role="tabpanel"
aria-labelledby={ariaLabelledBy}
className={panelClassName}
>
{content}
</motion.div>
</AnimatePresence>
</div>
);
}
与现有 Tabs 组合(以 shadcn/ui 为例)
关键点只有两步:
1)Tabs 受控:用本地tab
状态管理value
/onValueChange
;
2)用<AnimatedPanels />
替换四个<TabsContent>
,并把每个面板内容作为panels
的值。
"use client";
import { useState } from "react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AnimatedPanels } from "./animated-panels"; // ← 就是上面的通用组件
import { Card } from "@/components/ui/card";
const order = ["installation", "usage", "commands", "api"] as const;
type Tab = (typeof order)[number];
export default function Demo() {
const [tab, setTab] = useState<Tab>("installation");
const panels = {
installation: (
<Card className="p-6">这里是安装面板的内容(你的真实内容)</Card>
),
usage: <Card className="p-6">这里是使用面板的内容</Card>,
commands: <Card className="p-6">这里是命令面板的内容</Card>,
api: <Card className="p-6">这里是 API 面板的内容</Card>,
};
return (
<div className="space-y-6">
<Tabs
value={tab}
onValueChange={(v) => setTab(v as Tab)}
className="space-y-6"
>
<TabsList className="grid grid-cols-4">
<TabsTrigger id="tab-installation" value="installation">
安装
</TabsTrigger>
<TabsTrigger id="tab-usage" value="usage">
使用
</TabsTrigger>
<TabsTrigger id="tab-commands" value="commands">
命令
</TabsTrigger>
<TabsTrigger id="tab-api" value="api">
API
</TabsTrigger>
</TabsList>
{/* 面板区域:只渲染当前一个面板 */}
<AnimatedPanels
active={tab}
order={order as unknown as (string | number)[]}
panels={panels}
distance={48}
labelledByPrefix="tab"
className="min-h-[320px]" // 避免高度跳动
/>
</Tabs>
</div>
);
}
想集成到 Radix/Headless Tabs 或自研 Tabs?只要能拿到当前值和顺序,填入
<AnimatedPanels active order panels />
即可。
设计细节与取舍
-
为什么是“单实例面板”?
只渲染当前内容,避免display: none
抢先把 DOM 干掉,exit
才有机会播放;也减少 DOM 体积与内存占用。 -
绝对/相对定位切换
- 进/出场阶段:
position: absolute; width: 100%
,便于位移动画不影响布局。 - 居中阶段:
position: relative
,恢复正常文档流。
- 进/出场阶段:
-
方向判定
用order.indexOf(active) - order.indexOf(prevActive)
的正负值来判断“向左/向右”。如果两个值相同或首次渲染,方向为0
(只淡入)。 -
避免高度跳动
简单方案:给容器一个min-height
(比如最大期望高度)。
进阶方案:测量内容高度过渡,但要小心和绝对定位的交互复杂度,通常没必要。 -
尊重“减少动效”
通过prefers-reduced-motion
自动降级到纯opacity
,遵循可访问性规范。 -
SSR 安全
usePrefersReducedMotion
中做了typeof window === "undefined"
判断,避免服务端渲染报错。
可拓展玩法(改改参数即可)
- 仅淡入淡出:把
distance
设为0
或强制reduce = true
。 - 方向相反:将
enter/exit
的符号对调即可。 - 缩放动画:在
variants
的enter/exit
加上scale: 0.98 → 1
。 - 加速/减速:把
transition.type
改为"tween"
并设置duration: 0.22
。
示例(淡入 + 微缩放):
const variants = {
enter: { opacity: 0, scale: 0.98, position: "absolute" as const, width: "100%" },
center: { opacity: 1, scale: 1, position: "relative" as const, width: "100%" },
exit: { opacity: 0, scale: 0.98, position: "absolute" as const, width: "100%" },
};
性能建议
- 重内容懒加载:对不常访问的面板做
dynamic(() => import("./HeavyPanel"))
。 - 记忆化:面板内容如果依赖昂贵计算,可用
useMemo
缓存。 - 避免不必要重排:面板内尽量避免布局抖动(图片提前占位等)。
常见坑
- 直接给
TabsContent
做动画:在很多库里不可行,退出动画不会播。 - 顺序列表缺失/错误:
order
必须覆盖所有可能的active
值;否则方向判断会出错。 - 首次渲染方向异常:记得在没有
prevActive
时设方向为0
。 - 高度忽闪:容器加
min-height
是最省事的办法。
最终清单(Checklist)
- [x] Tabs 是受控的(
value
/onValueChange
) - [x] 使用
<AnimatedPanels active order panels />
- [x] 容器
position: relative
,面板进出用absolute
- [x] 处理
prefers-reduced-motion
- [x] 适当的
min-height
- [x] a11y:
aria-labelledby
指向对应触发器
结语
这套“单实例 + 方向感”的切换模式足够通用:
- 对任何 Tabs 库友好;
- 代码量小、耦合度低;
- 支持 a11y 与减少动效;
- 可按需扩展(缩放、淡入、滑动、交叉淡入等)。
把上面的 AnimatedPanels
复制到你的项目里,换上各面板的真实内容,你就拥有了一个优雅、可维护、可复用的 Tabs 过渡方案。
个人笔记
可以把AnimatePresence
的mode
设置为"popLayout"
,这样就可以让淡出与淡入同时进行,视觉更流畅。
应用举例:
"use client";
import { useState } from "react";
import { Copy, CheckCircle, Terminal, Package } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { useClipboard } from "use-clipboard-copy";
import { toast } from "sonner";
export default function CLIPage() {
const clipboard = useClipboard();
const [copiedStates, setCopiedStates] = useState<{ [key: string]: boolean }>(
{}
);
const handleCopy = (text: string, key: string) => {
clipboard.copy(text);
setCopiedStates((prev) => ({ ...prev, [key]: true }));
setTimeout(() => {
setCopiedStates((prev) => ({ ...prev, [key]: false }));
}, 2000);
toast.success("复制成功!", {
description: `已复制: ${text}`,
});
};
const CopyButton = ({ text, copyKey }: { text: string; copyKey: string }) => (
<Button
variant="ghost"
size="sm"
onClick={() => handleCopy(text, copyKey)}
className="h-6 w-6 p-0"
aria-label="复制代码"
>
{copiedStates[copyKey] ? (
<CheckCircle className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
);
const CodeBlock = ({
code,
language = "bash",
copyKey,
}: {
code: string;
language?: string;
copyKey: string;
}) => (
<div className="relative">
<pre className="bg-muted/50 rounded-md p-4 pr-12 overflow-x-auto whitespace-pre-wrap break-words">
<code className={`text-sm font-mono language-${language}`}>{code}</code>
</pre>
<div className="absolute top-2 right-2">
<CopyButton text={code} copyKey={copyKey} />
</div>
</div>
);
// --- Tabs 受控 + 方向判定 ---
const tabOrder = ["installation", "usage", "commands", "api"] as const;
type Tab = (typeof tabOrder)[number];
const [tab, setTab] = useState<Tab>("installation");
const [prevTab, setPrevTab] = useState<Tab>("installation");
const direction =
Math.sign(tabOrder.indexOf(tab) - tabOrder.indexOf(prevTab)) || 1; // 1:向右,-1:向左
const panelVariants = {
enter: (dir: number) => ({
x: dir * 80, // 新面板从右进(向右切) / 从左进(向左切)
opacity: 0,
position: "absolute" as const,
width: "100%",
}),
center: {
x: 0,
opacity: 1,
position: "relative" as const,
width: "100%",
},
exit: (dir: number) => ({
x: -dir * 80, // 旧面板往左出(向右切) / 往右出(向左切)
opacity: 0,
position: "absolute" as const,
width: "100%",
}),
};
return (
<div className="container mx-auto px-6 py-8">
{/* Header */}
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 120,
damping: 40,
duration: 0.25,
}}
>
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<Terminal className="h-6 w-6 text-primary-foreground" />
</div>
<div>
<h1 className="text-4xl font-bold tracking-tight">CLI 工具</h1>
<p className="text-muted-foreground">
一键安装和管理 QiuYe UI 组件
</p>
</div>
</div>
<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>基于 shadcn/ui CLI</span>
</div>
<div className="flex items-center gap-2">
<span>•</span>
<span>官方工具支持</span>
</div>
<div className="flex items-center gap-2">
<span>•</span>
<span>无需额外安装</span>
</div>
</div>
</motion.div>
{/* Content */}
<div>
<Tabs
value={tab}
onValueChange={(v) => {
setPrevTab(tab);
setTab(v as Tab);
}}
className="space-y-6"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 120,
damping: 40,
duration: 0.5,
delay: 0.45,
}}
>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger id="tab-installation" value="installation">
安装
</TabsTrigger>
<TabsTrigger id="tab-usage" value="usage">
使用
</TabsTrigger>
<TabsTrigger id="tab-commands" value="commands">
命令
</TabsTrigger>
<TabsTrigger id="tab-api" value="api">
API
</TabsTrigger>
</TabsList>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 120,
damping: 40,
duration: 0.5,
delay: 0.75,
}}
>
{/* Animated panels */}
<div className="relative min-h-[480px]">
<AnimatePresence initial={false} custom={direction} mode="popLayout">
<motion.div
key={tab}
custom={direction}
variants={panelVariants}
initial="enter"
animate="center"
exit="exit"
transition={{
type: "spring",
stiffness: 220,
damping: 28,
mass: 0.8,
}}
role="tabpanel"
aria-labelledby={`tab-${tab}`}
>
{tab === "installation" && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>使用 Shadcn/ui CLI</CardTitle>
<CardDescription>
使用官方 shadcn/ui CLI 工具安装 QiuYe UI 组件
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">
前置要求
</h3>
<p className="text-sm text-muted-foreground mb-3">
确保您的项目已经安装并配置了 shadcn/ui:
</p>
<CodeBlock
code="npx shadcn@latest init"
copyKey="shadcn-init"
/>
<p className="text-sm text-muted-foreground mt-2">
如果还没有初始化 shadcn/ui,请先运行上述命令
</p>
</div>
<Separator />
<div>
<h3 className="text-lg font-semibold mb-3">
方式一:配置注册表(推荐)
</h3>
<p className="text-sm text-muted-foreground mb-3">
在项目根目录的{" "}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
components.json
</code>{" "}
文件中添加QiuYe UI的注册表:
</p>
<CodeBlock
code={`{
"registries": {
"@qiuye-ui": "https://qiuye-ui.vercel.app/registry/{name}.json"
}
}`}
language="json"
copyKey="registry-config"
/>
<p className="text-sm text-muted-foreground mt-3 mb-3">
然后使用简化的命令安装组件:
</p>
<CodeBlock
code="npx shadcn@latest add @qiuye-ui/animated-button"
copyKey="install-component"
/>
</div>
<Separator />
<div>
<h3 className="text-lg font-semibold mb-3">
方式二:直接URL安装
</h3>
<p className="text-sm text-muted-foreground mb-3">
如果不想配置注册表,可以直接使用URL安装组件:
</p>
<CodeBlock
code="npx shadcn@latest add https://qiuye-ui.vercel.app/registry/animated-button.json"
copyKey="install-component-url"
/>
<p className="text-sm text-muted-foreground mt-2">
直接指定组件的注册表JSON文件URL进行安装
</p>
</div>
</CardContent>
</Card>
</div>
)}
{tab === "usage" && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>基本使用</CardTitle>
<CardDescription>
学习如何在您的项目中使用 QiuYe UI 组件
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">
1. 安装组件
</h3>
<p className="text-sm text-muted-foreground mb-3">
方式一:使用注册表名称(需先配置注册表)
</p>
<div className="space-y-3">
<CodeBlock
code="npx shadcn@latest add @qiuye-ui/animated-button"
copyKey="add-single"
/>
<CodeBlock
code="npx shadcn@latest add @qiuye-ui/gradient-card @qiuye-ui/typing-text"
copyKey="add-multiple"
/>
</div>
<p className="text-sm text-muted-foreground mt-3 mb-3">
方式二:直接使用URL(无需配置)
</p>
<div className="space-y-3">
<CodeBlock
code="npx shadcn@latest add https://qiuye-ui.vercel.app/registry/animated-button.json"
copyKey="add-single-url"
/>
</div>
<p className="text-sm text-muted-foreground mt-2">
支持单个组件安装或批量安装多个组件
</p>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">
2. 在代码中使用
</h3>
<CodeBlock
code={`import { AnimatedButton } from "@/components/qiuye-ui/animated-button";
export default function App() {
return (
<div>
<AnimatedButton animation="bounce">
点击我!
</AnimatedButton>
</div>
);
}`}
language="tsx"
copyKey="usage-example"
/>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">
3. 查看可用组件
</h3>
<p className="text-sm text-muted-foreground mb-3">
访问组件浏览器查看所有可用组件和演示:
</p>
<CodeBlock
code="https://qiuye-ui.vercel.app/components"
copyKey="component-browser"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>项目配置</CardTitle>
<CardDescription>
自定义CLI工具的行为和设置
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-semibold mb-2">配置文件示例</h4>
<CodeBlock
code={`{
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"qiuye-ui": "@/components/qiuye-ui"
}
}`}
language="json"
copyKey="config-example"
/>
</div>
</CardContent>
</Card>
</div>
)}
{tab === "commands" && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>常用命令</CardTitle>
<CardDescription>
使用官方 shadcn/ui CLI 管理 QiuYe UI 组件
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4">
{[
{
command: "npx shadcn@latest init",
description: "初始化 shadcn/ui 项目配置",
example: "npx shadcn@latest init",
},
{
command:
"npx shadcn@latest add @qiuye-ui/[component]",
description:
"添加 QiuYe UI 组件(需配置注册表)",
example:
"npx shadcn@latest add @qiuye-ui/animated-button",
},
{
command: "npx shadcn@latest add [URL]",
description: "直接使用URL添加组件",
example:
"npx shadcn@latest add https://qiuye-ui.vercel.app/registry/animated-button.json",
},
{
command:
"npx shadcn@latest add @qiuye-ui/[multiple]",
description: "批量添加多个组件",
example:
"npx shadcn@latest add @qiuye-ui/gradient-card @qiuye-ui/typing-text",
},
{
command: "npx shadcn@latest --help",
description: "查看CLI工具帮助信息",
example: "npx shadcn@latest --help",
},
].map((item, index) => (
<div
key={index}
className="border rounded-lg p-4"
>
<div className="flex items-start justify-between mb-2">
<div>
<code className="text-sm font-mono bg-muted px-2 py-1 rounded">
{item.command}
</code>
<p className="text-sm text-muted-foreground mt-1">
{item.description}
</p>
</div>
<CopyButton
text={item.example}
copyKey={`cmd-${index}`}
/>
</div>
<div className="bg-muted/30 rounded p-2 text-xs font-mono">
$ {item.example}
</div>
</div>
))}
</div>
<Separator />
<div>
<h3 className="text-lg font-semibold mb-3">
可用组件列表
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[
// TODO: 后续需要添加更多组件, 或支持动态加载组件列表
"animated-button",
"gradient-card",
"typing-text",
].map((component) => (
<div
key={component}
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
>
<code className="text-sm font-mono">
@qiuye-ui/{component}
</code>
<CopyButton
text={`npx shadcn@latest add @qiuye-ui/${component}`}
copyKey={`component-${component}`}
/>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
)}
{tab === "api" && (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>注册表结构</CardTitle>
<CardDescription>
静态注册表文件的结构和访问方式
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4">
{[
{
endpoint: "/registry/[component].json",
description: "单个组件的详细配置和源代码",
example:
"https://qiuye-ui.vercel.app/registry/animated-button.json",
},
].map((api, index) => (
<div
key={index}
className="border rounded-lg p-4"
>
<div className="flex items-center gap-3 mb-2">
<Badge variant="secondary">GET</Badge>
<code className="text-sm font-mono">
{api.endpoint}
</code>
</div>
<p className="text-sm text-muted-foreground mb-2">
{api.description}
</p>
<div className="bg-muted/30 rounded p-2 text-xs font-mono flex items-center justify-between">
<span>{api.example}</span>
<CopyButton
text={api.example}
copyKey={`endpoint-${index}`}
/>
</div>
</div>
))}
</div>
<Separator />
<div>
<h3 className="text-lg font-semibold mb-3">
注册表配置
</h3>
<p className="text-sm text-muted-foreground mb-3">
在您的项目中添加以下配置到{" "}
<code className="text-xs bg-muted px-1 py-0.5 rounded">
components.json
</code>
:
</p>
<CodeBlock
code={`{
"registries": {
"@qiuye-ui": "https://qiuye-ui.vercel.app/registry/{name}.json"
}
}`}
language="json"
copyKey="registry-config-final"
/>
</div>
<Separator />
<div>
<h3 className="text-lg font-semibold mb-3">
组件文件结构
</h3>
<p className="text-sm text-muted-foreground mb-3">
每个组件的JSON文件包含以下信息:
</p>
<CodeBlock
code={`{
"name": "component-name",
"type": "registry:component",
"dependencies": ["react", "motion"],
"registryDependencies": [],
"files": [
{
"type": "registry:component",
"name": "component-name.tsx",
"content": "组件源代码..."
}
]
}`}
language="json"
copyKey="component-structure"
/>
</div>
</CardContent>
</Card>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</Tabs>
</div>
</div>
);
}
Comments NOTHING