第三章:Tailwind CSS + shadcn/ui——高效 UI 开发

第三章:Tailwind CSS + shadcn/ui——高效 UI 开发

shadcn/ui 不是一个组件库,而是一个"组件源码集合"。你把源码复制进项目,然后完全拥有它——可以随意修改,不被任何第三方库版本锁死。配合 Tailwind CSS,是 2024 年最主流的 SaaS UI 开发方式。


一、Tailwind CSS 核心哲学

<!-- 传统 CSS:写 class 然后写样式 -->
<button class="btn-primary">提交</button>

/* CSS */
.btn-primary {
  background-color: #3b82f6;
  color: white;
  padding: 8px 16px;
  border-radius: 6px;
}

<!-- Tailwind:直接在 class 中写样式(Utility-first) -->
<button class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors">
  提交
</button>

为什么 Tailwind 更适合 SaaS 开发

  • 不用在 HTML 和 CSS 文件之间来回切换
  • 样式和组件强绑定,删组件不会留 CSS “孤儿”
  • 响应式前缀(md:, lg:)让布局更直观
  • Dark mode 前缀(dark:)一行搞定暗黑模式
// 响应式示例
<div className="
  grid 
  grid-cols-1        // 手机:1列
  md:grid-cols-2     // 平板:2列
  lg:grid-cols-3     // 桌面:3列
  gap-4
">
  {/* 内容 */}
</div>

// 暗黑模式示例
<div className="
  bg-white text-gray-900    // 亮色模式
  dark:bg-gray-900 dark:text-white  // 暗色模式
  p-6 rounded-lg
">

二、shadcn/ui 安装与使用

# 初始化(在 Next.js 项目中)
npx shadcn-ui@latest init

# 添加组件(只下载你需要的)
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add input
npx shadcn-ui@latest add form
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add table
npx shadcn-ui@latest add toast

# 组件会下载到 components/ui/ 目录
# 你完全拥有这些代码,可以随意修改!
// components/ui/button.tsx(shadcn/ui 生成,可修改)
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const buttonVariants = cva(
  // 基础样式(所有按钮共有)
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline: "border border-input hover:bg-accent hover:text-accent-foreground",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "underline-offset-4 hover:underline text-primary",
      },
      size: {
        default: "h-10 py-2 px-4",
        sm: "h-9 px-3 rounded-md",
        lg: "h-11 px-8 rounded-md",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
  VariantProps<typeof buttonVariants> {}

export function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

三、Landing Page 实战

// app/(marketing)/page.tsx - Landing Page

import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Check, Zap, Shield, BarChart3 } from 'lucide-react'

// Hero Section
function Hero() {
  return (
    <section className="py-24 px-4 text-center max-w-4xl mx-auto">
      <Badge variant="outline" className="mb-4">
        🚀 刚刚发布 v2.0
      </Badge>
      
      <h1 className="text-5xl font-bold tracking-tight text-gray-900 dark:text-white mb-6">
        用 AI 提升你的
        <span className="text-blue-600"> 工作效率</span>
      </h1>
      
      <p className="text-xl text-gray-600 dark:text-gray-400 mb-8 max-w-2xl mx-auto">
        面向开发者的 AI 工具平台。从代码审查到文档生成,
        让 AI 成为你最可靠的工作伙伴。
      </p>
      
      <div className="flex gap-4 justify-center">
        <Button size="lg" asChild>
          <Link href="/register">免费开始使用</Link>
        </Button>
        <Button size="lg" variant="outline" asChild>
          <Link href="#demo">查看演示</Link>
        </Button>
      </div>
      
      {/* 信任信号 */}
      <p className="text-sm text-gray-500 mt-4">
        已有 2,000+ 开发者使用 · 不需要信用卡
      </p>
    </section>
  )
}

// Features Section
const features = [
  {
    icon: Zap,
    title: "极速响应",
    description: "平均响应时间 < 200ms,不让 AI 拖慢你的工作流",
  },
  {
    icon: Shield,
    title: "数据安全",
    description: "你的代码永远不会被用来训练模型,SOC 2 认证",
  },
  {
    icon: BarChart3,
    title: "使用分析",
    description: "详细的 token 使用统计,优化你的 AI 成本",
  },
]

function Features() {
  return (
    <section className="py-24 bg-gray-50 dark:bg-gray-900">
      <div className="max-w-6xl mx-auto px-4">
        <h2 className="text-3xl font-bold text-center mb-12">
          为开发者设计的每一个细节
        </h2>
        
        <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
          {features.map((feature) => (
            <div
              key={feature.title}
              className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border"
            >
              <feature.icon className="h-8 w-8 text-blue-600 mb-4" />
              <h3 className="text-lg font-semibold mb-2">{feature.title}</h3>
              <p className="text-gray-600 dark:text-gray-400">{feature.description}</p>
            </div>
          ))}
        </div>
      </div>
    </section>
  )
}

// Pricing Section
const plans = [
  {
    name: "Free",
    price: "$0",
    period: "/月",
    description: "适合个人探索",
    features: ["100,000 tokens/月", "5 个项目", "邮件支持"],
    cta: "开始使用",
    href: "/register",
    highlighted: false,
  },
  {
    name: "Pro",
    price: "$29",
    period: "/月",
    description: "适合专业开发者",
    features: ["无限 tokens", "无限项目", "优先支持", "API 访问", "团队共享"],
    cta: "开始 14 天试用",
    href: "/register?plan=pro",
    highlighted: true,  // 突出显示
  },
  {
    name: "Team",
    price: "$99",
    period: "/月",
    description: "适合开发团队",
    features: ["10 个团队成员", "SSO 支持", "审计日志", "专属支持", "SLA 保证"],
    cta: "联系我们",
    href: "/contact",
    highlighted: false,
  },
]

function Pricing() {
  return (
    <section className="py-24 px-4" id="pricing">
      <div className="max-w-5xl mx-auto">
        <h2 className="text-3xl font-bold text-center mb-4">简单透明的定价</h2>
        <p className="text-center text-gray-600 mb-12">随时可以升级或取消</p>
        
        <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
          {plans.map((plan) => (
            <div
              key={plan.name}
              className={`rounded-xl p-8 border ${
                plan.highlighted
                  ? 'border-blue-600 bg-blue-50 dark:bg-blue-950 ring-2 ring-blue-600'
                  : 'border-gray-200 dark:border-gray-700'
              }`}
            >
              {plan.highlighted && (
                <Badge className="mb-4 bg-blue-600">最受欢迎</Badge>
              )}
              
              <h3 className="text-xl font-bold">{plan.name}</h3>
              <div className="mt-2">
                <span className="text-4xl font-bold">{plan.price}</span>
                <span className="text-gray-600">{plan.period}</span>
              </div>
              <p className="text-gray-600 mt-2 mb-6">{plan.description}</p>
              
              <ul className="space-y-3 mb-8">
                {plan.features.map((feature) => (
                  <li key={feature} className="flex items-center gap-2">
                    <Check className="h-4 w-4 text-green-600 flex-shrink-0" />
                    <span className="text-sm">{feature}</span>
                  </li>
                ))}
              </ul>
              
              <Button
                className="w-full"
                variant={plan.highlighted ? "default" : "outline"}
                asChild
              >
                <Link href={plan.href}>{plan.cta}</Link>
              </Button>
            </div>
          ))}
        </div>
      </div>
    </section>
  )
}

// 主页面
export default function LandingPage() {
  return (
    <main>
      <Hero />
      <Features />
      <Pricing />
    </main>
  )
}

四、Dashboard 布局

// app/(dashboard)/layout.tsx
import Link from 'next/link'
import { LayoutDashboard, Settings, CreditCard, Code } from 'lucide-react'
import { getCurrentUser } from '@/lib/auth'
import { UserMenu } from '@/components/user-menu'

const navItems = [
  { href: '/dashboard', icon: LayoutDashboard, label: '概览' },
  { href: '/dashboard/projects', icon: Code, label: '项目' },
  { href: '/dashboard/billing', icon: CreditCard, label: '账单' },
  { href: '/dashboard/settings', icon: Settings, label: '设置' },
]

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const user = await getCurrentUser()
  
  return (
    <div className="flex h-screen bg-gray-100 dark:bg-gray-900">
      {/* 侧边栏 */}
      <aside className="w-64 bg-white dark:bg-gray-800 border-r flex flex-col">
        {/* Logo */}
        <div className="p-6 border-b">
          <Link href="/dashboard" className="text-xl font-bold text-blue-600">
            YourSaaS
          </Link>
        </div>
        
        {/* 导航 */}
        <nav className="flex-1 p-4 space-y-1">
          {navItems.map((item) => (
            <Link
              key={item.href}
              href={item.href}
              className="flex items-center gap-3 px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
            >
              <item.icon className="h-5 w-5" />
              {item.label}
            </Link>
          ))}
        </nav>
        
        {/* 用户信息 */}
        <div className="p-4 border-t">
          <UserMenu user={user} />
        </div>
      </aside>
      
      {/* 主内容区 */}
      <main className="flex-1 overflow-auto p-8">
        {children}
      </main>
    </div>
  )
}

五、暗黑模式实现

# 安装 next-themes
npm install next-themes
// components/theme-provider.tsx
"use client"

import { ThemeProvider as NextThemesProvider } from "next-themes"

export function ThemeProvider({ children, ...props }: any) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh" suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}
// components/theme-toggle.tsx
"use client"

import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"

export function ThemeToggle() {
  const { setTheme, theme } = useTheme()
  
  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === "light" ? "dark" : "light")}
    >
      <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
    </Button>
  )
}

关键认知

shadcn/ui 的哲学比技术本身更重要

传统组件库(Ant Design / MUI):组件代码在 node_modules,你只是调用者,遇到奇怪 bug 或需要深度定制时,要么 hack CSS,要么 Fork 项目。

shadcn/ui:组件代码直接在你的项目里,你是所有者。版本升级是主动选择,不会被动被 breaking change 影响。

Landing Page 转化率核心要素

  1. 标题说清楚"对谁有什么价值"(< 10 字)
  2. 副标题说清楚"怎么实现"(< 30 字)
  3. 两个 CTA:主要(立即开始)+ 次要(了解更多)
  4. 社会证明(用户数量/公司 Logo)放在 Fold 以上

“UI 是产品的第一印象。用 shadcn/ui + Tailwind,你可以在 1 天内搭出看起来专业的 SaaS 界面。剩下的 99% 价值来自产品功能和用户体验,而不是 UI 库的选择。”