按钮。底下是 Base UI 的 Button,就是一个原生 <button>:事件是 onClick,
禁用是 disabled,键盘激活由浏览器负责。外观是 shadcn 的 buttonVariants(cva):
六个变体(default 是主操作,其余按语义递弱,destructive 只给不可逆操作)、
四档尺寸加四档纯图标尺寸。封装层自己加的只有 pending 那一套(见进行中)。
使用
import { Button, buttonVariants, LinkButton } from '@gedatou/cadenza-ui'<Button variant="outline">保存</Button>光标
Tailwind v4
去掉了
preflight 里给按钮的 cursor: pointer,shadcn 跟着换成了默认箭头。本库把它放回去了
——styles.css 里的全局规则,不用你贴任何 CSS,也没有「初始化时开一下」这种开关。
匹配的是 role 而不只是标签名,因为 Base UI 把非按钮控件渲染成 span
(Checkbox / Switch / RadioGroupItem 这三个 box-only 控件的方框就是整个点击区)。
三态是全库统一的约定:
进行中是 wait——pending 是「在忙」不是「禁止」,虽然它也带 aria-disabled:
styles.css 里 data-pending 的规则排在禁用规则之后、在同一层特异性上专门胜出(全局规则,
无需自己写)。两条规则都连子树一起管,所以禁用容器里的图标不会再单独给出文本光标。
与光标约定配套的还有一条:pending / 禁用状态及其子树内,hover: 反馈一律不触发
——hover 是「你可以对我做点什么」的 affordance,和 wait / not-allowed 光标同框
就是自相矛盾。:hover 本身拦不住(它沿祖先链一路上走),所以 styles.css 重定义了
hover 变体加守卫,对所有 hover: 工具类生效(包括 vendored 的类和你自己写的),
无需逐处处理。
尺寸
四档高度,加四档只放图标的方形档:
icon-* 是方形档,只放一个图标 —— 必给 aria-label:按钮没有可见文字时,
无障碍名只能从这里来。
带图标
图标和文字同框时,给图标挂 data-icon="inline-start" 或 data-icon="inline-end"
——它那一侧的内边距会收窄一档:
<Button variant="outline">
<IconPlus data-icon="inline-start" />
新建
</Button>
<Button variant="outline">
下一步
<IconArrowRight data-icon="inline-end" />
</Button>图标自带视觉留白,不收窄就显得偏松(default / lg 收到 2,xs / sm 收到 1.5)。
图标的尺寸也跟着档位走:没写 size-* 的 svg 由 cva 定尺寸(默认 size-4,xs 是
size-3,sm 是 size-3.5),自己写了 size-* 就以你的为准。纯图标按钮走
icon-* 档,不需要这个属性,见尺寸。
圆角
className 里加 rounded-full 就是胶囊按钮 —— cn 让后写的圆角赢:
<Button className="rounded-full">保存</Button>进行中
pending 表示这个按钮触发的动作还在途中(对齐 React 的 useTransition /
useFormStatus 词汇)。Base UI 没有这个概念,
所以三件事由封装层自己接上:
按压和键盘激活由 Base UI 的 disabled 一并吞掉(天然防双击)。
比 React Aria 那版少一件事:状态切换时不再有 assertive 补播报。它需要一个 live-region 单例,而且没有新内容可播——本库的 Spinner 是装饰性的,基座也不往 DOM 里 塞任何语言的文案。
默认视觉就是全库同一个 LoadingOverlay:
真正的覆盖层——标签留在原地被磨砂融开、隐约可见、继续撑宽度、仍是无障碍名,
什么都不替换;Spinner 用 foreground 色浮在纱上
(装饰性 aria-hidden;不用 currentColor 是因为会和纱下
的标签同色伪装)。按钮的磨砂是内容滤镜而非 backdrop:在 32px 高的小亮元素上,
backdrop 模糊核会沿边角涂出晕染(真机放大验证过的物理事实),改为模糊标签本身 +
平面暗纱由宿主裁切成形,磨砂观感相同、边缘与背景像素级贴合;暗纱浓度、过渡、
wait 光标、data-loading 钩子与表格/下拉完全同源。宽度全程不变,邻居不位移;
150ms 交叉淡入进出两向生效(motion-reduce 下豁免)。没有内建防闪延迟——快操作
(比如 200ms 就返回的请求)该不该出 spinner 由你决定,需要就延迟置起 pending:
这套结构只在传了 pending 时挂载(传 false 也算——机制留在
DOM 里退出动画才放得完);没用这个特性的按钮渲染裸 children,零开销。
想让宽度真正纹丝不动,pending 期间别换文案——spinner 已经在说话了。
自定义 pending
不传 pending 就什么都不注入,children 完全归你 —— 要自己画进行中的样子,传
disabled,别传 pending。下例是经典的「spinner 排在文案旁 + 换文案」形态;
注意内容变化会改变宽度,这是接管的代价(默认组合的宽度恒定靠的正是覆盖而不替换)。
代价还有一条:wait 光标和 hover 豁免跟着 data-pending 走,
这条路上没有它,拿到的是禁用态的 not-allowed:
链接按钮
LinkButton 是穿着按钮衣服的 <a>:有 href、支持 target,变体和尺寸同 Button。
底下是 Base UI 的 Button 走 nativeButton={false},所以禁用时该有的都有:
aria-disabled、tabIndex={-1}、激活被吞掉。href 还会被整个摘掉 —— 光有
aria-disabled 的链接仍然能从右键菜单打开,而没有 href 的 <a> 既不可聚焦也
不可导航。:disabled 在锚元素上永远不会命中,置灰靠 data-disabled(封装已接好):
它仍然是个链接。 nativeButton={false} 这条路上 Base UI 会给元素挂
role="button",辅助技术就会把它读成按钮、不再收进链接列表 —— shadcn 母版为此
干脆禁掉了 <Button render={<a />} nativeButton={false} /> 这种写法。封装层在
render 元素上传 role={undefined} 把它摘掉了(render 元素的 props 赢下合并),
所以这里拿到的是「链接语义 + 完整禁用契约」,不用二选一。
真要读作按钮(一个不导航、只触发动作的 <a>)就自己传回来:role="button"
排在后面,会盖过封装层这一手。
只要变体外观、其余全部自理时,用 buttonVariants 配普通 <a>:
<a className={buttonVariants({ variant: 'outline' })} href="/docs">文档</a>状态与 className
按钮的交互状态走 CSS 伪类:hover: / active: / focus-visible: / :disabled,
buttonVariants 自己就是这么写的。原生 <button> 上这些伪类覆盖了全部输入模态,
不需要 render props 那一层。要按状态换内容(不只是样式)就自己拿 state 判断,
自定义 pending 就是这个形态。
className 只收字符串,没有函数形态 —— vendored 层把它灌进 cva,函数会被丢掉,
所以类型上就把这条路封了(见 Props)。
状态属性只有两个,都在根元素上:
交互态没有 data 属性:Base UI 的 Button 只暴露 disabled,hover / active /
focus-visible 直接用 CSS 伪类,在原生 <button> 上它们覆盖了全部输入模态。
光标与 hover 守卫是全库统一的约定,见光标。
覆盖层与 Spinner 各自带着自己的 loading-overlay / spinner,需要从外部定位时
当选择器用。
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
Button
LinkButton
buttonVariants 也单独导出 —— 需要把别的元素画成按钮的样子(比如 fumadocs 的卡片链接)
时直接调用:buttonVariants({ variant: 'outline', size: 'sm', className: '...' })。
ButtonProps / LinkButtonProps 两个类型一并导出。