shadcn/ui 的 Base 与 Radix 两套组件 API 差异实战:render、items 与 defaultValue 的正确写法
shadcn/ui 仓库在组件注册表中同时维护了基于 Base UI(base)与基于 Radix UI(radix)的两套并行组件实现,两者的组合方式(asChild vs render)、Select 的数据驱动模型、以及 defaultValue 的值类型均存在系统性差异。本文以仓库中的 base-vs-radix 规则文档 为主体,完整覆盖其全部差异点与正误代码对照,并结合 CLI 源码与注册表目录结构说明如何确认当前项目属于哪一套 API,从而避免在迁移或跨项目复用组件代码时写出编译不通过或行为不符预期的 JSX。
先确认你的项目用的是哪一套:npx shadcn info 的 base 字段
规则文档的开篇要求:先运行 npx shadcn@latest info,查看输出中的 base 字段,再决定按哪一套 API 编写代码。该字段由 CLI 的 info 命令 生成:
// packages/shadcn/src/commands/info.ts
const config = await getConfig(cwd)
const base = getBase(config?.style)
而 base 的取值逻辑实现在 getBase 函数:
// packages/shadcn/src/utils/get-config.ts
export function getBase(style: string | undefined): PresetBase {
// An undefined style means no existing config, so default to base.
// Any defined style, including empty and unprefixed legacy values
// (new-york, new-york-v4, default), stays radix.
if (style === undefined) {
return "base"
}
return parsePresetStyle(style).base ?? "radix"
}
从源码结构看,可以得出两条实用结论:
- 没有
components.json配置(全新项目)时默认归入base; - 任何已定义的 style(包括
new-york、new-york-v4等遗留值)都会停留在radix。
也就是说,base / radix 不是两个可自由混搭的组件库,而是同一项目必须整体选定的两套 API。仓库中这两套实现分别位于 apps/v4/registry/bases/base 与 apps/v4/registry/bases/radix 两个并行目录,注册表 README 明确要求:对任何共享表面(同样的预览块、同样的示例意图),变更应同时应用到两套变体,只调整必须不同的部分——导入路径(.../base/ui/... 与 .../radix/ui/...)和原语 API。这正解释了为什么本文列出的每一条差异都必须“成对记忆”:同名的 DialogTrigger、Select、Accordion 在两个目录下的 props 签名并不相同。
组合方式:radix 用 asChild,base 用 render
两套 API 最基础、也最普遍的区别在于“替换默认元素”的组合机制:Radix 使用 asChild 把默认元素替换为子元素;Base 使用 render prop 声明渲染成什么元素。无论哪一套,都不要用多余的包裹元素套住 trigger。
错误写法(两种体系都错):
<DialogTrigger>
<div>
<Button>Open</Button>
</div>
</DialogTrigger>
正确写法(radix):
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
正确写法(base):
<DialogTrigger render={<Button />}>Open</DialogTrigger>
注意两者子内容的语义差异:radix 的 asChild 是“用唯一子元素替换自身”,子元素即触发按钮;base 的 render 是“把自身渲染为指定元素”,触发文案作为 children 传入。这个差异在 base 注册表的组件源码中随处可见,例如 alert-dialog.tsx:
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn("cn-alert-dialog-cancel", className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
规则文档列出了该模式适用的全部 trigger 与 close 类组件:DialogTrigger、SheetTrigger、AlertDialogTrigger、DropdownMenuTrigger、PopoverTrigger、TooltipTrigger、CollapsibleTrigger、DialogClose、SheetClose、NavigationMenuLink、BreadcrumbLink、SidebarMenuButton、Badge、Item。
Button / trigger 渲染为非 button 元素:base 必须加 nativeButton={false}
当 render 把元素改成非按钮语义的元素(<a>、<span>)时,base 体系要求显式传入 nativeButton={false},否则 Base UI 仍会按原生按钮语义处理(如阻止链接默认行为、保留 button 的无障碍属性)。
错误(base)——缺少 nativeButton={false}:
<Button render={<a href="/docs" />}>Read the docs</Button>
正确(base):
<Button render={<a href="/docs" />} nativeButton={false}>
Read the docs
</Button>
等价写法(radix):
<Button asChild>
<a href="/docs">Read the docs</a>
</Button>
同样的规则适用于 render 不是 Button 的 trigger 组件:
// base.
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
Pick date
</PopoverTrigger>
判断口诀:只要渲染目标是 <a>、<span> 这类非 <button> 元素,base 体系就补一个 nativeButton={false};radix 体系没有这个 prop,用 asChild 即可。
Select 差异:items prop、placeholder 与定位
Select 是两套 API 差异最大的组件之一,共有四个差异点。
items prop:base 必须在根节点声明
base 体系要求根 Select 携带 items 数组(数据驱动),radix 体系只使用内联 JSX。
错误(base)——缺少 items:
<Select>
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
</Select>
正确(base):
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
]
<Select items={items}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
正确(radix)——纯内联 JSX:
<Select>
<SelectTrigger>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
placeholder:base 用 value: null 项,radix 用 placeholder prop
- base:在
items数组里放一个{ label: "...", value: null }项,<SelectValue />不再需要placeholder; - radix:
<SelectValue placeholder="Select a fruit" />。
迁移时的典型错误就是把 radix 的 placeholder="..." 原样带到 base 版本,结果占位文案不显示——base 的占位是数据,不是 prop。
内容定位:alignItemWithTrigger vs position
两套体系对浮层定位的 prop 命名不同:
// base.
<SelectContent alignItemWithTrigger={false} side="bottom">
// radix.
<SelectContent position="popper">
- base:
alignItemWithTrigger={false}表示列表不强制与选中项对齐; - radix:
position="popper"表示用 popper 定位(相对触发器),否则为item-aligned。
Select 多选与对象值:base 独有
base 体系支持 multiple、SelectValue 的渲染函数 children,以及配合 itemToStringValue 的对象值;radix 体系的 Select 只支持单选且值只能是字符串。这两组能力在跨体系迁移时没有对等物,需要改用 Combobox 等其他组件替代(radix 侧多选通常由 Combobox 承担)。
base——多选:
<Select items={items} multiple defaultValue={[]}>
<SelectTrigger>
<SelectValue>
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
</SelectValue>
</SelectTrigger>
...
</Select>
base——对象值:
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
<SelectTrigger>
<SelectValue>{(value) => value.name}</SelectValue>
</SelectTrigger>
...
</Select>
两个要点:itemToStringValue 负责把对象值序列化为字符串(用于内部状态与无障碍播报);SelectValue 的 children 作为渲染函数接收当前值,可渲染对象上的任意字段。radix 体系两者皆无,遇到对象值场景不要硬套 Select。
ToggleGroup:radix 用 type,base 用 multiple 布尔值
单选/多选的开关在两套体系中表达方式不同,且 base 的 defaultValue 永远是数组,radix 单选时是字符串。
错误(base)——误用 radix 的 type prop:
<ToggleGroup type="single" defaultValue="daily">
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
</ToggleGroup>
正确(base):
// Single (no prop needed), defaultValue is always an array.
<ToggleGroup defaultValue={["daily"]} spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup multiple>
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
正确(radix):
// Single, defaultValue is a string.
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup type="multiple">
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
对照总结:
| 能力 | base | radix |
|---|---|---|
| 单选 | 默认(无需 prop) | type="single" |
| 多选 | multiple |
type="multiple" |
defaultValue 类型 |
恒为数组 ["daily"] |
单选为字符串 "daily" |
受控单选值的差异最能体现“base 一切皆数组”的设计:
// base — wrap/unwrap arrays.
const [value, setValue] = React.useState("normal")
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
// radix — plain string.
const [value, setValue] = React.useState("normal")
<ToggleGroup type="single" value={value} onValueChange={setValue}>
base 侧需要在边界处做“数组包装 / 拆包”:value={[value]} 进、v[0] 出;radix 侧直接透传字符串。迁移受控组件时漏掉这层包装是典型故障点。
Slider:base 单滑块可传标量,radix 恒为数组
Base 对单滑块接受普通数字,Radix 一律要求数组(因为 Radix Slider 原生就是多滑块模型)。
错误(base)——给单滑块传数组:
<Slider defaultValue={[50]} max={100} step={1} />
正确(base):
<Slider defaultValue={50} max={100} step={1} />
正确(radix):
<Slider defaultValue={[50]} max={100} step={1} />
范围滑块(range slider)两边都用数组。受控的 onValueChange 在 base 下可能需要类型断言:
// base.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
// radix.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={setValue} />
base 中 onValueChange 的回调参数类型同时覆盖标量与数组两种形态,因此范围滑块场景需要 v as number[] 断言后写入 state。
Accordion:type / collapsible(radix)vs multiple(base)
Radix 要求显式 type="single" 或 type="multiple",并支持 collapsible(允许收起已展开项);defaultValue 是字符串。Base 没有 type prop,用 multiple 布尔值控制多选,defaultValue 永远是数组。
错误(base)——照搬 radix 的写法:
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
正确(base):
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
// Multi-select.
<Accordion multiple defaultValue={["item-1", "item-2"]}>
<AccordionItem value="item-1">...</AccordionItem>
<AccordionItem value="item-2">...</AccordionItem>
</Accordion>
正确(radix):
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
迁移与双目录维护:仓库给出的工程化支撑
上述逐组件差异之所以值得沉淀成规则文档,是因为仓库中存在大量从 radix 到 base 的存量迁移与并行维护工作。两点工程化事实可以作为佐证:
- 并行注册表结构:apps/v4/registry/bases 目录下
base/与radix/两套组件一一对应(如 base/ui/accordion.tsx 与 radix 侧同名文件),bases README 要求共享表面的变更同时落到两边,仅调整导入路径与原语 API。 - 迁移技能与映射表:仓库自带 migrate-radix-to-base 技能,并按组件类别拆分了 menus.md、overlays.md、form-controls.md 等映射文档,与 shadcn 技能规则目录 互为配套;CLI 侧也提供 migrate 命令 供项目执行迁移。
对开发者而言,日常只需记住规则文档的判定路径:先看 info 输出的 base 字段确定体系,再按对应一侧的写法实现组件;跨项目复制粘贴代码时,把 asChild / render、type / multiple、字符串 / 数组 defaultValue、placeholder / value: null 这四组差异当成必查清单。
速查表
| 场景 | base(Base UI) | radix(Radix UI) |
|---|---|---|
| 元素替换 | render={<Button />} |
asChild + 子元素 |
| 渲染为非 button 元素 | 追加 nativeButton={false} |
无需额外 prop |
| Select 选项 | 根节点 items 数组 |
内联 SelectItem JSX |
| Select 占位 | { label, value: null } 项 |
<SelectValue placeholder="..." /> |
| Select 定位 | alignItemWithTrigger |
position="popper" |
| Select 多选 / 对象值 | multiple、itemToStringValue、渲染函数 children |
不支持(仅单选字符串值) |
| ToggleGroup 多选 | multiple |
type="multiple" |
| ToggleGroup 单选 | 默认 | type="single" |
ToggleGroup defaultValue |
恒为数组 | 单选为字符串 |
| Slider 单滑块值 | 标量 50 |
数组 [50] |
| Accordion 类型 | 无 type,multiple 布尔 |
type="single" | "multiple",支持 collapsible |
Accordion defaultValue |
恒为数组 | 单选为字符串 |
参考依据:规则文档 base-vs-radix.md、info 命令实现、getBase 取值逻辑、注册表并行结构说明 以及 base 侧组件源码(如 alert-dialog.tsx 的 render 用法)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00