diff --git a/.changeset/perf-button-icon-only-counter.md b/.changeset/perf-button-icon-only-counter.md
new file mode 100644
index 00000000000..b8f2b5bf25f
--- /dev/null
+++ b/.changeset/perf-button-icon-only-counter.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': patch
+---
+
+Button: Replace the icon-only-with-counter `:has(...):not(:has(...))` selector with a `data-icon-only-counter` attribute computed from props. Reduces style-recalculation cost on pages that render many Buttons. No visual or behavioral changes.
diff --git a/packages/react/src/Button/ButtonBase.module.css b/packages/react/src/Button/ButtonBase.module.css
index 7d39ed491e9..b9c26348a66 100644
--- a/packages/react/src/Button/ButtonBase.module.css
+++ b/packages/react/src/Button/ButtonBase.module.css
@@ -640,8 +640,7 @@
/* Icon-only + Counter */
- /* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
- &:where([data-has-count]):has([data-component='leadingVisual']):not(:has([data-component='text'])) {
+ &:where([data-icon-only-counter]) {
/* stylelint-disable-next-line primer/spacing */
padding-inline: var(--control-medium-paddingInline-condensed);
diff --git a/packages/react/src/Button/ButtonBase.tsx b/packages/react/src/Button/ButtonBase.tsx
index 9e755912ab2..19357e9ae43 100644
--- a/packages/react/src/Button/ButtonBase.tsx
+++ b/packages/react/src/Button/ButtonBase.tsx
@@ -99,6 +99,7 @@ const ButtonBase = forwardRef(({children, as: Component = 'button', ...props}, f
data-variant={variant}
data-label-wrap={labelWrap}
data-has-count={count !== undefined ? true : undefined}
+ data-icon-only-counter={count !== undefined && LeadingVisual && !children ? true : undefined}
aria-describedby={ariaDescribedByIds.filter(descriptionID => Boolean(descriptionID)).join(' ') || undefined}
// aria-labelledby is needed because the accessible name becomes unset when the button is in a loading state.
// We only set it when the button is in a loading state because it will supersede the aria-label when the screen
diff --git a/packages/react/src/Button/__tests__/Button.test.tsx b/packages/react/src/Button/__tests__/Button.test.tsx
index 1ade78f3fbb..f2c11962df8 100644
--- a/packages/react/src/Button/__tests__/Button.test.tsx
+++ b/packages/react/src/Button/__tests__/Button.test.tsx
@@ -357,6 +357,32 @@ describe('data-component attributes', () => {
})
})
+ describe('data-icon-only-counter', () => {
+ it('is set when count, leadingVisual, and no children are present', () => {
+ const {container} = render()
+ expect(container.querySelector('[data-component="Button"]')).toHaveAttribute('data-icon-only-counter', 'true')
+ })
+
+ it('is not set when children are present', () => {
+ const {container} = render(
+ ,
+ )
+ expect(container.querySelector('[data-component="Button"]')).not.toHaveAttribute('data-icon-only-counter')
+ })
+
+ it('is not set when leadingVisual is missing', () => {
+ const {container} = render()
+ expect(container.querySelector('[data-component="Button"]')).not.toHaveAttribute('data-icon-only-counter')
+ })
+
+ it('is not set when count is missing', () => {
+ const {container} = render()
+ expect(container.querySelector('[data-component="Button"]')).not.toHaveAttribute('data-icon-only-counter')
+ })
+ })
+
describe('IconButton', () => {
it('should have data-component="IconButton" on the button element', () => {
const {container} = render()