附录 B:Tailwind + shadcn/ui 最佳实践组件示例

B.0 基础工具:cn 函数(必须有) cn 是 shadcn/ui 的经典组合:clsx + tailwind-merge。 在所有组件里用于合并类名并解决冲突。 // src/lib/cn.ts import { type ClassValue, clsx } from "clsx" import …

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 库」:

  1. 建一个 packages/ui(monorepo)

  2. 把以上组件整理成:

    • components/ui/button.tsx
    • components/ui/input.tsx
    • components/ui/card.tsx
    • components/ui/dialog.tsx
    • components/ui/badge.tsx
    • components/ui/simple-table.tsx
    • components/layout/app-shell.tsx
  3. cnbuttonVariantsbadgeVariants 这类东西都放到统一命名空间

  4. 和你的 Tailwind theme / tokens 一起发布成 npm 包(私有或公开)

  5. 后续所有项目统一用同一套 UI 基础组件库,做到真正的 Design System

如果你愿意,我可以继续帮你:

  • 把这些组件整理成一个完整的 ui 目录结构清单 + barrel 文件
  • 或者直接设计一套**“Birdor Admin UI Kit”**,按你现在的掌机 / Birdor 工具站 / Shunhei 品牌统一风格来定制 Tailwind 主题(颜色、圆角、阴影、排版),再配一套组件库代码骨架。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页