Skip to content

Button ​

This page is examples: every area renders a real YueButton (as well as the Button family's YueButtonGroup, YueButtonToggle and YueButtonToggleItem). See API for the interface tables and the guide for usage principles.

Button is the first component of @yue-ui/vue, and the verification case for the whole pipeline (tokens → hooks → component → docs → packaged consumption).

Every example on this page renders a real component: the same component source, the same @yue-ui/vue/style.css. They are not screenshots and not copied HTML — if the component breaks, this page breaks with it.

On-demand import examples ​

Three entry points, each with one job:

Three entry pointsThe root entry only does named exports; only the plugin entry registers components.
EntryPurpose
@yue-ui/vueNamed exports, registers no component; the bundler keeps only the parts in use
@yue-ui/vue/buttonSingle-component entry, default is YueButton
@yue-ui/vue/pluginFull registration, the only entry that calls app.component()
@yue-ui/vue/style.cssStyles for every component
@yue-ui/vue/button.cssOnly the Button styles

Styles must be imported explicitly

The components do not resolve CSS: there is no style import at all in dist/*.js. That way the ESM entry also resolves directly under Node / SSR, and the cascade order stays visible in your own source.

Anatomy ​

A button is not a <button> plus a run of text. It has six parts, and each part is defined by one class name, a number of component tokens and one accessibility rule. Looking at the structure first makes the API tables further down much easier to read.

Assembled · The same button while loading —— the label and icons keep their place and the loader covers them
  1. Control box.yue-buttonheight, inset, border and radius; also the positioning context inside the box
  2. Leading content.yue-button__icon--leadingthe leading slot; icon size follows `size`
  3. Label.yue-button__labelthe default slot; usually the accessible name as well
  4. Trailing content.yue-button__icon--trailingthe trailing slot
  5. Loader layer.yue-button__loaderabsolutely positioned over the content; the default spinner or the loader slot
  6. Focus ring and hit area.yue-button:focus-visiblethe outline comes from focus-ring tokens; the hit area is the control box, except for `link`
The real code behind the anatomy diagramThe two buttons above are rendered by this code, and the loading layer is in there too.
#PartSelectorDetermined by
1Outer button box.yue-button--button-height-*, --button-padding-inline-*, --button-border-width, --button-border-radius
2Leading content.yue-button__icon--leadingthe leading slot; --button-icon-size-*, --button-gap
3Text area.yue-button__labelthe default slot; --button-font-size-*, --button-font-weight, --button-line-height
4Trailing content.yue-button__icon--trailingthe trailing slot; the same set of size tokens as the leading content
5Loading layer.yue-button__loaderrendered when loading is true; the loader slot or the default .yue-button__spinner
6Focus ring and hit area.yue-button:focus-visible--button-focus-ring-*; the hit area is the control box itself (link excepted)

Three structural conventions are worth remembering on their own:

  • The loading layer is a separate layer. It is absolutely positioned over the content, so loading does not change the button width and does not take the label out of the accessibility tree — see Loading state below.
  • Content is not replaced, only hidden. While loading, leading / trailing stay where they are in the DOM and are merely painted transparent; swapping a slot out is the easiest source of layout shift to write down and the easiest to overlook.
  • link deliberately gives up the box. Its part 1 has no fixed height and no horizontal padding, so the hit area is noticeably smaller — which is why it only fits inside a sentence.

Basic examples ​

Basic exampleWith no attribute passed: theme=default, variant=solid, size=md, shape=square.

Theme variants ​

theme decides what the button "is for", that is, which set of semantic colors it takes.

Theme variantsFive semantic roles, mutually independent from variant.
themeSemanticSolid fill sourceSolid contrast (light / dark)
defaultNeutral action--button-default-background16.48 / 16.29
primaryPrimary page action--button-primary-background4.54 / 5.58
successConfirmation / completion--button-success-background6.55 / 7.06
warningNeeds attention--button-warning-background5.73 / 11.56
dangerDestructive action--button-danger-background6.31 / 9.96

The contrast is not estimated: --button-{theme}-color on --button-{theme}-background, the hover and pressed states, the accent text of the unfilled variants, and the selected-state --button-selected-* are all among the 110 gates in pnpm audit:tokens (that number is determined by tools/token-audit.pairs.mjs, and audit:docs checks whether it is written correctly).

variant decides "how it is painted", and is orthogonal to theme. They answer two different questions:

  • Whether there is a shell — solid fills, outline / dashed draw a border, text draws nothing;
  • Whether it occupies the box — all of them except link keep the full control box (fixed height + horizontal padding), so the hit area matches the other buttons; link deliberately drops the box, because it is meant to sit in the middle of a sentence.
Five treatments on the primary colorOne theme, five weights.
Five treatments on the neutral colorSwitching theme does not require changing variant.
variantFillBorderTextBoxUse
solidTheme solid colorNoneContent color on the solid fillYesThe one primary action on the page
outline--surfaceSolid line, theme-readable colorTheme-readable colorYesSecondary action that needs a sense of boundary
dashed--surfaceDashed line, theme-readable colorTheme-readable colorYesLow-frequency actions such as "Add" or "Add configuration"; the dashes themselves say "something else can go here"
textTransparentNoneTheme-readable colorYesToolbars, table rows, dense areas
linkTransparentNone--button-link-colorNoAn inline jump embedded in a sentence

The boundary between text and link

Both have "no shell", but only one keeps the box.

text is a quieter button: its height and padding are exactly the same as solid, the hit area is just as large, and hover gives feedback through the shared subtle overlay. link is an inline action: no fixed height, no horizontal padding, it reads the link color and underlines on hover.

So use text in a toolbar and link inside a sentence. Do not use link as "a lighter button" — its hit area becomes smaller than it should ever be.

The text color of unfilled variants is not the text color of solid

The solid text is "the content color on a saturated fill" (white in light mode), which would be invisible on a white background. So outline / dashed / text read a different set of tokens: --button-{theme}-accent; each of them points at a semantic text role, and every one is covered by the contrast gates.

hover and focus-visible share one state ​

Keyboard and mouse see the same cueFocus and Tab through them: the fill matches hover, and the focus ring is always kept.

The focus ring is an independent declaration and is not displaced by the fill color: resets such as outline: none were not adopted, because they delete the only position cue a keyboard user has.

Theme × variant matrix ​

Laying out the 5 themes and the 5 treatments is how every combination gets checked — especially whether the fill and the text fall over together in dark mode.

Theme × variantEvery cell is a real button; the light and dark contrast are both measured in verify:visual.
solid
outline
dashed
text
link
State matrixHow disabled and loading look in each theme; the disabled state is explicitly exempt under WCAG, so only whether it uses the agreed tokens is verified.
default
primary
success
warning
danger

Size ​

Sizesm / md / lg, and falling back to the configuration when size is omitted.

The three size steps are not hard-coded pixels; each one points at a set of geometry tokens:

sizeHeightHorizontal paddingFont sizeIcon
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

When size is not passed, the size from the app-level configuration is used (default md). The configuration is provided through plugin options or provideYueConfig(); the component's own size always wins.

The namespace is fixed, not a configuration option

useNamespace('button') produces yue-button, and that yue comes from the YUE_NAMESPACE constant. YueConfig has no prefix.

The reason is practical: a CSS selector cannot be assembled from a variable at runtime. Once the namespace is configurable, the class names become .app-button, while the stylesheet shipped with the package only writes .yue-button — every rule silently stops applying and the button degrades to the host's default styling. The class names are themselves public API (both "Token mapping" and "Overrides and the cascade" below reference them directly), so making them changeable would only make the documentation wrong for whoever changed them.

If the namespace really has to change, the correct approach is to rewrite the selectors when the stylesheet is generated, not to configure them at runtime. Passing prefix immediately produces an explanatory warning.

Shape ​

Shapecircle is an icon button and must be given an accessible name.
shapeRadiusNotes
square--button-border-radiusDefault
round--button-border-radius-fullCapsule
circle--button-border-radius-fullSquare + fully rounded; the width equals the height and the horizontal padding drops to zero

circle has no visible text, so its accessible name is the only source of its meaning. In development mode, omitting aria-label / aria-labelledby produces a console warning (in a production build this code is tree-shaken away).

Disabled state ​

Disabled stateA native button uses the native disabled; other tags use aria-disabled.
  • On a <button>, disabled sets the native disabled, and the browser blocks the click.
  • With an <a> or a custom component it uses aria-disabled="true" + tabindex="-1", and calls preventDefault() in the click handler.
  • On a native <button> it does not add aria-disabled as well: the platform already expresses the disabled state, and adding it a second time makes screen readers announce it twice.
  • loading is unrelated to this whole set of output. On any tag it only outputs aria-busy="true", writing neither aria-disabled nor tabindex: busy is not disabled, and Tab must still reach it in the browser. The difference between the three <a> elements in the subsection below can be walked through with Tab directly.

Rendered as <a> or a custom component ​

tagA disabled <a> leaves the Tab order; a loading <a> does not — its Tab order is verified key by key in the browser.

tag accepts any tag name or component. When passing a component, wrap it in markRaw(), otherwise Vue turns the component definition into a reactive object too and warns in the console.

The difference between the output of the three, not a word more:

Statedisabledaria-disabledaria-busytabindex
Plain <a>————
<a> with loading——"true"—
<a> with disabled—"true"—"-1"
Native <button> + loading——"true"— (a native button never writes tabindex)

During loading the click is blocked by the handler's preventDefault() (including the anchor jump triggered by Enter on the keyboard), so "still focusable" holds rather than being an optimistic assumption.

Loading state ​

Loading stateloading blocks clicks, sets aria-busy and covers the content with the loading layer — but does not set the native disabled.

Clicking the first button shows a real state transition; the last one uses the loader slot, which replaces the indicator, not the layout.

loading and disabled deliberately do not reuse the same mechanism:

  • disabled → the native disabled, and the element leaves the focusable sequence.
  • loading → aria-busy="true" + the handler blocking the click, keeping focus and the tab order. Pulling focus out from under the user before the request comes back is a worse problem than being clickable.

Loading does not change the layout ​

Keeping the content in place with the loading layer on top is not an aesthetic choice; it is what keeps the button the same width before and after the request:

The same content, one of them loadingThe two buttons have identical text, icon and size, and only loading differs; the widths are compared pixel by pixel in the browser.

Three things hold at the same time, and not one of them can be missing:

ConventionWhy
The content is not unmounted (no v-if)Unmounting would change the width immediately, and would also make the button lose its accessible name while loading
The content is hidden with opacity: 0, not display: none / visibility: hiddenThe latter two take the text out of the accessibility tree; opacity only affects painting
The loading layer is position: absolute; inset: 0Completely out of flow; neither the default spinner nor a custom loader stretches the button

The label should still change from "Submit" to "Submitting": aria-busy states "busy", but cannot say what it is busy with, and the spinner is invisible to screen reader users.

Button groups and segmented controls ​

Three components, each with one job. Grouping logic does not go into YueButton: a button does not need to know that the concept of a "group" exists.

  • YueButton — a single button, active is its only switch semantic;
  • YueButtonGroup — merged radii + role="group", with no selection state;
  • YueButtonToggle — holds the v-model on top of the group;
  • YueButtonToggleItem — one item is a button for a value.
Group and segmented controlThe top two are just stuck together; the bottom two remember which one was selected.

Currently selected: center. It changes immediately after a click — this page renders real components, not mock-ups.

A group is structure, not a shortcut for "setting eight props at once". It has no theme / variant / size: set those on the buttons inside the group item by item, or, as described in Overrides and the cascade, re-point the --button-* tokens on the container.

The visuals and semantics of the selected state ​

The selected item reads the --button-selected-* family that already existed at migration time (--selected-background / --selected-color); they were already covered by the contrast gates, there was simply no component painting them before.

Every item is still a buttontheme / variant / size / loading can each be set on its own; disabled only disables that one item.
ScenarioWhat to use
Several options where exactly one must be current (segmented control)YueButtonToggle + YueButtonToggleItem; the selected item outputs aria-pressed="true"
A set of unrelated toggles (bold / italic)YueButtonGroup + each YueButton's own active
Merely lined up visuallyYueButtonGroup

The selection is mandatory: clicking the already selected item again does not deselect it. A segmented control with no "current item" cannot answer "which one is it now"; a group of buttons that can all be turned off is the second scenario above.

Keyboard navigation is not implemented yet

A group is currently just a set of ordinary Tab stops; arrow keys and a roving tabindex are follow-up work. Changing only the tab order without providing arrow keys would be worse than doing nothing, so this step has no half-finished version.

Block state ​

Block stateblock makes the button fill the container width.

Leading / Trailing slots ​

Icons are passed in through slots and are not bound to any icon library. See Design / Icon for the complete asset boundary, sizes and accessibility rules.

Slotsleading / trailing are each wrapped in .yue-button__icon, and the size follows size.
SlotRendered atNotes
default.yue-button__labelProvide an accessible name separately when it is omitted
leading.yue-button__icon--leadingStays in place and is hidden while loading; it is not displaced
trailing.yue-button__icon--trailingStays in place and is hidden while loading
loader.yue-button__loaderReplaces the default spinner; rendered inside the absolutely positioned loading layer

The interface tables live on the API page ​

This page only answers "what does it look like and how is it used". The complete tables of Props, Slots, Events and app-level configuration are written in exactly one place, Button API — copy the same contract twice and one copy is bound to go stale first. YueConfig only has size; language is not in the configuration, it is a locale instance, see the i18n guide.

Dark theme ​

Switch Dark once in the toolbar above — the switch acts on the document root, because in the token contract the light values are defined on :root and the dark values on [data-theme=dark]: a container nested inside a dark page cannot use data-theme="light" to "undo" the inherited dark values back again.

These are the roles that need separate confirmation in dark mode:

RoleLightDark
--button-danger-background--red-700 (dark red fill + white text)--error → --red-300 (light red fill + dark text)
--button-success-background--success → --green-700--success → --green-400
--button-success-color--text-inverse → white--text-inverse → near black

In other words, saturated fills such as danger / success flip over as a whole in dark mode: the fill gets lighter and the text gets darker. --text-inverse is exactly this "inverse content" role, so the success button does not need an extra --on-success.

Accent theme ​

The Azure / Neutral switch in the toolbar writes data-accent. In the token contract azure is the default scope (there is no corresponding override block), and only neutral is a real override — so switching to neutral is the proof that this accent axis is truly wired up.

More than the fill color is affected: --accent-text, --accent-border, --on-accent, and --link-decoration (which becomes underline under neutral) all follow along, so the Link variant goes from "no underline" to "underlined".

Token mapping ​

No bare color value appears in the component styles, and primitive tokens are not read directly either. --_* are the component's internal composition slots, each assigned by the Button component tokens below, and theme modifiers only re-point them:

Composition slotAssigned by which token (default theme)
--_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 change it to --button-border-radius-full)
PurposeToken
Size steps--button-height-{sm,md,lg}, --button-padding-inline-{sm,md,lg}, --button-font-size-{sm,md,lg}
Geometry--button-padding-block, --button-padding-inline-flush, --button-gap, --button-border-width, --button-border-radius, --button-border-radius-full
Typography--button-font-weight, --button-line-height
Icons and loading--button-icon-size-{sm,md,lg}, --button-spinner-border-width, --button-spinner-duration
Selected state--button-selected-background, --button-selected-color, --button-selected-border-color, --button-selected-background-hover, --button-selected-background-pressed
Motion--button-duration, --button-ease
Focus--button-focus-ring-color, --button-focus-ring-width, --button-focus-ring-offset
Disabled--button-disabled-background, --button-disabled-color, --button-disabled-border-color
Unfilled variants--button-subtle-background, --button-subtle-background-hover, --button-subtle-background-pressed, --button-outline-background
Link variant--button-link-color, --button-link-decoration, --button-link-decoration-hover

A group introduces no new token: the merged radii read the button's own --_radius (assigned by --button-border-radius or --button-border-radius-full), and the overlapping borders read --button-border-width.

Accessibility notes ​

  • Native first. It renders <button type="button"> by default, so keyboard, focus and form semantics all come from the platform.
  • disabled and aria-disabled have clearly separated roles. <button> uses the native disabled; <a> and custom components use aria-disabled="true", together with tabindex="-1" to leave the tab order, and the click is blocked in the handler.
  • loading takes away neither focus nor the name. It sets aria-busy="true" and intercepts the click, but keeps the element focusable; the content is hidden with opacity: 0 rather than unmounted, so the button still has an accessible name during the request, and the spinner is an aria-hidden="true" decoration.
  • The three states of active are intentional. A button that is not passed active does not output aria-pressed: a plain button should not be read as a toggle button; only active=false means "it is a toggle button and is currently unselected".
  • A segmented control must have a name. YueButtonToggle renders role="group", so pass aria-label or use aria-labelledby pointing at visible text, and each item inside the group outputs aria-pressed="true|false".
  • circle must have an accessible name. The icon is aria-hidden, so use aria-label, or have aria-labelledby point at visible text. A missing name produces one console warning in development mode.
  • The focus ring comes from tokens. :focus-visible is painted with --button-focus-ring-*; neither the width nor the offset is a hard-coded value, and both follow the theme.
  • Respect user preferences. Under prefers-reduced-motion: reduce the transitions and the spinner rotation are turned off, but the static loading cue is kept; under forced-colors: active the border switches to the system color ButtonBorder and the disabled state uses GrayText.

Overrides and the cascade ​

The token package declares the layer order, but the component rules themselves are in no layer, and this is not an oversight:

Unlayered styles take precedence over all layers, no matter how high the selector specificity. If the component rules were written into @layer implementations, any unlayered reset in the host environment would win over them — VitePress ships button { background-color: transparent }, and Tailwind's Preflight and normalize.css are the same. A button placed inside a layer would have its fill color silently disappear in a real project.

So the division of labour is:

What you want to changeHow to do it
Systematic values such as color, size and radiusRe-point the Button component tokens. Tokens live in the components layer, so overriding with @layer demo is the cleanest and needs no !important
The density / size within one areaRe-point the component tokens on the container, and the component reads the new values
The detail of a single component ruleWrite a CSS rule with no lower specificity than it, as usual, and load it after the component styles
How to overrideWhat changes is the tokens, not the component's CSS selectors.

The Compact density in the toolbar above demonstrates the second one: it acts on the preview container and re-points only the component tokens inside that area, and the button itself knows nothing about it.

Trade-offs with TDesign ​

The reference implementation (refer/tdesign-common/style/web/components/button) contains several battle-tested interaction rules worth absorbing, and several that Yue deliberately does not copy. They are recorded one by one here, so that the discussion does not have to be repeated later.

Adopted ​

From the reference implementationWhat Yue does
touch-action: manipulationAdopted directly: it removes the roughly 300ms double-tap zoom delay on mobile without disabling pinch zoom
vertical-align: middleAdopted directly: alignment is correct when a button is embedded in a text flow or a table cell
position: relativeAdopted as a positioning context inside the box. Today's spinner and icons are all in flow, so it costs nothing now; without it, any absolutely positioned layer in the future would escape to an unrelated ancestor
hover and focus-visible share a color stateAdopted the principle and rewrote the selectors with tokens: keyboard users and mouse users see the same "where am I now" cue. The focus ring is a separate declaration and is always kept
variant="text"Adopted the semantics: the full control box is kept (the hit area matches the other buttons), no fill and no border, and hover gives a subtle overlay
variant="dashed"Adopted: it shares every value with outline and differs only in border-style, which suits low-frequency actions such as "Add"
theme="warning"Adopted: the semantic layer gains --action-warning / --action-on-warning, at the same level as --action-danger
A fixed gap between icon and textAdopted the principle, but continues to use gap: var(--button-gap) instead of hard-coding 8px
Theme × state matrices for documentation and regressionAdopted: the two matrices above are it, and verify:visual measures the contrast cell by cell

Not adopted ​

What the reference implementation doesWhy it is not copied
transition: allOnly the background, border and text color are transitioned. all would bring layout properties into the animation too, at the price of performance problems that are hard to trace
overflow: hiddenIt exists to serve the ripple. Yue has no ripple, and adding it would only clip custom content and future local feedback
A built-in rippleIt adds component logic and motion complexity; a dependency-free approach is more stable, and a ripple needs overflow: hidden to work
outline: noneIt deletes the only position cue a keyboard user has. The reference implementation draws its own focus state; Yue achieves the same effect with :focus-visible + focus ring tokens, without touching the browser default behaviour
padding: calc(padding - border width)Yue already uses box-sizing: border-box and size tokens, so copying this mechanically would make the tokens fight the actual box model
--td-* tokens and concrete color valuesYue has its own --button-* system; copying color values across libraries would make the differences impossible to explain once the two themes evolve separately
The --ghost modifier (white-ghost foreground, for use on dark / colored backgrounds)Yue's --button-ghost-* is the "unfilled" contract fixed at migration time, and it is watched by the audit gates. Changing it into "a button on an inverse background" would directly overturn that contract. If such a button is really wanted, a new set of --button-on-inverse-* tokens should be added rather than reusing ghost
Keeping text and ghost side by sideThe reference implementation's variant="text" and Yue's former ghost look the same. Two names for one appearance only make users agonise over which to choose — so Yue calls it text uniformly

Trade-offs with Vuetify ​

refer/vuetify is another reference line: its Button is a large ecosystem component, and its maturity lies in information architecture (Usage → API → Anatomy → Props → Variants → Slots → Examples → Accessibility), state modelling (active / loading / readonly orthogonal), context defaults and real browser testing. These four are exactly what Yue absorbs, not its API surface.

Adopted ​

From VuetifyWhat Yue does
Anatomy in the documentationAdopted: the Anatomy at the top of this page, rendered with real YueButton components, not a diagram
active orthogonal to disabled / loadingAdopted: active has three states (not passed / false / true), independent of disabled and loading
A replaceable loader slotAdopted: the default spinner is kept, and a custom indicator renders inside the same absolutely positioned loading layer, without being bound to an icon library
The content stays in place while loading (opacity: 0)Adopted: see Loading does not change the layout
Grouping and selection modelled separatelyAdopted, but split more finely: YueButtonGroup (structure) / YueButtonToggle (v-model) / YueButtonToggleItem (one value)
Consistency checks between API metadata and documentationAdopted: pnpm audit:docs compares the type definitions, the SFCs, the API tables and the props used in the examples
Running states and contrast in a browserAdopted: verify:visual measures cell by cell, and this documentation page is the object under test

Not adopted ​

What Vuetify doesWhy it is not copied
icon / prepend-icon / append-icon string propsThey depend on VIcon and an icon registry. Yue explicitly ships no icon library, and the leading / trailing slots are a better fit for a general-purpose package
elevation / ripple / position / location / arbitrary size propsMaterial-only capabilities; the ripple also needs overflow: hidden, which would clip custom content
Five size steps × five density stepsYue keeps a small, verifiable set of steps; "this area is more compact" is expressed by re-pointing the --button-* tokens on the container
A generic defaults Provider, defaults: { VBtn: { … } }Right now only one component needs subtree defaults, and abstracting too early turns one configuration option into a framework layer. The two-level inheritance of YueConfig + provideYueConfig() is kept for now
Sass compile-time theme variablesYue's CSS tokens and CSS variables support runtime skinning and light / dark mode
Stuffing value / grouping logic into ButtonA button does not need to know that a "group" exists. Grouping is handled by YueButtonToggle + YueButtonToggleItem