🧠 Headless / Unstyled 组件
一句话定性
Headless UI 是整部组件库史里最关键的一次范式转变:它把一个组件拆成两半——“行为 + 状态 + 无障碍(a11y)“(那个极难、极该外包的内核)和 “样式”(那个高度个性化、该归你的部分)——然后只交付前者,样式完全留白。它精准地回答了全包型组件库的两难:你终于可以只买最难的部分,而把品牌和设计的控制权 100% 拿回来。
一、它是什么 & 出现的时代
约 2019 年起,2018-2023 工程化时代的中后段,一批 Headless(无头)/ Unstyled(无样式) 组件库登场:
| 项目 | 出品方 | 特点 |
|---|---|---|
| Radix UI | WorkOS | React 无样式原语(primitives),a11y 标杆,后成为 shadcn 的底座 |
| Headless UI | Tailwind Labs(Tailwind CSS 团队) | 为配合 Tailwind 而生的无样式组件 |
| React Aria / React Stately | Adobe | 把行为(Aria)与状态(Stately)再拆开,框架无关的 a11y 内核 |
| TanStack(Table/Virtual/Query 等) | Tanner Linsley | ”Headless” 思路推广到表格、虚拟列表、数据层——只给逻辑,不给 UI |
它们的共同信条:组件库不该替你决定长什么样。
// Radix: 组件只提供"行为/状态/无障碍"结构, 没有任何视觉样式
// 样式完全由你用 Tailwind / CSS-in-JS 注入
import * as Dialog from '@radix-ui/react-dialog';
<Dialog.Root>
<Dialog.Trigger className="px-4 py-2 rounded bg-black text-white">打开</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6">
{/* 焦点陷阱、Esc 关闭、aria-* 朗读、点击外部关闭, Radix 全包了; 样式全是你的 */}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>二、为什么会出现(解决上一代什么痛点)
全包型组件库把两个本应分开的诉求焊死了
Element 给你完整组件,但带来一道无法拆开的焊缝:
- 你想要它极难自己写的行为和 a11y → 必须连它的整套内置样式一起吃下;
- 你想要自己的设计 / Design System → 又要和它的内置样式做无尽的覆盖斗争(
!important、:deep()、追内部 className、改 Less 变量……),脆弱且升级即崩。“我只想要逻辑,不想要你的皮”——这个朴素诉求,在全包模型下无法满足。
而焊缝的两半,难度天差地别。这要从一个被长期低估的事实说起:
真正难的,从来不是样式,而是 a11y 和行为
自己写一个生产级的下拉/弹窗/组合框,要正确处理:
- 焦点管理:focus trap、关闭后焦点归位、Tab 顺序;
- 键盘交互:↑↓ 导航、Enter 选择、Esc 关闭、Home/End、type-ahead 首字母跳转;
- WAI-ARIA:正确的
role、aria-expanded、aria-activedescendant、aria-controls,让屏幕阅读器朗读正确;- 边界:点击外部关闭、屏幕边缘翻转定位、滚动锁定、SSR 兼容……
这些几乎没人能凭一己之力写对,且踩坑无数。它才是组件库真正的价值内核——也正是最该被专业项目托管、最该外包的部分。
Headless 的洞察(一次漂亮的第一性原理):把这个最难的内核单独抽出来交付,样式这种高度个性化、人人想自己掌控的部分,本就不该由组件库决定。 于是——解耦。
它的出现还踩中了一个关键时代红利:Tailwind CSS 的崛起。当样式可以用 className 原子类极其顺手地就地拼出来,“组件不带样式”从”麻烦”变成了”自由”——你不再需要为每个组件写 CSS 文件,直接在 Headless 组件的 className 上拼 Tailwind 类即可。Headless + Tailwind 是天作之合,这也解释了为什么 Headless UI 干脆就是 Tailwind 团队出品。
三、核心机制 & 为什么流行
- 行为与样式解耦(核心):组件只暴露结构、状态和无障碍语义(
Dialog.Trigger、Select.Item、isOpen、aria-*),零视觉样式。样式由你通过className/style/ CSS-in-JS 注入。 - a11y 默认正确:键盘、焦点、ARIA 这些”做对极难”的事,开箱即对。这是 Headless 最硬的卖点。
- 可组合的原语(primitives):Radix 提供的是细粒度可组合的零件(Root/Trigger/Content/Portal…),而非铁板一块的成品组件——你按需拼装。
- 逻辑与渲染分离的更激进形态:React Aria 把”行为(Aria hooks)“与”状态(Stately)“进一步拆开,做成框架无关的逻辑层;TanStack 用 headless hooks 交付表格/虚拟滚动/数据请求的纯逻辑,UI 完全交给你——“Headless”从组件扩展成一种通用设计哲学。
为什么这是"关键范式转变"
四、带来的新问题 / 副作用
自由的代价
- 样式要从零写:Headless 不给样式,意味着每个组件的视觉都得你自己出。对”只想快速搭个后台”的场景,这是负生产力——你又退回了 Bootstrap/AntD 想解决的”做页面要自己写样式”的起点(只不过这次你保留了 a11y)。
- 强依赖 Tailwind / 设计能力:Headless 的体验高度绑定 Tailwind 与一定的设计素养;团队没有设计资源时,产出可能还不如直接用 AntD 整齐。
- 拼装心智成本:Radix 的可组合原语很灵活,但要理解 Root/Trigger/Portal/Content 的组合方式,比
<Modal open />一行多了学习曲线。- “还差最后一公里”:你想要的其实是”带了合理默认样式、但能随便改”的组件——纯 Headless 太裸,全包组件库太死。这个中间地带的空缺,正是 shadcn 要填的。
五、为什么会衰落 / 现状
Headless 没有衰落,正当红,且已成为现代组件生态的底层基础设施:
- Radix 成为事实标准的无样式原语层,shadcn-ui、众多设计系统都建立其上;
- React Aria 是 a11y 领域的标杆,被越来越多严肃产品采用;
- TanStack 系列把 headless 哲学带到表格/虚拟列表/数据层,广受欢迎。
它真正的”现状”,是被 shadcn-ui 推上了大众舞台——shadcn 用 “Radix(行为)+ Tailwind(样式)+ 复制源码进项目” 的组合,把 Headless 那”还差的最后一公里”补齐:既有合理的默认样式可直接用,又因为源码归你而可以随便改。 可以说,Headless 是引擎,shadcn 是把引擎装进了人人会开的车。
六、对后续技术的影响(因果链)
全包型组件库 (AntD/MUI/Element): 样式覆盖难 + "想要行为却被迫连样式一起吃" (焊缝)
│ + 关键事实: 真正难的是 a11y/行为, 不是样式
│ + 时代红利: [[原子化CSS|Tailwind]] 崛起 → "组件不带样式"从麻烦变自由
↓
Headless / Unstyled (2019+): 解耦! 只交付"行为+状态+a11y", 样式 100% 留白
Radix(WorkOS) / Headless UI(Tailwind Labs) / React Aria(Adobe) / TanStack
│
├─► 重塑责任边界: 该外包的(a11y内核)外包, 该收回的(样式/品牌)收回
│
├─► 副作用: 样式要从零写, 强依赖 Tailwind + 设计能力 ("最后一公里"空缺)
│ │
│ ↓ 谁来补"既有默认样式又能随便改"的中间地带?
│ [[shadcn-ui]] (2023) = Radix(行为) + Tailwind(样式) + 源码复制进项目
│
└─► "Headless"升华为通用哲学: 逻辑/渲染分离 ──► TanStack 推广到数据/表格层
──► React Aria 框架无关的 a11y 内核
历史地位
🔗 本组:UI组件库演进史 | Bootstrap | 组件库时代-AntD-MUI-Element | shadcn-ui 🔗 时代:2018-2023 工程化时代 | 2023-未来 AI时代 🔗 框架:React | Vue 🔗 样式:原子化CSS | CSS-in-JS | CSS演进史