Button
A button lets people trigger an action, such as submitting a form, saving changes, or opening a dialog. Providing a destination turns it into a link instead, so the same element can also be used for navigation. Its content can be a text label, an icon, or a combination of both, and it can show a loading state while an action is in progress.
Dependencies
When to use
Use a button to trigger an action that happens right there on the page, such as submitting a form, saving changes, opening a dialog, or deleting something. It suits any situation where people expect an immediate, in-page response as soon as they activate it. If activating it should instead take someone to a different page, see the alternatives below.
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/button.js";
<eun-button color="primary">Confirm</eun-button>
<eun-button color="secondary">Cancel</eun-button>
Importing the file registers <eun-button> as a custom element, with
no further setup needed. It works with any framework, or none, since
it's a standard web component.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/button.js";
</script>
<eun-button color="primary">Confirm</eun-button>
npm install @eunomia/elements
import "@eunomia/elements/button.js";
function ConfirmActions() {
return (
<>
<eun-button color="primary">Confirm</eun-button>
<eun-button color="secondary">Cancel</eun-button>
</>
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/button.js";
export function ConfirmActions() {
return (
<>
<eun-button color="primary">Confirm</eun-button>
<eun-button color="secondary">Cancel</eun-button>
</>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/button.js";
</script>
<template>
<eun-button color="primary">Confirm</eun-button>
<eun-button color="secondary">Cancel</eun-button>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/button.js";
@Component({
selector: "app-confirm-actions",
template: `
<eun-button color="primary">Confirm</eun-button>
<eun-button color="secondary">Cancel</eun-button>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ConfirmActionsComponent {}
Alternatives
| You want… | Prefers |
|---|---|
| A text-styled link for page navigation | eun-link |
Guidance
- Use one
primarybutton per section, for the main action - Use
secondaryfor cancel/back actions alongside it - Reserve
criticalfor destructive actions (delete, remove) - Use
warningfor an action that deserves caution but isn't destructive (overwrite, proceed anyway) - Start labels with an action verb ("Save changes", not "Changes")
- Set
loadingduring an async action to prevent double-submits - Wire it to a target with
command/commandforinstead of a click handler whenever the button doesn't need to run other logic first, preferred over the Popover API's ownpopovertarget: see Invoker Commands API below
- Stacking several
primarybuttons side by side - Using
eun-buttonfor plain text navigation instead ofeun-link(see Alternatives above), unlesshrefis set for a link styled as a button - Overriding colors with inline styles instead of the
--button-*CSS variables - Disabling a button without explaining why elsewhere in the UI
Live testing
Properties
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| fixed-color | boolean | false | Keeps a light or dark button white or black in every theme. Without it, they follow the grey scale, which flips with the active theme. Has no effect on the other colors |
| aria-label | string | — | The accessible label for the button. When the button opens a link in a new tab, this automatically gets a note appended announcing it. Ignored with a slotted anchor, which takes it instead |
| loading-label | string | — | The accessible label announced while the button is loading. Defaults to the localized string for the current locale (English: "Loading…") |
| icon | EunomiaIconName | — | An optional leading icon, shown at the start of the button. Ignored once something is slotted into start, and overridden by the default icon from severity when both are set |
| severity | 'info' | 'success' | 'warning' | 'critical' | 'neutral' | — | The severity of the action this button leads to. When set, it supplies a default leading icon unless icon is also set or something is slotted into start. It has no effect on color, which is still controlled by color |
| appearance | 'soft' | 'fill' | 'outline' | 'flat' | 'raised' | 'ghost' | 'fill' | The button's surface style. Fill is a solid, colored background with light text. Soft is a paler tint of the same color. Outline keeps the color as a border instead of a fill. Flat always renders a muted, low emphasis surface regardless of color. Raised adds a drop shadow. Ghost shows no surface at rest, only gaining a border and a tinted background on hover, focus, or press |
| full | boolean | false | Whether the button stretches to fill 100% of the width available from its parent, instead of sizing to its content. Its content stays centered once stretched |
| active | boolean | false | Indicates whether the component is on disabled state or not. By default, set to false. |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| type | string | 'button' | The button's native type. Ignored when href is set |
| color | 'primary' | 'secondary' | 'critical' | 'success' | 'warning' | 'light' | 'medium' | 'dark' | 'primary' | The color applied to the button. light/medium/dark are neutral grey tones with no semantic meaning, matching eun-tag's color scale, for a de-emphasized action next to a semantically-colored one |
| fixedColor | boolean | false | Keeps a light or dark button white or black in every theme. Without it, they follow the grey scale, which flips with the active theme. Has no effect on the other colors |
| size | string | 'm' | The size of the button |
| rounded | boolean | false | Whether the button has fully rounded corners |
| slide | boolean | false | Whether the button uses a sliding open animation |
| href | string | — | The URL the button navigates to. When set, the button renders as a link instead of a button |
| target | '_blank' | '_self' | '_parent' | '_top' | — | Where to open the linked URL. Only used when href is set. Opening in a new tab automatically adds an external link icon and announces it to assistive technology, in the current locale. A slotted anchor opening in a new tab gets the same icon and announcement |
| locale | string | — | The locale used to resolve the "opens in a new tab" announcement. Defaults to the one set with setDefaultLocale, English otherwise |
| download | string | — | The filename used when the linked URL is downloaded instead of navigated to. Only used when href is set |
| loading | boolean | false | Whether the button shows a loading indicator. Like disabled, it blocks interaction, but keeps the original content in place so the button doesn't change size |
| command | string | — | The command sent to the element targeted by commandFor when the button is clicked, following the Invoker Commands API |
| commandFor | string | — | The id of the element that command is sent to |
| hostTabStop | — | Whether the host itself is a tab stop while enabled. `false` for a component whose focusable controls live inside its shadow root. | |
| resolvedLocale | — | This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary. | |
| dictionary | — | This instance's locale-resolved string dictionary — see the class doc for the resolution order. |
Slots
| Name | Description |
|---|---|
| (default) | The button content |
| start | An icon or other content shown at the start of the button. Takes priority over the default icon from icon or severity. Leave empty to let one of those render instead |
| end | An icon or other content shown at the end of the button. Ignored when the button opens a link in a new tab, through href and target or a slotted anchor, since an external link indicator takes its place |
Events
| Name | Type | Description |
|---|---|---|
| click | Fired when the button is activated by mouse, Enter, or Space | |
| eunnavigate | NavigationEvent | Fired before following href, so a client-side router can intercept the navigation. Not fired for downloads, and cancelable |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --button-background | Sets the background color |
| --button-background-hover | Sets the background color on hover |
| --button-background-active | Sets the background color while pressed |
| --button-text | Sets the text color |
| --button-padding | Sets the button's padding, for every size |
| --button-padding-block | Sets the vertical padding, as one length applied to both sides. Ignored when --button-padding is set |
| --button-padding-inline | Sets the horizontal padding, as one length applied to both sides. Ignored when --button-padding is set |
| --button-border-radius | Sets the corner radius of the button, for every size and over rounded |
| --button-focus-outline | Sets the color of the focus outline |
| --button-transition | Sets the duration of the button's transitions |
| --button-shadow | Sets a box shadow. None is applied by default |
| --button-shadow-hover | Sets the box shadow on hover, falling back to button-shadow when unset |
| --button-border-color | Sets the border color, overriding the color the active color would otherwise use |
| --button-border-color-hover | Sets the border color on hover or focus, falling back to button-border-color when unset |
| --button-border-size | Sets the width of the border. Defaults to 1px for outline and 1.5px otherwise |
| --button-font-size | Sets the font size, for every size |
| --button-font-weight | Sets the font weight |
| --button-line-height | Sets the line height, for every size. Also sizes the icons |
| --button-width | Sets the button's width, such as 100% to stretch it like the full attribute does. Works in every browser, from a container query or a media query alike. Set it on the button itself, since nested components inherit it too |
Variants
Colors
color selects the color role: primary (default), secondary,
critical, success, or warning.
<eun-button>Primary</eun-button>
<eun-button color="secondary">Secondary</eun-button>
<eun-button color="critical">Critical</eun-button>
<eun-button color="success">Success</eun-button>
<eun-button color="warning">Warning</eun-button>
Part of the
Neutral colors
light, medium, and dark add no semantic meaning of their own. They use
the same neutral colors Tag's color uses. Reach for one
when an action needs to sit next to a semantically-colored button without
competing with it, such as a secondary "Learn more" alongside a primary
CTA.
<eun-button>Confirm</eun-button>
<eun-button color="light">Learn more</eun-button>
light and dark are absolute colors: white and black, whatever the appearance.
fill is solid white with black text (solid black with white text for dark),
and outline is a white border with white text (black for dark). soft
is as solid as fill, since white and black have no pale wash, and flat is a
faint wash of the color itself. Pick them for a background of the opposite
tone.
<eun-button color="light" fixed-color appearance="outline">Outline</eun-button>
<eun-button color="dark" fixed-color appearance="fill">Fill</eun-button>
Keeping light and dark in every theme
By default, light and dark follow the grey scale, which flips with the active
theme: a light button is white in the light theme and dark in the dark theme. Set
fixed-color to pin it to white (or black) in every theme, without overriding
any CSS variable. Toggle the theme in the toolbar above to compare: the left
column follows the theme, the right one does not. fixed-color has no effect
on the other colors.
<eun-button color="light" fixed-color>Light</eun-button>
<eun-button color="dark" fixed-color appearance="outline">Dark</eun-button>
Severity
severity (info, success, warning, critical, or neutral, the
same vocabulary as Alert/Tag)
sets the button's leading icon by default, once set and icon isn't (and
nothing is slotted into start). It has no effect on color, which stays
driven by color instead. A full severity range doesn't have a
matching action color (no "info"/"neutral" button), so the two stay
independent rather than one implying the other.
<eun-button severity="info">Info</eun-button>
<eun-button severity="success">Success</eun-button>
<eun-button severity="warning">Warning</eun-button>
<eun-button severity="critical">Critical</eun-button>
<eun-button severity="neutral">Neutral</eun-button>
An explicit icon (prop or slotted) always takes precedence over severity's own default.
Appearances
appearance picks the surface treatment, independent of color. It uses
the same vocabulary alert and other feedback
components use for fill/outline/flat, extended with two
action-only treatments, raised (a drop shadow) and ghost (no visible
surface at all until hover/focus/active). flat always renders the same muted,
low-emphasis surface regardless of color, meant for low-priority
actions rather than reinforcing the brand color. ghost is similar, but
starts completely invisible, only appearing on hover/focus/active when
no color is set (set one to color it instead). It's a good fit for a
secondary/dismiss action that shouldn't visually compete with a primary
one nearby, e.g. eun-modal's own default cancel button.
fill is the default look when appearance is left unset, a solid,
opaque color-colored background. soft is a pale tint of the same
color, the look feedback components call default, renamed here since
it isn't the button's default. ghost has no background or border
at rest by design. Hover/focus it above to see it appear. The dashed
outline is added for this demo only, so its hit area stays visible at
rest, and isn't part of the real component (see the plain markup below).
<eun-button appearance="fill">Fill</eun-button>
<eun-button appearance="soft">Soft</eun-button>
<eun-button appearance="outline">Outline</eun-button>
<eun-button appearance="raised">Raised</eun-button>
<eun-button appearance="flat">Flat</eun-button>
<eun-button appearance="ghost">Ghost</eun-button>
Sizes
Five sizes are available through the size attribute: xs, s,
m (default), l, xl.
<eun-button size="xs">Extra small</eun-button>
<eun-button size="l">Large</eun-button>
Each size sets its own font size and line height. To match a typography
that none of them reaches, set --button-font-size, --button-line-height
and --button-font-weight, which apply on top of every size and keep working
with href and with a slotted link. The line height also sizes the icons.
<eun-button
style="
--button-font-size: 12px;
--button-line-height: 16px;
--button-font-weight: 500;
"
>
Custom typography
</eun-button>
Rounded
The boolean rounded attribute gives the button a pill shape.
<eun-button rounded>Rounded</eun-button>
Full width
The button never stretches to fill a flex or grid parent's default (stretch) sizing on its own, unlike a plain native <button>: it always sizes to its own content, in every container, until full (or --button-width, below) asks for the opposite explicitly. slide is the one exception, since its own hover-reveal animation needs that same stretch to have room to expand into.
The boolean full attribute stretches the button to 100% of the width
its own parent makes available, instead of sizing to its own content,
and keeps the content centered. It's handy for a call to action that
should span a card or a form, especially on small screens.
<eun-button full>Save</eun-button>
Full width in a container query
A plain attribute can't be toggled from CSS at a given width. To stretch
a button only in a narrow container, set the --button-width custom
property to 100% from a @container rule instead. It gives the same
result as the full attribute in every browser, and the responsive
behavior stays in pure CSS, with no script and no attribute to toggle.
.actions {
container-type: inline-size;
}
@container (width < 600px) {
.actions eun-button {
--button-width: 100%;
}
}
--button-width takes any width, such as a fixed 200px, and keeps the
content centered. Set it on the button itself rather than on a
container, since the buttons inside nested components would inherit it
too. A width or min-width set on the button from a class or a media
query works the same way.
Content
With icons
The leading (start) icon can be set either through the icon property,
or by slotting a eun-icon (or any content) into start, a manual
override for anything the icon prop's plain string can't express. Both
produce the same result for a plain icon. Reach for the slot as soon as
it needs to be something else (e.g. combined with slide, below). The
trailing (end) icon has no property equivalent, so always slot it.
Setting only an icon in the default slot produces an icon-only button, so
take care to apply the accessibility rules in the Accessibility tab when
doing so.
import "@eunomia/elements/icon.js";
<eun-button icon="iconName">Label</eun-button>
<!-- equivalent, spelled out by hand -->
<eun-button>
<eun-icon slot="start" name="iconName"></eun-icon>
Label
</eun-button>
The icon module must be imported alongside the button, exactly like eun-loader below for the loading state.
eun-icon or Material Symbols glyph always picks up
the button's text color automatically. A raw, hand-drawn <svg>
does too, unless its root already sets its own fill
(e.g. fill="none" on a stroke-only outline icon): that explicit
attribute is always left alone, and its children keep whichever fill
they declare themselves, so an outline icon slotted straight in doesn't turn into
a solid blob.
Icon-only
Slot just an icon with no visible label, and always pair it with an
aria-label describing the action. The icon itself carries no
accessible text (it's decorative, rendered aria-hidden), so without
one the button would announce nothing at all to a screen reader.
<eun-button aria-label="Delete" color="critical">
<eun-icon name="delete"></eun-icon>
</eun-button>
Behavior
Loading
Setting loading blocks interaction the same way disabled does. The
button keeps its original content in place, kept invisible so its size
doesn't change, while a eun-loader spinner is layered on top.
import "@eunomia/elements/loader.js";
<eun-button loading>Save changes</eun-button>
eun-loader is used internally, but its module must still be imported alongside eun-button for the spinner to render, exactly like eun-icon for slotted icons above.
Slide button
It shows the label on hover or focus and collapses to just the icon
otherwise, using only an icon slotted into start. It's implemented in
pure CSS, with no measurement and no JavaScript needed. The label's grid
track animates from 0fr to 1fr, so it always expands to exactly its
own natural content width, however long that is, with no hardcoded
width to tune.
<eun-button slide color="critical">
<eun-icon slot="start" name="delete"></eun-icon>
Remove
</eun-button>
Links
Setting href renders the button as a native <a> internally
instead of a <button>, delegating navigation, focus, and activation
entirely to the browser. All the same visual props (color,
size, rounded, ...) still apply.
<eun-button href="https://example.com">Visit website</eun-button>
When href is set, type is ignored (an <a> has no submit/reset mechanism) and the host is removed from the tab order, since the rendered <a> is the real, natively focusable element.
Opening in a new tab
Setting target="_blank" automatically adds rel="noopener noreferrer",
replaces the end slot with an external-link icon, and appends
"(opens in a new tab)" to the accessible name. That wording follows
locale, or the default set with setDefaultLocale, so a French page
announces "(ouvre dans un nouvel onglet)".
<eun-button href="https://example.com" target="_blank">
Visit website
</eun-button>
Disabled link
Native <a> elements have no disabled attribute, so a disabled
link button simply omits href instead. It keeps its usual visual
style, but is no longer exposed as a link to the browser or to
assistive technology, and gets aria-disabled="true" set for
clarity.
<eun-button href="https://example.com" disabled> Visit website </eun-button>
SPA navigation
A link button dispatches a cancelable eunnavigate event right
before following href, letting an SPA router intercept it for
client-side routing instead of a full page load. It's never dispatched
for download links, since a file download isn't a navigation.
The same router bridge covers every link-like component, Link and
Card included: see the
Search engines and server rendering
With href, the button's <a> only exists in the shadow root, so a
crawler that doesn't run JavaScript finds no link in the server HTML.
For an internal call to action that should be indexed, leave href unset
and place a native anchor as a direct child instead. It's stretched over
the whole button, so every pixel stays clickable, while the accessible
name remains the anchor's own text. The anchor is also what takes focus,
so an aria-label goes on it. One left on eun-button is ignored in
this mode, and a console warning names it.
<eun-button color="primary">
<a href="/guides/server-rendering/">Read the guide</a>
</eun-button>
import Link from "next/link";
<eun-button color="primary">
<Link href="/guides/server-rendering/">Read the guide</Link>
</eun-button>;
The anchor owns navigation, so no eunnavigate is fired for it. Since a
link has no native disabled state, disabled and loading mark the
anchor aria-disabled="true", take it out of the tab order and block its
clicks. target and rel are yours to write on the anchor. See the
When the anchor opens in a new tab, the button replaces the end slot
with the same external-link icon as with href, and announces the new
tab through the anchor's aria-description, in the button's locale.
The anchor's text and aria-label stay as you wrote them, and an
aria-description of your own is kept as is.
<eun-button color="primary">
<a href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
Visit open-wc
</a>
</eun-button>
import Link from "next/link";
<eun-button color="primary">
<Link href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
Visit open-wc
</Link>
</eun-button>;
The aria-description is only a fallback: it's added once the element
upgrades, and some screen readers only read a description after a pause,
or leave it out of their links list. To announce the new tab everywhere,
pages without JavaScript included, write the wording inside the anchor
with the eun-new-tab-hint class. It becomes part of the anchor's
accessible name and of the server-rendered HTML, links-ssr.css hides
it visually, and the button then adds no aria-description, so the new
tab is never announced twice.
import Link from "next/link";
<eun-button>
<Link href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
Visit open-wc
<span className="eun-new-tab-hint"> (opens in a new tab)</span>
</Link>
</eun-button>;
The class is hidden by links-ssr.css, so load that stylesheet, as described in the
Integration
Invoker Commands API
Setting command and commandfor turns a button into an invoker: no
click handler needed, the target picks up the command itself. Prefer
this over wiring a click handler yourself, and over the Popover API's
own popovertarget, which only ever covers popover-attributed
targets. Modal and Drawer both understand show-modal, close, and
request-close out of the box.
This will permanently remove the item from your workspace.
<eun-modal
closeable
close-on-backdrop
id="delete-modal"
heading="Delete item"
confirm-label="Delete"
confirm-color="critical"
>
<p>This will permanently remove the item from your workspace.</p>
</eun-modal>
<eun-button command="show-modal" commandfor="delete-modal"
>Delete item</eun-button
>
A command name starting with -- is a custom command: nothing happens
by default, so the target listens for it and decides what it means.
<eun-button command="--print" commandfor="invoice">Print</eun-button>
document.getElementById("invoice").addEventListener("command", (event) => {
if (event.command === "--print") {
window.print();
}
});
See the
Server-side rendering
The button's box lives in its shadow root, so a server-rendered button is
plain inline text until the element upgrades, then becomes a button all
at once, shifting the layout around it. Link button-ssr.css in the
page's <head> to draw the final box from the first paint:
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/button-ssr.css"
/>
A bundler that resolves package exports (Vite, webpack, Turbopack, or a
Sass @use/@import) can reach the same file through the package
specifier instead:
@use "@eunomia/elements/button-ssr.css";
It covers every size, color and appearance, rounded, full,
disabled, the --button-* custom properties and the room of the icon
set by icon or severity. Hover and focus styles, the loading
indicator and the slide animation only arrive with the upgrade, so a
slide button shows its label until then.
Theming
Colors come from the active theme's --eun-color-primary-* /
--eun-color-secondary-* / --eun-color-critical-* / --eun-color-success-*
/ --eun-color-warning-* tokens (see
--button-* custom properties instead of the theme
tokens directly (full list in the API tab).
<eun-button
style="
--button-background: #b45309;
--button-background-hover: #92400e;
--button-focus-outline: #b45309;
"
>
Custom button
</eun-button>
The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.
| Variable | Sets |
|---|---|
--button-background |
Sets the background color |
--button-background-hover |
Sets the background color on hover |
--button-background-active |
Sets the background color while pressed |
--button-border-radius |
Sets the corner radius of the button |
--button-focus-outline |
Sets the color of the focus outline |
--button-shadow |
Sets a box shadow. None is applied by default |
--button-shadow-hover |
Sets the box shadow on hover, falling back to button-shadow when unset |
--button-border-color |
Sets the border color, overriding the color the active color would otherwise use |
--button-border-color-hover |
Sets the border color on hover or focus, falling back to button-border-color when unset |
--button-border-size |
Sets the width of the border, 1px for outline and 1.5px otherwise |
Keyboard and focus
Keyboard interactions
When focus is on the component:
| Key | Action |
|---|---|
Enter |
Activates the button |
Space |
Activates the button |
Focus indicator
A :focus-visible outline is always rendered via outline, not a
background/shadow change, so it isn't suppressed by any surface
appearance (raised, outline, flat…). Its color comes from a shade
picked specifically for contrast against the page background, one
step darker than the button's own fill rather than reusing the fill
color itself, so the ring stays visible instead of blending into the
button on secondary and other lighter colors. Override it with
--button-focus-outline if a custom theme needs a different color.
Semantics and screen readers
Aria attributes
aria-label/aria-labelledby: set when the visible label isn't sufficient on its ownaria-haspopup="true": when the button controls a menu, listbox, modal, or anything using the popover APIaria-expanded="true\|false": for buttons controlling an accordion or disclosurearia-pressed="true\|false": for toggle buttons- For a link button opening a new tab, "(opens in a new tab)" is announced automatically in the button's
locale, so no manual wording is needed. With a slotted anchor, it goes on the anchor'saria-description - While
loading,aria-busy="true"is set and the loading indicator exposesrole="status"with an accessible label (loadingLabel, by default "Loading…" in the active locale)
Label accessibility
The accessible name is resolved in order of priority: aria-labelledby,
then aria-label, then the slotted text content, then title as a
last resort. When using aria-labelledby or aria-label, include the
visible label within the accessible one, since voice-command users rely
on that match.
Icon-only buttons
Add an aria-label repeating the intended action, since the icon alone
isn't announced. The title attribute doesn't appear on keyboard
navigation and doesn't scale with page zoom, so limit icon-only
buttons to recurring, easily identifiable actions. See the
Icon-only example in the Examples tab for the exact pattern.
Disabled state
A disabled button is not focusable and shouldn't carry a tooltip or other pointer-only affordance, since a keyboard user could never reach it to trigger that affordance in the first place. Avoid disabling a form's submit button. Prefer inline validation messaging instead, so keyboard and screen-reader users understand why the action is currently blocked rather than being met with an unreachable, unexplained control.
Slide button
The label stays in the accessible name at all times, even while
visually collapsed to just the icon. It's hidden with opacity,
which affects paint only, not the accessibility tree. A screen
reader always announces the full label (e.g. "Remove"), regardless
of whether the sighted, hover-revealed state is currently showing.