无头组件系统 · 新资源

Headless UI 设计系统.

Headless UI 提供未设样式的 React 交互组件,适用于对话框、列表框和开关。本指南以文档共享流程为例,介绍如何将这些组件原生的焦点、选择和弹出行为与常规 CSS 组合使用。

OpenDesign 指南9 个章节27 条参考条目原始应用指南
01

Headless UI 设计系统界面预览

打开共享对话框,验证电子邮件,使用键盘选择权限,添加本地邀请,并切换公开链接状态。

使用原生无头基础组件共享文档打开完整示例
使用原生 @headlessui/react 2.2.10 和 React 19。所有颜色、字体、间距和布局均为原创示例 CSS,并非官方 Headless UI 令牌。· 实现参考
02

Headless UI 设计系统设计参考

Headless UI 没有官方视觉令牌比例。以下所有数值和颜色均属于本 OpenDesign 示例;链接文档介绍了行为和状态 API。已测试的软件包:@headlessui/react 2.2.10。

原创 CSS 颜色角色

来源
名称 / 规则值 / 配置用途
--canvas#f5f5f4 文档工作区。
--surface#ffffff 纸张、访问权限面板和对话框。
--ink#1c1917 主文本。
--muted#57534e 次要文案。
--line#d6d3d1 面板和输入框边框。
--accent#4338ca 主要操作和焦点轮廓。
--accent-soft#eef2ff 聚焦选项和头像背景。
--error#b91c1c 无效电子邮件的文字和边框。

原创间距与几何尺寸

来源
名称 / 规则值 / 配置用途
--space-1 / --space-24px / 8px微小间距。
--space-3 / --space-412px / 16px控制间隙和移动端内边距。
--space-6 / --space-824px / 32px面板内边距和主要间隔。
--radius-control8px按钮和输入框。
--radius-panel16px纸张和对话框。
.dialog-panel widthmin(100%, 460px)窄屏上的有界覆盖层。
.dialog-panel max-heightcalc(100% - 16px)在固定定位容器内部留出空间。

原有字体和响应式设置

来源
名称 / 规则值 / 配置用途
Body16px / 1.5 system-ui无需外部字体。
Screen title28px / 1.25文档标识。
Document headline32px; 26px below 700px编辑内容层级。
Dialog heading20px / 1.3说明分享任务。
Labels and helper copy14px字段上下文。
Error and access description13px持续反馈。
Breakpoint700px文档面板和访问权限面板纵向排列。

原生交互约定

来源
名称 / 规则值 / 配置用途
Dialog open / onCloseControlled boolean / dismissal callback按 Escape 键或点击外部区域会请求关闭,不会保存。
DialogPanelContent boundary背景遮罩和定位容器位于此边界之外。
Listbox value / onChangeControlled permission string键盘选择会更新已选权限。
data-focus / data-selectedNative state attributesCSS 将键盘焦点与已确认的选择区分开来。
Switch checked / onChangeControlled booleandata-checked 用于设置其视觉状态。
03

Headless UI 设计系统完整指南

实用指南,介绍如何为真实界面选择、应用和评审此系统。

DESIGN-headless-ui.md

完整指导、实现起点和源代码链接。

下载指南

Headless UI 设计系统:先组合行为,再设计屏幕样式

先组合行为,再设计屏幕样式

Dialog、Listbox 和 Switch 分别对应不同的交互。Dialog 用于处理有界任务及其焦点上下文;Listbox 用于表示一项已确认的选择;Switch 用于表示独立的布尔设置。组合这些组件,并不意味着要合并它们的状态:邀请草稿、所选权限和公开链接设置仍各自独立。

摘自本 OpenDesign 指南

此示例使用 React 组件 API,并直接导入 DialogPanel 和 ListboxButton 等组件。其对话框中包含一个 listbox,因此弹出层的定位和关闭行为必须能在已有覆盖层中正常运作。请先搭建这一组合,再在其周围添加文档面板和访问权限摘要。提供的 CSS 为这些原生组件完整呈现了一种外观。

Headless UI 设计系统:Dialog、Listbox 和 Switch 的结构

明确划分覆盖层边界

DialogBackdrop 是固定定位容器的同级元素。DialogPanel 是该容器内有界的白色面板,不会铺满整个视口。DialogTitle 用于说明任务,Description 则解释关闭对话框后的行为。原生输入框带有 data-autofocus,Headless UI 会管理对话框外围的焦点行为。

阅读配套规范

listbox 用于选择值;switch 用于切换布尔值

ListboxButton 显示所选角色。ListboxOptions 使用 anchor="bottom",并根据 --button-width 设置宽度,让弹出层与触发器对齐。每个 ListboxOption 都有稳定的字符串值。原生的 data-focus 和 data-selected 属性分别控制两种不同的 CSS 样式。

公开链接 Switch 位于包含 Label 的 Field 中,而不是一个没有标签的装饰性药丸按钮中。其选中状态会同时改变可访问控制项和可见的说明文字。本地邀请与公开链接设置相互独立。

阅读配套规范

Headless UI 设计系统:分开处理选择、关闭和本地确认

此处采用的状态模型

打开时会初始化一份新的收件人草稿,并将角色设为“可查看”。点击取消、按 Escape 键或点击外部区域都会关闭对话框;这些操作都不会添加收件人。提交时会验证电子邮件地址、拒绝已存在的地址,然后将邀请添加到内存中并关闭对话框。确认信息会明确说明没有发送电子邮件。

权限 listbox 更改的是草稿角色,不会影响现有收件人的访问权限。公开链接状态则会更改另一个独立的布尔值。这样可以避免常见错误:将每次关闭操作都视为权限已保存。

  • 输入无效:保持对话框打开,并将错误信息与电子邮件字段关联。
  • 收件人重复:保持现有访问权限列表不变。
  • 本地邀请已接受:更新列表,并播报仅供预览的结果。

Headless UI 设计系统:分别设置选择样式和键盘焦点样式

分别设置选择样式和键盘焦点样式

ListboxOption 会通过 data-focus 和 data-selected 表示不同状态。获得焦点的选项使用浅色强调样式,而已确认的权限则显示勾选标记。这样,键盘用户可以在浏览其他选项时,仍清楚地知道当前选择是什么。

同样的区分也适用于弹出层之外:错误角色用于无效的收件人字段,而 Switch 的选中样式表示公开链接的访问状态。请将 CSS 限定在其所描述的组件状态范围内。若使用宽泛的选择器,为所有获得焦点或选中的元素应用相同颜色,就会模糊这些不同的含义。

css
:root { --accent: #4338ca; --accent-soft: #eef2ff; --error: #b91c1c; }
.role-option[data-focus] { background: var(--accent-soft); }
.role-option[data-selected] .selected { visibility: visible; }

代码参考

Headless UI 设计系统:让文档标识和权限清晰易读

可复现的字体设置

文档标题为 28px,行高为 1.25;对话框标题为 20px,行高为 1.3。正文使用平台系统字体,字号为 16px,行高为 1.5。字段标签和辅助说明的字号为 14px。这些具体数值都在 style.css 中,读者无需从截图中推测。

访问权限列表中的电子邮件地址使用 overflow-wrap:anywhere,避免地址撑宽所在列。权限显示在单独一行。添加邀请时,对话框中会完整显示文档标题和所选权限。

Headless UI 设计系统:完整的覆盖层定位与响应式 CSS

布局不依赖前置条件

提供的样式表包含完整的覆盖层设置:固定内边距、背景遮罩不透明度、z-index、最大宽度、纵向滚动和内边距。视口宽度低于 700px 时,文档面板会纵向排列,对话框也会缩小内边距。此方案不依赖 Tailwind 构建。

请将定位容器与 DialogPanel 区分开来。容器覆盖可用视口并提供边缘内边距;面板则提供有界的内容表面。对于内容较长的对话框,应设置可滚动区域,确保收件人字段、权限选项和关闭操作在窄屏上仍可访问。

css
.dialog-position { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; padding: 16px; overflow-y: auto; }
.dialog-panel { width: min(100%, 460px); max-height: calc(100% - 16px); overflow-y: auto; }

代码参考

Headless UI 设计系统:使用原生状态和焦点行为

键盘验证

打开对话框,确认初始焦点位于收件人字段。按 Tab 键移动到权限触发器;使用方向键和 Enter 键选择角色。按 Escape 键会根据当前焦点上下文,关闭活动的弹出层或对话框。关闭对话框后,焦点应返回分享触发器。

邮箱输入框使用 aria-invalid 和 aria-describedby 标记自定义错误。选项中的勾号仅作装饰,因为 Listbox 已提供选择语义。公开链接控件带有可见标签,切换后说明文字仍会保留。启用减少动态效果的 CSS 设置后,装饰性的开关滑块过渡效果会被移除。

阅读配套规范

Headless UI 设计系统:始终一致地使用一代 React API

始终一致地使用一代 React API

完整的 React 条目会挂载 App,其样式表负责提供遮罩层几何布局和状态选择器。在经过验证的 2.2.10 示例中,DialogPanel、DialogBackdrop、ListboxButton 和 ListboxOptions 均为直接导入。应有计划地迁移旧版复合组件写法,不要将其与此组合方式混用。

ListboxOptions 使用 anchor="bottom" 和原生的 --button-width 变量,使选项列表与触发器对齐。这些细节属于集成方案的一部分,并非可有可无的装饰:移除定位容器,或将宽度替换为无关的固定值,都可能导致嵌套弹出层失效,即使字段收起时看起来仍然正常。

  1. 在 React 项目中安装 @headlessui/react。
  2. 从示例中保存 App.jsx 和 style.css,然后按示例所示替换 main.jsx。
  3. 运行开发服务器,并测试对话框关闭、权限选择和重复邮箱处理。

Headless UI 设计系统:检查实际的权限交互

验收条件

有效的评审应从一份文档和一位现有收件人开始,而不是从空白组件图库开始。添加一条有效邀请,并将权限设为“可评论”,然后重新打开并尝试使用相同的邮箱。确认原有条目仍然保留,且重复邮箱会触发明确的错误提示。开启公开链接,并检查其独立说明。

此示例仅演示本地 UI 状态,不提供收件人搜索或分享后端。接入实际服务需要为待处理和错误响应建立状态模型,并明确制定并发权限变更的处理规则;原生基础组件不会替你决定授权规则。

Headless UI 设计系统组件示例

App.jsx — 原生分享组件组合

完整演示受控 Dialog、权限 Listbox、带标签的 Switch 和本地验证。

jsx
import React, { useState } from 'react';
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle, Description, Listbox, ListboxButton, ListboxOptions, ListboxOption, Switch, Field, Label } from '@headlessui/react';
import './style.css';
const roles = ['Can view', 'Can comment', 'Can edit'];
export default function App() {
 const [open,setOpen]=useState(false),[role,setRole]=useState(roles[0]),[publicLink,setPublicLink]=useState(false),[email,setEmail]=useState(''),[error,setError]=useState(''),[notice,setNotice]=useState('');
 const [people,setPeople]=useState([{email:'[email protected]',role:'Can edit'}]);
 const start=()=>{setEmail('');setError('');setRole(roles[0]);setOpen(true)};
 const submit=e=>{e.preventDefault();if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)){setError('Enter a complete email address.');return;}if(people.some(p=>p.email===email)){setError('This person already appears in the access list.');return;}setPeople([...people,{email,role}]);setOpen(false);setNotice('Invitation added to this local example only. No email was sent.');};
 return <main className='sharing-shell'>
  <header><div><span className='eyebrow'>HEADLESS UI / CUSTOM SURFACE</span><h1>Quarterly brief</h1><p className='muted'>Research · Updated today</p></div><button className='primary' onClick={start}>Share document</button></header>
  <div className='document-grid'><article className='paper'><span className='tag'>WORKING DOCUMENT</span><h2>Make the next decision clearer.</h2><p>Three research priorities for the coming quarter, shared with the people who need to review them.</p><div className='rule'/><h3>01 / Understand the handoff</h3><p>Compare where context is lost between research notes and the next product decision.</p><h3>02 / Review access deliberately</h3><p>Keep editing rights separate from the ability to read a document.</p></article>
  <aside className='access'><h2>People with access</h2><p className='muted'>The list below is local demonstration state.</p>{people.map((p,i)=><div className='person' key={p.email}><span className='avatar'>{i===0?'AK':'+'}</span><div><strong>{p.email}</strong><small>{p.role}</small></div></div>)}<Field className='link-setting'><Label>Public link</Label><Switch checked={publicLink} onChange={setPublicLink} className='switch'><span /></Switch></Field><p className='link-detail'>{publicLink?'Anyone with the example link could view this document. This changes only the local preview.':'Restricted to the named people in this example.'}</p><p role='status' className='notice'>{notice}</p></aside></div>
  <Dialog open={open} onClose={()=>setOpen(false)} className='dialog-root'><DialogBackdrop className='backdrop'/><div className='dialog-position'><DialogPanel className='dialog-panel'><DialogTitle as='h2'>Share quarterly brief</DialogTitle><Description className='muted'>Choose one recipient and a permission. Closing discards this invitation draft.</Description><form noValidate onSubmit={submit}><label className='field-label' htmlFor='recipient'>Recipient email</label><input id='recipient' data-autofocus type='email' value={email} onChange={e=>{setEmail(e.target.value);setError('')}} aria-invalid={!!error} aria-describedby={error?'email-error':undefined} placeholder='[email protected]'/>{error&&<p id='email-error' role='alert' className='error'>{error}</p>}<Listbox value={role} onChange={setRole}><Label className='field-label'>Permission</Label><ListboxButton className='role-button'>{role}<span aria-hidden='true'>⌄</span></ListboxButton><ListboxOptions anchor='bottom' className='role-options'>{roles.map(value=><ListboxOption key={value} value={value} className='role-option'><span>{value}</span><span className='selected' aria-hidden='true'>✓</span></ListboxOption>)}</ListboxOptions></Listbox><p className='muted permission-help'>{role==='Can edit'?'May change document content in this example.':role==='Can comment'?'May add comments without editing content.':'May read the document without changing it.'}</p><div className='dialog-actions'><button type='button' onClick={()=>setOpen(false)}>Cancel</button><button className='primary' type='submit'>Add invitation</button></div></form></DialogPanel></div></Dialog>
  <footer>Native Dialog, Listbox, and Switch behavior. Original CSS below supplies all visual values; Headless UI does not ship this palette.</footer>
 </main>;
}

代码参考

  • 不会发送邮箱或请求。
  • 按 Escape 或点击取消会丢弃未保存的邀请。

style.css — 完整的原始视觉系统

App.jsx 所需的全部视觉令牌、状态选择器、遮罩层位置和响应式规则。这些是示例中的选择,并非 Headless UI 官方默认值。

css
:root{--canvas:#f5f5f4;--surface:#fff;--ink:#1c1917;--muted:#57534e;--line:#d6d3d1;--accent:#4338ca;--accent-soft:#eef2ff;--error:#b91c1c;--space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px;--space-6:24px;--space-8:32px;--radius-control:8px;--radius-panel:16px;font:16px/1.5 system-ui,sans-serif;color:var(--ink);background:var(--canvas)}*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:var(--radius-control);padding:10px 16px;background:var(--surface);color:var(--ink);font-weight:600}button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.primary{background:var(--accent);color:white;border-color:var(--accent)}.sharing-shell{max-width:1120px;margin:auto;padding:32px}.sharing-shell>header{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:32px}.eyebrow{font-size:11px;letter-spacing:.1em;color:var(--accent);font-weight:700}h1{font-size:28px;line-height:1.25;margin:8px 0}h2{font-size:20px;line-height:1.3;margin:0 0 12px}h3{font-size:16px;margin:24px 0 8px}p{margin:0 0 16px}.muted{color:var(--muted);font-size:14px}.document-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(260px,1fr);gap:24px}.paper,.access{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius-panel);padding:32px}.paper h2{font-size:32px;max-width:15ch;margin-top:24px}.paper p{max-width:48ch}.tag{font-size:11px;font-weight:600;border:1px solid var(--line);padding:4px 8px;border-radius:4px}.rule{height:1px;background:var(--line);margin:24px 0}.person{display:flex;gap:12px;padding:16px 0;border-bottom:1px solid var(--line)}.person strong{display:block;font-size:14px;overflow-wrap:anywhere}.person small{color:var(--muted)}.avatar{flex:none;width:32px;height:32px;border-radius:50%;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-size:12px}.link-setting{display:flex;align-items:center;justify-content:space-between;margin-top:24px;font-weight:600}.switch{padding:3px;width:44px;height:26px;border:0;border-radius:999px;background:#a8a29e}.switch span{display:block;width:20px;height:20px;border-radius:50%;background:white;transition:transform .15s}.switch[data-checked]{background:var(--accent)}.switch[data-checked] span{transform:translateX(18px)}.link-detail{margin-top:12px;font-size:13px;color:var(--muted)}.notice{font-size:13px;color:var(--accent)}footer{font-size:12px;color:var(--muted);margin-top:24px}.dialog-root{position:fixed;inset:0;z-index:20}.backdrop{position:fixed;inset:0;background:rgb(28 25 23/.45)}.dialog-position{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto}.dialog-panel{width:min(100%,460px);max-height:calc(100% - 16px);overflow-y:auto;background:var(--surface);padding:32px;border-radius:var(--radius-panel);box-shadow:0 24px 64px rgb(0 0 0/.22)}.field-label{display:block;font-size:14px;font-weight:600;margin:20px 0 8px}input[type=email]{display:block;width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:var(--radius-control);background:white;color:var(--ink)}input[aria-invalid=true]{border-color:var(--error)}.error{font-size:13px;color:var(--error);margin:8px 0}.role-button{display:flex;justify-content:space-between;width:100%;text-align:left}.role-options{z-index:30;width:var(--button-width);--anchor-gap:4px;background:var(--surface);border:1px solid var(--line);padding:4px;border-radius:8px;box-shadow:0 8px 24px rgb(0 0 0/.12);outline:none}.role-option{display:flex;justify-content:space-between;padding:10px;border-radius:4px;cursor:pointer;font-size:14px}.role-option[data-focus]{background:var(--accent-soft)}.role-option .selected{visibility:hidden;color:var(--accent)}.role-option[data-selected] .selected{visibility:visible}.permission-help{margin-top:12px}.dialog-actions{display:flex;justify-content:flex-end;gap:12px;margin-top:24px}@media(max-width:700px){.sharing-shell{padding:20px}.sharing-shell>header{align-items:flex-start;flex-direction:column}.document-grid{grid-template-columns:1fr}.paper,.access{padding:24px}.paper h2{font-size:26px}.dialog-panel{padding:24px}}@media(prefers-reduced-motion:reduce){.switch span{transition:none}}

代码参考

  • 保持 data-focus 和 data-selected 相互独立。
  • 不需要预先安装 Tailwind。
04

Headless UI 设计系统 Agent 设置

实现起点

上文已提供完整的样式和组件文件。需要使用所选 Vite 版本支持的 Node 版本。此演示会在本地打包相同的原生组件包。

文本
# Terminal
npm create vite@latest sharing-demo -- --template react
cd sharing-demo
npm install
npm install @headlessui/[email protected]

# Save the complete examples as src/App.jsx and src/style.css.
# Replace src/main.jsx with:
import React from 'react';
import {createRoot} from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App/>);

# Terminal
npm run dev

适用于你项目的提示词

Agent 提示词
使用提供的 Headless UI App.jsx 和 style.css。保留原生 Dialog、Listbox 和 Switch 的组合方式,将权限草稿与已确认的访问权限分开,并保留所有焦点和选中状态选择器。将本地追加邀请的逻辑替换为我们经过授权的 API,并包含待处理和失败状态;切勿在 onClose 时保存。不要将自定义的 CSS 配色描述为官方 Headless UI 主题。
05

Headless UI 设计系统:来源与范围

原始的 OpenDesign 视觉配置和 React 示例,采用原生 Headless UI 2.2.10 行为。CSS 值明确属于应用层选择;Headless UI 不提供此主题。邀请和权限变更仅存在于本地预览内存中。