diff --git a/BREAKING.md b/BREAKING.md
index 99ca0c9e610..973cc7211c2 100644
--- a/BREAKING.md
+++ b/BREAKING.md
@@ -63,11 +63,12 @@ The following breaking changes apply to `ion-button`:
6. The `md` button follows Material Design 2 for its spacing, outline, elevation and disabled state. [6](#version-10x-button-md-material-design-2)
7. The `ios` button follows iOS 17 for its font, icons and disabled state. [7](#version-10x-button-ios-17)
8. A badge without a `vertical` position now has a gap between it and the label, and no longer makes the button taller than its `min.height`. To remove the gap, set `IonButton.size..badge.margin.start` for global styles or `--ion-button-size--badge-margin-start` for component-specific styles to `0`.
-9. CSS variables have been replaced. [9](#version-10x-button-replaced-css-variables)
-10. `--padding-*` CSS variables are no longer part of the documented public API (but remain functional). [10](#version-10x-button-padding-variables)
-11. The fill, size, shape and expand classes have been renamed. [11](#version-10x-button-renamed-classes)
-12. The `default` size has been deprecated. [12](#version-10x-button-deprecated-default-size)
-13. Theme classes (`ion-button.md`, `ion-button.ios`) are no longer supported. [13](#version-10x-button-theme-classes)
+9. A button with an icon in the `icon-only` slot now shows only that icon and any badge with a `vertical` position. Text, elements in the `start` and `end` slots, and other content are hidden. Text and elements without a slot are only visually hidden, so they still name the button for assistive technology. To show other content, remove the `icon-only` slot and use the `start` or `end` slot for the icon.
+10. CSS variables have been replaced. [10](#version-10x-button-replaced-css-variables)
+11. `--padding-*` CSS variables are no longer part of the documented public API (but remain functional). [11](#version-10x-button-padding-variables)
+12. The fill, size, shape and expand classes have been renamed. [12](#version-10x-button-renamed-classes)
+13. The `default` size has been deprecated. [13](#version-10x-button-deprecated-default-size)
+14. Theme classes (`ion-button.md`, `ion-button.ios`) are no longer supported. [14](#version-10x-button-theme-classes)
diff --git a/core/src/components/button/button.scss b/core/src/components/button/button.scss
index 2f352e980ef..7d43cf97727 100644
--- a/core/src/components/button/button.scss
+++ b/core/src/components/button/button.scss
@@ -183,6 +183,22 @@ $spinner-sizes: xsmall, small, medium, large, xlarge;
}
}
+// An icon-only button shows only its icon and badges with a `vertical` position.
+// Other content stays in the accessibility tree, so wrapped text still names the button.
+:host(.button-has-icon-only) ::slotted(:not([slot]):not(ion-badge[vertical])) {
+ @include mixins.visually-hidden();
+}
+
+/**
+ * Text can't be selected, so the default slot passes it a zero font size.
+ * Letter spacing is inherited in px, so it's reset too.
+ */
+:host(.button-has-icon-only) slot:not([name]) {
+ font-size: 0;
+
+ letter-spacing: 0;
+}
+
// Button: Slotted Elements
// --------------------------------------------------
diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx
index 009a5d5dc81..a267fce190f 100644
--- a/core/src/components/button/button.tsx
+++ b/core/src/components/button/button.tsx
@@ -20,7 +20,7 @@ import type { IonButtonExpand, IonButtonFill, IonButtonShape, IonButtonSize } fr
* @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component.
*
* @slot - Content is placed between the named slots if provided without a slot.
- * @slot icon-only - Should be used on an icon in a button that has no text.
+ * @slot icon-only - An icon shown on its own, with any badge that has a `vertical` position. Text and elements without a slot are visually hidden, so they can still name the button for assistive technology. Content in the `start` and `end` slots isn't rendered.
* @slot start - Content is placed to the left of the button text in LTR, and to the right in RTL.
* @slot end - Content is placed to the right of the button text in LTR, and to the left in RTL.
*
@@ -521,9 +521,10 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
>
-
+ {/* An icon-only button skips these slots, so outside styles can't show their content */}
+ {!hasIconOnly && }
-
+ {!hasIconOnly && }
{showRipple && }
diff --git a/core/src/components/button/test/icon/button.e2e.ts b/core/src/components/button/test/icon/button.e2e.ts
index ad24f474098..a718637664e 100644
--- a/core/src/components/button/test/icon/button.e2e.ts
+++ b/core/src/components/button/test/icon/button.e2e.ts
@@ -12,3 +12,58 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
});
});
});
+
+/**
+ * This behavior does not vary across modes or directions.
+ * The ionic theme's link styles set `display`, so it also
+ * checks that outside styles can't show hidden content.
+ */
+configs({ modes: ['ionic-md'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('button: icon only'), () => {
+ test('should only show the icon and badges with a vertical position', async ({ page }) => {
+ await page.setContent(
+ `
+
+
+ `,
+ config
+ );
+
+ const extra = page.locator('#extra');
+
+ await expect(extra.locator('[slot="start"]')).toBeHidden();
+ await expect(extra.locator('[slot="end"]')).toBeHidden();
+ // Content without a slot is visually hidden, so it still names the button
+ await expect(page.locator('#inline-badge')).toHaveCSS('opacity', '0');
+ await expect(page.locator('#vertical-badge')).toBeVisible();
+
+ // The hidden text takes no space, so the button stays the size of a plain icon-only button
+ const plainBox = await page.locator('#plain').boundingBox();
+ const extraBox = await extra.boundingBox();
+ expect(extraBox!.width).toBe(plainBox!.width);
+ expect(extraBox!.height).toBe(plainBox!.height);
+ });
+
+ test('should use hidden text as the accessible name', async ({ page }) => {
+ await page.setContent(
+ `
+ Add item
+ Add item
+ Add item
+ `,
+ config
+ );
+
+ for (const id of ['bare', 'span', 'label']) {
+ await expect(page.locator(`#${id}`).getByRole('button')).toHaveAccessibleName('Add item');
+ }
+ });
+ });
+});