diff --git a/.changeset/perf-actionlist-has-selector.md b/.changeset/perf-actionlist-has-selector.md
new file mode 100644
index 00000000000..ace58e0d124
--- /dev/null
+++ b/.changeset/perf-actionlist-has-selector.md
@@ -0,0 +1,16 @@
+---
+'@primer/react': patch
+---
+
+perf(ActionList): Optimize CSS `:has()` selectors for better INP
+
+**Performance improvements:**
+- Replace `:has([aria-disabled], [disabled])` with `[data-disabled]` attribute on `
` - avoids descendant scan
+- Replace `:has([data-loading])` with `[data-loading]` on `` - avoids descendant scan
+- Scope TrailingAction loading selector to direct child: `:has(> .TrailingAction[data-loading])`
+- Add documentation comments explaining acceptable `:has()` patterns (mixed descriptions, SubGroup active state)
+
+**Why this matters:**
+CSS `:has()` selectors that scan descendants can cause expensive style recalculations during user interactions, impacting INP (Interaction to Next Paint). By using data attributes set via JS on the parent ``, we eliminate descendant traversal.
+
+Part of #7312
diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css
index f8007fb3e63..c6d178d6cae 100644
--- a/packages/react/src/ActionList/ActionList.module.css
+++ b/packages/react/src/ActionList/ActionList.module.css
@@ -86,7 +86,14 @@
display: none;
}
- /* if a list has a mix of items with and without descriptions, reset the label font-weight to normal */
+ /*
+ * If a list has a mix of items with and without descriptions, reset the label font-weight.
+ * NOTE: Uses double descendant :has() - traverses list twice per recalc.
+ * This is acceptable because:
+ * 1. ActionLists are typically small (10-50 items)
+ * 2. Alternative would require JS to detect mixed state and set data attribute
+ * 3. The visual impact is subtle (font-weight change), not critical styling
+ */
&:has([data-has-description='true']):has([data-has-description='false']) {
& .ItemLabel {
font-weight: var(--base-text-weight-normal);
@@ -121,7 +128,8 @@
}
}
- &:not(:has([aria-disabled], [disabled]), [data-has-subitem='true']) {
+ /* PERFORMANCE: Use data-disabled on instead of :has([aria-disabled], [disabled]) which scans descendants */
+ &:not([aria-disabled], [data-disabled='true'], [data-has-subitem='true']) {
@media (hover: hover) {
&:hover,
&:active {
@@ -276,8 +284,8 @@
}
}
- &:where([data-loading='true']),
- &:has([data-loading='true']) {
+ /* PERFORMANCE: data-loading is set on the by JS, avoiding :has() descendant scan */
+ &:where([data-loading='true']) {
& * {
color: var(--fgColor-muted);
}
@@ -322,10 +330,9 @@
}
}
- /* disabled */
-
+ /* PERFORMANCE: data-disabled is set on the by JS, avoiding :has() descendant scan */
&[aria-disabled='true'],
- &:has([aria-disabled='true'], [disabled]) {
+ &[data-disabled='true'] {
& .ActionListContent * {
color: var(--control-fgColor-disabled);
}
@@ -366,7 +373,8 @@
}
/* When TrailingAction is in loading state, keep labels and descriptions accessible */
- &:has(.TrailingAction [data-loading='true']):not([aria-disabled='true']) {
+ /* PERFORMANCE: scoped to direct child TrailingAction */
+ &:has(> .TrailingAction[data-loading='true']):not([aria-disabled='true']) {
/* Ensure labels and descriptions maintain accessibility contrast */
& .ItemLabel {
color: var(--fgColor-default);
@@ -540,7 +548,11 @@
display: none;
}
- /* show active indicator on parent collapse if child is active */
+ /*
+ * Show active indicator on parent collapse if child is active.
+ * NOTE: Uses adjacent sibling + descendant :has() - SubGroup is typically small (few nav items).
+ * This is acceptable because scope is limited to the collapsed SubGroup's children.
+ */
&:has(+ .SubGroup [data-active='true']) {
background: var(--control-transparent-bgColor-selected);
@@ -637,6 +649,7 @@ default block */
word-break: normal;
}
+ /* NOTE: Uses descendant :has() - scope is just this item's children (label + description). Acceptable. */
&:has([data-truncate='true']) {
& .ItemLabel {
flex: 1 0 auto;
@@ -713,7 +726,8 @@ span wrapping svg or text */
height: 100%;
/* Preserve width consistency when loading state is active for text buttons only */
- &[data-loading='true']:has([data-component='buttonContent']) {
+ /* PERFORMANCE: scoped to direct child for O(1) lookup */
+ &[data-loading='true']:has(> [data-component='buttonContent']) {
/* Double the left padding to compensate for missing right padding */
padding: 0 0 0 calc(var(--base-size-12) * 2);
@@ -731,6 +745,11 @@ span wrapping svg or text */
}
}
+/*
+ * NOTE: Uses descendant :has() - VisualComponent is nested inside Tooltip > button,
+ * which is 3 levels deep (> * > * > .TrailingVisual). This is acceptable since
+ * the search is scoped to this small wrapper element's subtree.
+ */
.InactiveButtonWrap {
&:has(.TrailingVisual) {
grid-area: trailingVisual;
diff --git a/packages/react/src/ActionList/Group.module.css b/packages/react/src/ActionList/Group.module.css
index 201d3086b86..761b9ec15c4 100644
--- a/packages/react/src/ActionList/Group.module.css
+++ b/packages/react/src/ActionList/Group.module.css
@@ -4,7 +4,11 @@
&:not(:first-child) {
margin-block-start: var(--base-size-8);
- /* If somebody tries to pass the `title` prop AND a `NavList.GroupHeading` as a child, hide the `ActionList.GroupHeading */
+ /*
+ * If somebody tries to pass the `title` prop AND a `NavList.GroupHeading` as a child, hide the `ActionList.GroupHeading`.
+ * NOTE: Uses descendant :has() - this is an edge case handler for developer errors.
+ * Scope is limited to group's children, not entire list. Acceptable performance cost.
+ */
/* stylelint-disable-next-line selector-max-specificity */
&:has(.GroupHeadingWrap + ul > .GroupHeadingWrap) {
/* stylelint-disable-next-line selector-max-specificity */
diff --git a/packages/react/src/ActionList/Item.tsx b/packages/react/src/ActionList/Item.tsx
index ac917743fd5..90122ccf5b3 100644
--- a/packages/react/src/ActionList/Item.tsx
+++ b/packages/react/src/ActionList/Item.tsx
@@ -256,6 +256,8 @@ const UnwrappedItem = (
data-variant={variant === 'danger' ? variant : undefined}
data-active={active ? true : undefined}
data-inactive={inactiveText ? true : undefined}
+ data-loading={loading && !inactive ? true : undefined}
+ data-disabled={disabled ? true : undefined}
data-has-subitem={slots.subItem ? true : undefined}
data-has-description={slots.description ? true : false}
className={clsx(classes.ActionListItem, className)}
diff --git a/packages/react/src/ActionList/TrailingAction.tsx b/packages/react/src/ActionList/TrailingAction.tsx
index 933c3a6f584..bb1824cdedf 100644
--- a/packages/react/src/ActionList/TrailingAction.tsx
+++ b/packages/react/src/ActionList/TrailingAction.tsx
@@ -31,7 +31,7 @@ export type ActionListTrailingActionProps = ElementProps & {
export const TrailingAction = forwardRef(
({as = 'button', icon, label, href = null, className, style, loading, ...props}, forwardedRef) => {
return (
-
+
{icon ? (