Chapter 04

表单组件

掌握 Button/Input/Select/Checkbox/Switch 等基础表单件,理解 cva 变体系统与 cn() 类名合并的设计哲学。

4.1 关键概念

variant(变体)
组件在同尺寸下的视觉风格分支,如 Button 的 default / destructive / outline / secondary / ghost / link。由 cva 声明。
size(尺寸)
另一维度的变体,控制高度/内边距,如 default / sm / lg / icon。variant 与 size 正交组合。
cva(class-variance-authority)
把 variant/size 映射到 Tailwind class 的声明式库,返回一个函数:传入变体名,产出合并好的类名字符串。
cn()
clsx + tailwind-merge,合并"组件内置类"与"外部传入 className",并对冲突的 Tailwind 类去重。
asChild 模式
基于 Radix Slot,让组件把样式"下发"给子元素而非自己渲染标签。<Button asChild><a>...</a></Button> 让链接长得像按钮。
受控 vs 非受控
输入类组件既可非受控(defaultValue)也可受控(value + onChange/onValueChange)。表单库场景通常受控。
Label 关联
Label 组件用 htmlFor 关联输入 id,点击 Label 聚焦输入,且被屏幕阅读器朗读,属可访问性刚需。

4.2 添加组件

$ npx shadcn@latest add button input label textarea \
    select checkbox radio-group switch

4.3 Button:变体全家桶

import { Button } from "@/components/ui/button";

<Button>Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>

// 尺寸
<Button size="sm">Small</Button>
<Button size="lg">Large</Button>
<Button size="icon"><Plus /></Button>

// asChild:让链接拥有按钮样式
<Button asChild>
  <a href="/login">去登录</a>
</Button>

4.4 用 cn() 追加自定义样式

// 外部 className 会被 cn() 合并,且冲突项以外部为准
<Button className="w-full mt-4">全宽按钮</Button>

// 条件类名
import { cn } from "@/lib/utils";
<Button className={cn("transition", isLoading && "opacity-60 pointer-events-none")}>
  {isLoading ? "提交中…" : "提交"}
</Button>
为什么需要 tailwind-merge

如果 Button 内置 px-4,你又传 px-8,普通字符串拼接会得到 "px-4 px-8"——CSS 里后者不一定赢。twMerge 智能识别同类冲突,只保留最后一个,结果确定为 px-8

4.5 Input 与 Label

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

<div className="grid gap-1.5">
  <Label htmlFor="email">邮箱</Label>
  <Input id="email" type="email" placeholder="you@example.com" />
</div>

4.6 Textarea

import { Textarea } from "@/components/ui/textarea";

<div className="grid gap-1.5">
  <Label htmlFor="bio">简介</Label>
  <Textarea id="bio" placeholder="介绍一下你自己" rows={4} />
</div>

4.7 Select(基于 Radix)

import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";

<Select onValueChange={(v) => console.log(v)}>
  <SelectTrigger className="w-[180px]">
    <SelectValue placeholder="选择框架" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="next">Next.js</SelectItem>
    <SelectItem value="vite">Vite</SelectItem>
    <SelectItem value="remix">Remix</SelectItem>
  </SelectContent>
</Select>
为什么不用原生 select

Radix 的 Select 用 div 重建下拉,可自由设计选项样式、支持图标/分组/滚动,同时保留键盘导航与 ARIA。原生 <select> 无法定制选项外观。

4.8 Checkbox / RadioGroup / Switch

import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";

// 复选框
<div className="flex items-center gap-2">
  <Checkbox id="terms" />
  <Label htmlFor="terms">同意条款</Label>
</div>

// 单选组
<RadioGroup defaultValue="card">
  <div className="flex items-center gap-2">
    <RadioGroupItem value="card" id="r1" /><Label htmlFor="r1">信用卡</Label>
  </div>
  <div className="flex items-center gap-2">
    <RadioGroupItem value="wechat" id="r2" /><Label htmlFor="r2">微信</Label>
  </div>
</RadioGroup>

// 开关(受控)
<div className="flex items-center gap-2">
  <Switch id="notify" checked={on} onCheckedChange={setOn} />
  <Label htmlFor="notify">开启通知</Label>
</div>

4.9 自定义一个新变体

因为代码是你的,加变体只是改文件。给 Button 加一个 success

// components/ui/button.tsx —— 在 variants.variant 里加一行
variant: {
  default: "bg-primary text-primary-foreground hover:bg-primary/90",
  success: "bg-green-600 text-white hover:bg-green-600/90",  // 新增
  // ...其余不变
}
// 立即可用:<Button variant="success">保存成功</Button>

4.10 表单基础对照

组件受控 prop回调底座
Input / TextareavalueonChange原生
SelectvalueonValueChangeRadix Select
CheckboxcheckedonCheckedChangeRadix Checkbox
RadioGroupvalueonValueChangeRadix RadioGroup
SwitchcheckedonCheckedChangeRadix Switch

4.11 小结与下一步