附录 B:Tailwind + shadcn/ui 最佳实践组件示例
By Leeting Yan
B.0 基础工具:cn 函数(必须有)
cn 是 shadcn/ui 的经典组合:clsx + tailwind-merge。
在所有组件里用于合并类名并解决冲突。
// src/lib/cn.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
后面所有组件都默认从这里引用 cn。
B.1 Button 组件(核心中的核心)
B.1.1 设计目标
- 支持
variant:primary / secondary / outline / ghost / destructive / link - 支持
size:sm / md / lg / icon - 支持
loading状态(显示 spinner) - 支持 disabled
- 支持
asChild(配合 Radix / Next Link) - 使用 Tailwind + shadcn 风格
B.1.2 代码示例
// src/components/ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/cn"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 " +
"disabled:pointer-events-none disabled:opacity-50 " +
"ring-offset-background",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3 rounded-md",
lg: "h-11 px-8 rounded-md",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(
buttonVariants({ variant, size, className }),
loading && "relative text-transparent cursor-wait"
)}
ref={ref}
disabled={props.disabled || loading}
{...props}
>
{loading && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent" />
</span>
)}
<span className={cn(loading && "invisible")}>{children}</span>
</Comp>
)
}
)
Button.displayName = "Button"
用法示例:
<Button>默认按钮</Button>
<Button variant="outline">Outline</Button>
<Button variant="destructive" size="sm">删除</Button>
<Button loading>保存中...</Button>
B.2 Input / Textarea 表单组件
B.2.1 Input 组件
// src/components/ui/input.tsx
import * as React from "react"
import { cn } from "@/lib/cn"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm " +
"ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium " +
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 " +
"focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
B.2.2 Textarea 组件
// src/components/ui/textarea.tsx
import * as React from "react"
import { cn } from "@/lib/cn"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm " +
"ring-offset-background placeholder:text-muted-foreground " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring " +
"focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
B.3 表单字段组合:FormField / Label / Description / Error
一个常见模式:label + input + hint + error,统一间距和样式,避免每个页面都手写。
// src/components/ui/form-field.tsx
import { cn } from "@/lib/cn"
interface FormFieldProps {
label?: string
description?: string
error?: string
requiredMark?: boolean
className?: string
children: React.ReactNode
}
export function FormField({
label,
description,
error,
requiredMark,
className,
children,
}: FormFieldProps) {
return (
<div className={cn("space-y-1", className)}>
{label && (
<label className="flex items-center text-sm font-medium text-foreground">
<span>{label}</span>
{requiredMark && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{children}
{description && !error && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
{error && (
<p className="text-xs text-destructive flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-destructive" />
<span>{error}</span>
</p>
)}
</div>
)
}
使用示例:
<FormField label="邮箱" description="用于登录和通知">
<Input type="email" placeholder="you@example.com" />
</FormField>
<FormField label="密码" error="密码长度至少 8 位" requiredMark>
<Input type="password" />
</FormField>
B.4 Card(卡片组件)
用于后台 / 控制台的基础模块容器。
// src/components/ui/card.tsx
import * as React from "react"
import { cn } from "@/lib/cn"
export function Card({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-xl border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
export function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 p-6 border-b", className)}
{...props}
/>
)
}
export function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<h3
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
}
export function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("p-6", className)} {...props} />
)
}
export function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex items-center justify-end gap-2 p-6 border-t", className)}
{...props}
/>
)
}
使用示例:
<Card>
<CardHeader>
<CardTitle>本月收入</CardTitle>
<CardDescription>按自然月统计,不含退款</CardDescription>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold">¥ 128,900</div>
</CardContent>
<CardFooter>
<Button variant="outline" size="sm">导出报表</Button>
<Button size="sm">查看详情</Button>
</CardFooter>
</Card>
B.5 Badge(小徽章)
统一用在标签、状态标识、计数等。
// src/components/ui/badge.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/cn"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold " +
"transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "text-foreground",
success:
"border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-300",
warning:
"border-transparent bg-amber-500/10 text-amber-600 dark:text-amber-300",
destructive:
"border-transparent bg-destructive/10 text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
示例:
<Badge>默认</Badge>
<Badge variant="success">已完成</Badge>
<Badge variant="warning">待审核</Badge>
<Badge variant="destructive">已关闭</Badge>
B.6 表格封装:DataTable(简化版)
只给你一个简化但实用的版本,适合后台常见列表:
- 支持 striped rows
- 支持 hover
- 支持空态
- Tailwind 样式可进一步抽象
// src/components/ui/simple-table.tsx
import { cn } from "@/lib/cn"
export interface Column<T> {
key: keyof T | string
header: React.ReactNode
render?: (row: T) => React.ReactNode
className?: string
}
interface SimpleTableProps<T> {
columns: Column<T>[]
data: T[]
emptyText?: string
className?: string
}
export function SimpleTable<T>({
columns,
data,
emptyText = "暂无数据",
className,
}: SimpleTableProps<T>) {
return (
<div className={cn("overflow-hidden rounded-xl border bg-card", className)}>
<table className="min-w-full divide-y divide-border">
<thead className="bg-muted/50">
<tr>
{columns.map((col, i) => (
<th
key={String(col.key) + i}
className={cn(
"px-4 py-3 text-left text-xs font-medium text-muted-foreground",
col.className
)}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border bg-card">
{data.length === 0 && (
<tr>
<td
colSpan={columns.length}
className="px-4 py-6 text-center text-sm text-muted-foreground"
>
{emptyText}
</td>
</tr>
)}
{data.map((row, rowIndex) => (
<tr
key={rowIndex}
className="hover:bg-muted/40 transition-colors"
>
{columns.map((col, colIndex) => (
<td
key={String(col.key) + colIndex}
className={cn(
"px-4 py-3 text-sm text-foreground align-middle",
col.className
)}
>
{col.render
? col.render(row)
: (row as any)[col.key as keyof T]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
使用示例:
type User = {
id: string
name: string
email: string
status: "active" | "pending" | "suspended"
}
const columns: Column<User>[] = [
{ key: "name", header: "姓名" },
{ key: "email", header: "邮箱" },
{
key: "status",
header: "状态",
render: (row) => (
<Badge
variant={
row.status === "active"
? "success"
: row.status === "pending"
? "warning"
: "destructive"
}
>
{row.status === "active"
? "正常"
: row.status === "pending"
? "待激活"
: "已停用"}
</Badge>
),
},
]
<SimpleTable columns={columns} data={userList} />
B.7 Dialog / Modal(结合 Radix + Tailwind)
标准做法是用 Radix UI 的 Dialog,配合 Tailwind 封装。
// src/components/ui/dialog.tsx
import * as React from "react"
import * as RadixDialog from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/cn"
export const Dialog = RadixDialog.Root
export const DialogTrigger = RadixDialog.Trigger
export const DialogClose = RadixDialog.Close
export function DialogContent({
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof RadixDialog.Content>) {
return (
<RadixDialog.Portal>
<RadixDialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
<RadixDialog.Content
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 " +
"rounded-xl border bg-background p-6 shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring",
className
)}
{...props}
>
{children}
<RadixDialog.Close className="absolute right-3 top-3 rounded-full p-1 text-muted-foreground hover:bg-muted">
<X className="h-4 w-4" />
</RadixDialog.Close>
</RadixDialog.Content>
</RadixDialog.Portal>
)
}
export function DialogHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("space-y-1.5 mb-4", className)} {...props} />
)
}
export function DialogTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
}
export function DialogDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
)
}
export function DialogFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("mt-6 flex items-center justify-end gap-2", className)}
{...props}
/>
)
}
使用示例:
<Dialog>
<DialogTrigger asChild>
<Button>新建项目</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>新建项目</DialogTitle>
<DialogDescription>项目用于组织你的应用、数据和成员。</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<FormField label="项目名称" requiredMark>
<Input placeholder="例如:MiniPlay Studio" />
</FormField>
<FormField label="描述">
<Textarea rows={3} />
</FormField>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
<Button>创建</Button>
</DialogFooter>
</DialogContent>
</Dialog>
B.8 Navbar / 顶部导航(SaaS 通用)
简化版:左边 Logo,中间导航,右边用户菜单。
// src/components/layout/top-nav.tsx
import Link from "next/link"
import { cn } from "@/lib/cn"
import { Button } from "@/components/ui/button"
interface NavItem {
label: string
href: string
active?: boolean
}
interface TopNavProps {
items: NavItem[]
className?: string
}
export function TopNav({ items, className }: TopNavProps) {
return (
<header className={cn("border-b bg-background", className)}>
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<div className="flex items-center gap-2">
<div className="h-7 w-7 rounded-lg bg-gradient-to-br from-sky-500 to-indigo-600" />
<span className="text-sm font-semibold tracking-tight">
Birdor Studio
</span>
</div>
<nav className="hidden items-center gap-4 text-sm font-medium md:flex">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
"text-muted-foreground hover:text-foreground",
item.active && "text-foreground"
)}
>
{item.label}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="hidden md:inline-flex">
文档
</Button>
<Button size="sm">控制台</Button>
</div>
</div>
</header>
)
}
使用:
<TopNav
items={[
{ label: "概览", href: "/", active: true },
{ label: "项目", href: "/projects" },
{ label: "账单", href: "/billing" },
]}
/>
B.9 PageShell:SaaS 控制台页面骨架
一个典型的布局:
- 顶部导航
- 左侧菜单
- 右侧主内容
- 响应式收缩
这里只给主要结构及 Tailwind 写法,便于你跟掌机业务或 Birdor 工具后台直接对接。
// src/components/layout/app-shell.tsx
import { ReactNode } from "react"
import { cn } from "@/lib/cn"
interface AppShellProps {
sidebar: ReactNode
header?: ReactNode
children: ReactNode
}
export function AppShell({ sidebar, header, children }: AppShellProps) {
return (
<div className="flex h-screen bg-background">
{/* Sidebar */}
<aside className="hidden w-60 border-r bg-muted/40 md:flex md:flex-col">
{sidebar}
</aside>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
{header && (
<header className="border-b bg-background">
<div className="mx-auto flex h-14 max-w-6xl items-center px-4">
{header}
</div>
</header>
)}
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-6xl px-4 py-6">{children}</div>
</main>
</div>
</div>
)
}
用法示例:
<AppShell
sidebar={<YourSidebar />}
header={<div className="text-sm font-medium">项目概览</div>}
>
{/* 页面主体内容 */}
<DashboardContent />
</AppShell>
B.10 小结:如何把这些组件变成“你自己的 shadcn/ui”
你可以按下面思路整理成「企业内部 UI 库」:
-
建一个
packages/ui(monorepo) -
把以上组件整理成:
components/ui/button.tsxcomponents/ui/input.tsxcomponents/ui/card.tsxcomponents/ui/dialog.tsxcomponents/ui/badge.tsxcomponents/ui/simple-table.tsxcomponents/layout/app-shell.tsx
-
把
cn、buttonVariants、badgeVariants这类东西都放到统一命名空间 -
和你的 Tailwind theme / tokens 一起发布成 npm 包(私有或公开)
-
后续所有项目统一用同一套 UI 基础组件库,做到真正的 Design System
如果你愿意,我可以继续帮你:
- 把这些组件整理成一个完整的
ui目录结构清单 + barrel 文件 - 或者直接设计一套**“Birdor Admin UI Kit”**,按你现在的掌机 / Birdor 工具站 / Shunhei 品牌统一风格来定制 Tailwind 主题(颜色、圆角、阴影、排版),再配一套组件库代码骨架。