Skip to content

Button 按钮 ​

本页是 示例:所有区域渲染真实的 YueButton(以及 Button 族的 YueButtonGroup、YueButtonToggle、YueButtonToggleItem)。接口表见 API,使用原则见指南。

按钮是 @yue-ui/vue 的第一个组件,也是整条链路(Token → Hooks → 组件 → 文档 → 打包消费)的验证用例。

页面上的每个示例渲染的都是真实的组件:同一份组件源码、同一份 @yue-ui/vue/style.css。它们不是截图,也不是抄写下来的 HTML —— 如果组件坏了,这一页就会跟着坏。

按需引入示例 ​

三个入口,各管一件事:

三个入口根入口只做命名导出,插件入口才注册组件。
入口作用
@yue-ui/vue命名导出,不注册任何组件;打包器只保留用到的部分
@yue-ui/vue/button单组件入口,default 就是 YueButton
@yue-ui/vue/plugin全量注册,唯一会调用 app.component() 的入口
@yue-ui/vue/style.css全部组件样式
@yue-ui/vue/button.css只有 Button 的样式

样式必须显式引入

组件不解析 CSS:dist/*.js 里没有任何样式导入。这样 ESM 入口在 Node / SSR 下也能直接解析,级联顺序也留在你自己的源码里可见。

组件解剖(Anatomy) ​

按钮不是一个 <button> 加一段文字。它有六个部分,每一部分都由一个类名、若干 Component Token 和一条无障碍规则定义。先看结构,再看后面的 API 表会容易得多。

装配完成的按钮 · loading 时的同一个按钮 —— 文字与图标留在原位,加载层盖在上面
  1. 外层按钮盒.yue-button高度、内边距、边框、圆角;也是盒内定位的上下文
  2. 前置内容.yue-button__icon--leadingleading 插槽,图标尺寸随 size
  3. 文本区域.yue-button__label默认插槽;通常也是可访问名称的来源
  4. 后置内容.yue-button__icon--trailingtrailing 插槽
  5. 加载层.yue-button__loader绝对定位覆盖在内容之上;默认 spinner 或 loader 插槽
  6. 焦点环与点击区域.yue-button:focus-visibleoutline 来自焦点环 Token;点击区域是控件盒本身,link 例外
解剖图对应的真实代码上面的两个按钮就是这段代码渲染出来的,加载层也在里面。
#部分选择器由什么决定
1外层按钮盒.yue-button--button-height-*、--button-padding-inline-*、--button-border-width、--button-border-radius
2前置内容.yue-button__icon--leadingleading 插槽;--button-icon-size-*、--button-gap
3文本区域.yue-button__label默认插槽;--button-font-size-*、--button-font-weight、--button-line-height
4后置内容.yue-button__icon--trailingtrailing 插槽;与前置内容同一套尺寸 Token
5加载层.yue-button__loaderloading 为真时渲染;loader 插槽或默认的 .yue-button__spinner
6焦点环与点击区域.yue-button:focus-visible--button-focus-ring-*;点击区域就是控件盒本身(link 例外)

三条结构约定值得单独记住:

  • 加载层是独立的一层。 它绝对定位覆盖在内容之上,因此 loading 不会改变按钮宽度,也不会把 label 从无障碍树里摘掉 —— 见下面的加载状态。
  • 内容不会被替换,只会被隐藏。 loading 时 leading / trailing 仍然是它们在 DOM 里的那个位置,只是被画成透明;换掉某个插槽是最容易被写下去、也最容易被忽略的布局抖动来源。
  • link 主动放弃盒子。 它的第 1 部分没有固定高度和横向内边距,点击区域明显更小 —— 这就是为什么它只适合嵌在句子里。

基础示例 ​

基础示例不传任何属性时:theme=default、variant=solid、size=md、shape=square。

主题变体 ​

theme 决定按钮「是干什么的」,也就是它取哪一组语义色。

主题变体五个语义角色,与 variant 相互独立。
theme语义实心填充来源实心对比度(浅 / 深)
default中性操作--button-default-background16.48 / 16.29
primary页面主操作--button-primary-background4.54 / 5.58
success确认 / 完成--button-success-background6.55 / 7.06
warning需要注意--button-warning-background5.73 / 11.56
danger破坏性操作--button-danger-background6.31 / 9.96

对比度不是估的:--button-{theme}-color 落在 --button-{theme}-background 上、悬停与按下两个状态、未填充变体的 accent 文字,以及选中态的 --button-selected-*,都在 pnpm audit:tokens 的 110 项门禁里(这个数字由 tools/token-audit.pairs.mjs 决定,audit:docs 会检查它有没有写错)。

variant 决定「怎么画」,与 theme 正交。它们回答的是两个不同的问题:

  • 要不要外壳 —— solid 填充,outline / dashed 描边,text 什么都不画;
  • 占不占盒子 —— 除 link 外都保留完整控件盒(固定高度 + 横向内边距),所以点击区域和其他按钮一致;link 故意去掉盒子,因为它要嵌在句子中间。
主色上的五种画法同一个 theme,五种权重。
中性色上的五种画法切换 theme 不需要改 variant。
variant填充边框文字盒子用途
solid主题实心色无实心上的内容色有页面上唯一的主操作
outline--surface实线,主题可读色主题可读色有次级操作,需要边界感
dashed--surface虚线,主题可读色主题可读色有「新增」「添加配置」等低频动作,虚线本身就在说「这里还能加东西」
text透明无主题可读色有工具栏、表格行内、密集区域
link透明无--button-link-color无嵌在句子里的行内跳转

text 与 link 的边界

两者都「没有外壳」,但只有一个保留盒子。

text 是安静一点的按钮:高度和内边距与 solid 完全一致,点击区域一样大,悬停时用共享的淡色覆盖层给出反馈。link 是行内动作:没有固定高度、没有横向内边距,读链接色,悬停加下划线。

所以工具栏里用 text,句子里用 link。不要用 link 当「更轻的按钮」——它的点击区域会小到不该有的程度。

未填充变体的文字色不是 solid 的文字色

solid 的文字是「饱和填充色上的内容色」(浅色模式下是白色),放到白底上就看不见了。所以 outline / dashed / text 读的是另一组 Token:--button-{theme}-accent,它们各自指向一个语义文字角色,且每一条都被对比度门禁覆盖。

hover 与 focus-visible 共享一个状态 ​

键盘与鼠标看到同一个提示聚焦后按 Tab 走一遍,填充色与悬停一致,焦点环始终保留。

焦点环是独立声明,不会被填充色顶掉:outline: none 这类重置没有采纳,因为它会把键盘用户唯一的位置提示删掉。

主题 × 变体矩阵 ​

把 5 个主题和 5 种画法铺开,用来检查每个组合——尤其是深色模式下填充与文字会不会一起翻车。

主题 × 变体每一格都是一个真实按钮;浅色与深色的对比度都在 verify:visual 里量过。
solid
outline
dashed
text
link
状态矩阵禁用与加载在每一档主题上的样子;禁用态是 WCAG 明确豁免的,所以只校验它用的是不是约定 Token。
default
primary
success
warning
danger

尺寸 ​

尺寸sm / md / lg,以及不写 size 时回落到配置。

三个尺寸档位不是硬编码的像素,而是各自指向一组几何 Token:

size高度横向内边距字号图标
sm--button-height-sm--button-padding-inline-sm--button-font-size-sm--button-icon-size-sm
md--button-height-md--button-padding-inline-md--button-font-size-md--button-icon-size-md
lg--button-height-lg--button-padding-inline-lg--button-font-size-lg--button-icon-size-lg

不传 size 时用应用级配置里的 size(默认 md)。配置通过插件选项或 provideYueConfig() 提供;组件自身的 size 永远优先。

命名空间是固定的,不是配置项

useNamespace('button') 产出 yue-button,这个 yue 来自 YUE_NAMESPACE 常量, YueConfig 里没有 prefix。

原因很实际:CSS 选择器无法在运行时由变量拼出来。一旦命名空间可配置,类名会变成 .app-button,而随包发布的样式表只写了 .yue-button——每条规则都静默失效, 按钮退化成宿主默认样式。类名本身就是公开 API(下面「Token 映射」和「覆写与级联」 都直接引用它们),所以让它可以被改,只会让文档对改了它的人变成错的。

真要换命名空间,正确做法是在生成样式表时改写选择器,而不是运行时配置。 传了 prefix 会立刻收到一条解释性警告。

形状 ​

形状circle 是图标按钮,必须给可访问名称。
shape圆角说明
square--button-border-radius默认
round--button-border-radius-full胶囊形
circle--button-border-radius-full正方形 + 全圆角;宽度等于高度,横向内边距归零

circle 没有可见文字,可访问名称就是它唯一的语义来源。开发模式下不给 aria-label / aria-labelledby 会在控制台收到警告(生产构建里这段代码会被 tree-shake 掉)。

禁用状态 ​

禁用状态原生 button 使用原生 disabled;其他标签使用 aria-disabled。
  • disabled 在 <button> 上设置原生 disabled,点击由浏览器拦掉。
  • 换成 <a> 或自定义组件时用 aria-disabled="true" + tabindex="-1",并在点击处理器里 preventDefault()。
  • 原生 <button> 上不会同时加 aria-disabled:平台已经表达了禁用,再加一次会被读屏重复播报。
  • loading 与这整套输出无关。 它在任何标签上都只输出 aria-busy="true",不写 aria-disabled、不写 tabindex:忙不等于禁用,浏览器里按 Tab 必须仍然能到达它。下面这一小节里三个 <a> 的差异可以直接用 Tab 走一遍。

渲染成 <a> 或自定义组件 ​

tagdisabled 的 <a> 退出 Tab 顺序;loading 的 <a> 不退出——它的 Tab 顺序由浏览器逐次按键验证。

tag 接受任意标签名或组件。传组件时请用 markRaw() 包一层,否则 Vue 会把组件定义也变成响应式对象并在控制台提醒。

三者输出的差别,一字不多:

状态disabledaria-disabledaria-busytabindex
普通 <a>————
loading 的 <a>——"true"—
disabled 的 <a>—"true"—"-1"
原生 <button> + loading——"true"—(原生按钮从不写 tabindex)

loading 期间点击被处理器 preventDefault() 拦下(包括键盘 Enter 触发的锚点跳转),所以「仍然可聚焦」是成立的,而不是乐观假设。

加载状态 ​

加载状态loading 会阻止点击、设置 aria-busy,并把加载层盖在内容上 —— 但不设置原生 disabled。

点第一个按钮可以看到真实的状态流转;最后一个用的是 loader 插槽,它替换的是指示器,不是布局。

loading 与 disabled 故意不复用同一个机制:

  • disabled → 原生 disabled,元素退出可聚焦序列。
  • loading → aria-busy="true" + 处理器拦截点击,保留焦点与 tab 顺序。请求还没回来就把焦点从用户脚下抽走,是比「能点到」更糟的问题。

加载不改变布局 ​

内容留在原位、加载层盖在上面,这不是审美选择,而是为了让按钮在请求前后保持同一个宽度:

同样的内容,一个在加载两个按钮的文字、图标与尺寸完全相同,只有 loading 不同;宽度在浏览器里被逐像素比对。

三件事同时成立,缺一不可:

约定为什么
内容不卸载(无 v-if)卸载会立刻改变宽度,也会让按钮在加载期间失去可访问名称
内容用 opacity: 0 隐藏,不用 display: none / visibility: hidden后两者会把文字移出无障碍树;opacity 只影响绘制
加载层 position: absolute; inset: 0完全脱离布局;默认 spinner 与自定义 loader 都不会撑开按钮

文案仍然建议从「提交」变成「提交中」:aria-busy 说明的是「忙」,说不出在忙什么,而 spinner 对读屏用户是不可见的。

按钮分组与分段控件 ​

三个组件,各管一件事。分组逻辑不放进 YueButton:按钮不需要知道「组」这个概念存在。

  • YueButton —— 单个按钮,active 是它唯一的开关语义;
  • YueButtonGroup —— 合并圆角 + role="group",没有选择状态;
  • YueButtonToggle —— 在分组之上持有 v-model;
  • YueButtonToggleItem —— 一个就是某个值的按钮。
分组与分段控件上面两个只是拼在一起,下面两个会记住选了哪一个。

当前选中:center。点击后会立即变化 —— 这一页渲染的是真实组件,不是示意图。

分组是结构,不是「一次设置八个 prop」的快捷方式。它没有 theme / variant / size:对照组内的按钮逐项设置,或者按覆写与级联里说的,在容器上重指 --button-* Token。

选中态的视觉与语义 ​

选中项读的是迁移时就存在的 --button-selected-* 一族(--selected-background / --selected-color),它们本来就被对比度门禁覆盖,只是此前没有组件去画。

每一项仍然是一个按钮theme / variant / size / loading 都可以单独给;disabled 只禁用它自己。
场景用什么
几个选项必须有一个是当前项(分段控件)YueButtonToggle + YueButtonToggleItem,选中项输出 aria-pressed="true"
一组互不相关的开关(粗体 / 斜体)YueButtonGroup + 每个 YueButton 自己的 active
只是视觉上排在一起YueButtonGroup

选择是强制的:再次点击已选中的项不会取消选择。没有「当前项」的分段控件回答不了「现在是哪一个」;可以全部关掉的一组按钮是上面第二种场景。

键盘导航尚未实现

分组现在只是一组普通的 Tab 停靠点,方向键与 roving tabindex 是后续工作。只改 tab 顺序而不提供方向键,会比不做更糟,所以这一步没有半成品。

Block 状态 ​

Block 状态block 让按钮吃满容器宽度。

Leading / Trailing 插槽 ​

图标通过插槽传入,不绑定任何图标库。完整的资源边界、尺寸和无障碍规则见设计 / 图标。

插槽leading / trailing 各自包在 .yue-button__icon 里,尺寸随 size 走。
插槽渲染位置备注
default.yue-button__label省略时请另行提供可访问名称
leading.yue-button__icon--leadingloading 时留在原位并被隐藏,不会被顶替
trailing.yue-button__icon--trailingloading 时留在原位并被隐藏
loader.yue-button__loader替换默认 spinner;渲染在绝对定位的加载层里

接口表在 API 页 ​

这一页只回答「长什么样、怎么用」。Props、Slots、Events 和应用级配置的完整表格只写在 Button API 一处 —— 同一份契约抄两遍,就一定会有一遍先过期。YueConfig 只有 size;语言不在配置里,它是 locale 实例,见国际化指南。

深色主题 ​

用上方工具条里的深色切一次即可 —— 切换作用在文档根节点上,因为 Token 契约里浅色值定义在 :root、深色值定义在 [data-theme=dark]:嵌在深色页面里的容器没法用 data-theme="light" 把继承下来的深色值「撤销」回去。

深色模式下需要单独确认的是这几个角色:

角色浅色深色
--button-danger-background--red-700(深红填充 + 白字)--error → --red-300(浅红填充 + 深字)
--button-success-background--success → --green-700--success → --green-400
--button-success-color--text-inverse → 白--text-inverse → 近黑

也就是说危险 / 成功这类「饱和填充」在深色下会整体翻过来:填充变亮、文字变暗。--text-inverse 正是这个「反色内容」角色,所以成功按钮不需要额外造一个 --on-success。

Accent 主题 ​

工具条里的天蓝 / 中性切换会写 data-accent。Token 契约里 azure 是默认作用域(没有对应覆盖块),neutral 才是一段真正的覆盖 —— 所以切到中性才是这套主题色轴真的接上了的证据。

受影响的不只是填充色:--accent-text、--accent-border、--on-accent、以及 --link-decoration(中性下变 underline)都会跟着走,Link 变体因此会从「无下划线」变成「有下划线」。

Token 映射 ​

组件样式里不出现任何裸色值,也不直接读 primitive Token。--_* 是组件内部的组合槽位,每个都由下面这些 Button Component Token 赋值,主题修饰符只负责重指:

组合槽位由哪个 Token 赋值(default 主题)
--_fill--button-default-background
--_fill-hover--button-default-background-hover
--_fill-pressed--button-default-background-pressed
--_on-fill--button-default-color
--_accent--button-default-accent
--_border--button-default-border-color
--_radius--button-border-radius(round / circle 会把它改成 --button-border-radius-full)
用途Token
尺寸档位--button-height-{sm,md,lg}、--button-padding-inline-{sm,md,lg}、--button-font-size-{sm,md,lg}
几何--button-padding-block、--button-padding-inline-flush、--button-gap、--button-border-width、--button-border-radius、--button-border-radius-full
排版--button-font-weight、--button-line-height
图标与加载--button-icon-size-{sm,md,lg}、--button-spinner-border-width、--button-spinner-duration
选中态--button-selected-background、--button-selected-color、--button-selected-border-color、--button-selected-background-hover、--button-selected-background-pressed
动效--button-duration、--button-ease
焦点--button-focus-ring-color、--button-focus-ring-width、--button-focus-ring-offset
禁用--button-disabled-background、--button-disabled-color、--button-disabled-border-color
未填充变体--button-subtle-background、--button-subtle-background-hover、--button-subtle-background-pressed、--button-outline-background
链接变体--button-link-color、--button-link-decoration、--button-link-decoration-hover

分组不引入任何新 Token:合并圆角读的是按钮自己的 --_radius(由 --button-border-radius 或 --button-border-radius-full 赋值),重叠边框读的是 --button-border-width。

无障碍说明 ​

  • 原生优先。 默认渲染 <button type="button">,键盘、焦点、表单语义都由平台提供。
  • disabled 与 aria-disabled 分工明确。 <button> 用原生 disabled;<a> 和自定义组件用 aria-disabled="true",同时 tabindex="-1" 退出 tab 顺序,点击在处理器里被拦掉。
  • loading 不夺走焦点,也不夺走名字。 它设置 aria-busy="true" 并拦截点击,但保留元素可聚焦;内容用 opacity: 0 隐藏而不是卸载,所以按钮在请求期间仍然有可访问名称,spinner 是 aria-hidden="true" 的装饰。
  • active 的三态是有意的。 不传 active 的按钮不会输出 aria-pressed:普通按钮不该被读成开关按钮;active=false 才表示「是开关按钮,当前未选中」。
  • 分段控件必须有名。 YueButtonToggle 渲染 role="group",请传 aria-label 或用 aria-labelledby 指向可见文字,组内每一项输出 aria-pressed="true|false"。
  • circle 必须有可访问名称。 图标是 aria-hidden 的,所以请用 aria-label,或让 aria-labelledby 指向可见文字。开发模式下缺失会收到一次控制台警告。
  • 焦点环来自 Token。 :focus-visible 用 --button-focus-ring-* 绘制,宽度与偏移都不是硬编码值,跟随主题变化。
  • 尊重用户偏好。 prefers-reduced-motion: reduce 下过渡和 spinner 旋转被关闭,但保留静态加载提示;forced-colors: active 下边框改用系统色 ButtonBorder,禁用态用 GrayText。

覆写与级联 ​

Token 包声明了层顺序,但组件规则本身不在任何层里,这不是疏漏:

无层(unlayered)样式优先于所有层,无论选择器权重多高。如果组件规则写进 @layer implementations,任何宿主环境的无层重置都会赢过它 —— VitePress 自带 button { background-color: transparent },Tailwind 的 Preflight、normalize.css 也一样。放在层里的按钮,填充色会在真实项目里悄悄消失。

所以分工是:

想改什么怎么做
颜色、尺寸、圆角等成体系的值重指 Button Component Token。Token 住在 components 层,用 @layer demo 覆写最干净,也不需要 !important
某一区域内的密度 / 尺寸在容器上重指组件 Token,组件读到的就是新值
单条组件规则的细节像平常一样写一条权重不低于它的 CSS,并在组件样式之后加载
覆写方式动的是 Token,不是组件的 CSS 选择器。

上面工具条里的紧凑密度演示的就是第二种:它作用在预览容器上,只重指了这一块区域里的组件 Token,按钮本身对此一无所知。

与 TDesign 的取舍 ​

参考实现(refer/tdesign-common/style/web/components/button)里有几条久经考验的交互规则值得吸收,也有几条是 Yue 刻意不抄的。逐条记下来,免得以后反复讨论。

采纳 ​

来自参考实现Yue 的做法
touch-action: manipulation直接采纳:去掉移动端约 300ms 的双击缩放延迟,又不关闭双指缩放
vertical-align: middle直接采纳:按钮嵌进文字流或表格单元时对齐正确
position: relative采纳为在盒内定位的上下文。今天的 spinner 与图标都在流内,所以现在零成本;没有它,将来任何绝对定位的层都会逃到一个无关的祖先上
hover 与 focus-visible 共享颜色状态采纳原则,用 Token 重写选择器:键盘用户与鼠标用户看到同一个「当前在哪」提示。焦点环是独立声明,始终保留
variant="text"采纳语义:保留完整控件盒(点击区域与其他按钮一致)、无填充无边框、悬停给淡色覆盖层
variant="dashed"采纳:与 outline 共享全部取值,只差 border-style,适合「新增」这类低频动作
theme="warning"采纳:语义层补齐 --action-warning / --action-on-warning,与 --action-danger 同级
图标与文字固定间距采纳原则,但继续用 gap: var(--button-gap),不写死 8px
主题 × 状态矩阵做文档与回归采纳:上面两节矩阵就是它,且 verify:visual 会逐格量对比度

不采纳 ​

参考实现的做法为什么不抄
transition: all只过渡背景、边框、文字颜色。all 会把布局属性也纳入动画,代价是难查的性能问题
overflow: hidden它是为 ripple 服务的。Yue 没有 ripple,加上它只会裁掉自定义内容和将来的局部反馈
内置 ripple增加组件逻辑与动效复杂度;无依赖方案更稳,且 ripple 需要 overflow: hidden 才能工作
outline: none直接删掉键盘用户唯一的位置提示。参考实现自己另画了焦点态,Yue 用 :focus-visible + 焦点环 Token 达到同样效果,不动浏览器默认行为
padding: calc(内边距 - 边框宽度)Yue 已经用 box-sizing: border-box 和尺寸 Token,机械照搬会让 Token 与实际盒模型打架
--td-* Token 与具体色值Yue 有自己的 --button-* 体系;跨库抄色值会让两套主题各自演变后无法解释差异
--ghost 修饰符(white-ghost 前景,用于深色/彩色底上)Yue 的 --button-ghost-* 是迁移时就定下的「未填充」契约,而且被审计门禁盯着。把它改成「反色底上的按钮」会直接推翻那条契约。真要这种按钮,应当新增一组 --button-on-inverse-* Token,而不是复用 ghost
把 text 与 ghost 并存参考实现的 variant="text" 与 Yue 原来的 ghost 是同一种观感。两个名字一种外观,只会让使用者纠结选哪个——所以 Yue 统一叫 text

与 Vuetify 的取舍 ​

refer/vuetify 是另一条参考线:它的 Button 是一个大型生态组件,成熟之处在信息架构(Usage → API → Anatomy → Props → Variants → Slots → Examples → Accessibility)、状态建模(active / loading / readonly 正交)、上下文默认值和真实浏览器测试。Yue 吸收的正是这四条,而不是它的 API 面。

采纳 ​

来自 VuetifyYue 的做法
文档里的 Anatomy采纳:本页开头的组件解剖,用真实 YueButton 渲染,不是示意图
active 与 disabled / loading 正交采纳:active 是三态(不传 / false / true),独立于禁用与加载
可替换的 loader 插槽采纳:默认 spinner 保留,自定义指示器渲染在同一个绝对定位的加载层里,不绑定图标库
loading 时内容留在原位(opacity: 0)采纳:见加载不改变布局
分组与选择分开建模采纳,但拆得更细:YueButtonGroup(结构)/ YueButtonToggle(v-model)/ YueButtonToggleItem(一个值)
API 元数据与文档的一致性检查采纳:pnpm audit:docs 比对类型定义、SFC、API 表与示例中的 prop
浏览器里跑状态与对比度采纳:verify:visual 逐格量测,本文档页就是被测对象

不采纳 ​

Vuetify 的做法为什么不抄
icon / prepend-icon / append-icon 字符串 prop依赖 VIcon 与图标注册表。Yue 明确不自带图标库,leading / trailing 插槽对通用包更合适
elevation / ripple / position / location / 任意尺寸 propMaterial 专属能力;ripple 还需要 overflow: hidden,会裁掉自定义内容
五档尺寸 × 五档 densityYue 保持少量、可验证的档位;“这一片更紧凑”用容器上重指 --button-* Token 表达
defaults: { VBtn: { … } } 通用默认值 Provider目前只有一个组件需要子树默认值,过早抽象会把一个配置项变成一层框架。先保留 YueConfig + provideYueConfig() 的两级继承
Sass 编译期主题变量Yue 的 CSS Token 与 CSS 变量支持运行时换肤和浅色 / 深色模式
把 value / 分组逻辑塞进 Button按钮不需要知道「组」存在。分组由 YueButtonToggle + YueButtonToggleItem 承担