Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/instructions/css.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,16 @@ If the file ends with `*.module.css`, it is a CSS Module.
## General Conventions

- After making a change to a file, use `npx stylelint -q --rd --fix` with a path to the file changed to make sure the code lints

## `:has()` Selectors and Safari Performance

`:has()` selectors are blocked by stylelint (`selector-pseudo-class-disallowed-list`) because they can cause catastrophic Safari performance regressions. In Aug 2025, a single `:has()` selector on a broadly-present component froze the entire GitHub UI for 10-20+ seconds on Safari. See [github/github-ui#17224](https://github.com/github/github-ui/issues/17224) for the full audit.

**When you need `:has()`:**

1. Ensure it's scoped to a CSS Module class (`&:has(...)` inside a `.Component` rule). Never use `:has()` on `body`, `html`, `*`, or other high-cardinality selectors.
2. Keep the argument simple. Avoid deeply-nested descendant selectors inside `:has()`.
3. Add a scoped stylelint disable with justification. Use `stylelint-disable-next-line` for individual selectors, or a `stylelint-disable`/`stylelint-enable` block around a group of related selectors. Avoid file-level disables so new `:has()` selectors in the same file still trigger review. Example: `/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */`
4. Consider alternatives first: data attributes set via JS, `:where()`, or restructuring the DOM so the parent already has the information it needs.

**Why CSS Modules help:** CSS Module class names are unique hashed identifiers, so the browser only evaluates `:has()` on elements matching that specific class, not the entire DOM. This limits the invalidation blast radius. Unscoped selectors like `body:has(...)` force the browser to scan all descendants of `body` on every DOM mutation, which is where WebKit's quadratic invalidation triggers.
8 changes: 8 additions & 0 deletions packages/react/src/ActionList/ActionList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
}

/* if a list has a mix of items with and without descriptions, reset the label font-weight to normal */
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has([data-has-description='true']):has([data-has-description='false']) {
& .ItemLabel {
font-weight: var(--base-text-weight-normal);
Expand All @@ -103,6 +104,7 @@
border-radius: var(--borderRadius-medium);

/* apply flex if trailing action exists as an immediate child */
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(> .TrailingAction) {
display: flex;
flex-wrap: nowrap;
Expand Down Expand Up @@ -371,6 +373,7 @@
}

/* When TrailingAction is in loading state, keep labels and descriptions accessible */
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(.TrailingAction [data-loading='true']):not([data-is-disabled]) {
/* Ensure labels and descriptions maintain accessibility contrast */
& .ItemLabel {
Expand Down Expand Up @@ -546,6 +549,7 @@
}

/* show active indicator on parent collapse if child is active */
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(~ .SubGroup [data-active='true']) {
background: var(--control-transparent-bgColor-selected);

Expand Down Expand Up @@ -642,6 +646,7 @@ default block */
word-break: normal;
}

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has([data-truncate='true']) {
& .ItemLabel {
flex: 1 0 auto;
Expand Down Expand Up @@ -718,6 +723,7 @@ span wrapping svg or text */
height: 100%;

/* Preserve width consistency when loading state is active for text buttons only */
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&[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);
Expand All @@ -737,10 +743,12 @@ span wrapping svg or text */
}

.InactiveButtonWrap {
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(.TrailingVisual) {
grid-area: trailingVisual;
}

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(.LeadingVisual) {
grid-area: leadingVisual;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/ActionList/Group.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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 */
/* stylelint-disable-next-line selector-max-specificity */
/* stylelint-disable-next-line selector-max-specificity, selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(.GroupHeadingWrap + ul > .GroupHeadingWrap) {
/* stylelint-disable-next-line selector-max-specificity */
& > .GroupHeadingWrap {
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/AvatarStack/AvatarStack.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
box-shadow: 0 0 0 var(--avatar-border-width) transparent;
}

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:not([data-component='Avatar']):not(:has([data-square])) {
border-radius: 50%;
}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/Breadcrumbs/Breadcrumbs.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
list-style: none;

/* allow menu items to wrap line */
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(.MenuOverlay) {
white-space: normal;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/Button/ButtonBase.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
justify-content: space-between;
gap: var(--base-size-8);

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has([data-kbd-chord]) {
padding-inline-end: var(--base-size-6);
}
Expand Down Expand Up @@ -173,6 +174,7 @@
margin-right: var(--control-large-gap);
}

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has([data-kbd-chord]) {
padding-inline-end: var(--base-size-8);
}
Expand Down Expand Up @@ -638,6 +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'])) {
/* stylelint-disable-next-line primer/spacing */
padding-inline: var(--control-medium-paddingInline-condensed);
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/ButtonGroup/ButtonGroup.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
}

/* this is a workaround until portal based tooltips are fully removed from dotcom */
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(div:last-child:empty) {
button,
a {
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/Dialog/Dialog.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@
* When the attribute IS present (flag ON), browser skips :has() evaluation
* because the :not() check fails first (O(1) attribute lookup).
*/
/* stylelint-disable-next-line selector-no-qualifying-type */
/* stylelint-disable-next-line selector-no-qualifying-type, selector-pseudo-class-disallowed-list -- :has() on body guarded by feature flag negation, audited (github/github-ui#17224) */
body:not([data-dialog-scroll-optimized]):has(.Dialog.DisableScroll) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can/should just ship the flag here that enables the optimization path soon!

I just keep missing the window's where it's unlocked to do that
#7633

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added #7633 to my list of “PRs to merge in the next release.” I’ll keep an eye on it and merge it the next time main is unlocked.

/* stylelint-disable-next-line primer/spacing */
padding-right: var(--prc-dialog-scrollgutter) !important;
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/PageHeader/PageHeader.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
--custom-font-size, --custom-line-height, --custom-font-weight are custom properties that can be used to override the below values.
We don't want these values to be overridden but still want to allow consumers to override them if needed.
*/
/* stylelint-disable selector-pseudo-class-disallowed-list -- :has() scoped to CSS Module, audited (github/github-ui#17224) */
&:has([data-component='TitleArea'][data-size-variant='large']) {
font-size: var(--custom-font-size, var(--text-title-size-large, 2rem));
font-weight: var(--custom-font-weight, var(--base-text-weight-normal, 400));
Expand Down Expand Up @@ -163,6 +164,7 @@
padding-block-end: var(--base-size-8);
}
}
/* stylelint-enable selector-pseudo-class-disallowed-list */

& [data-component='PH_LeadingAction'],
& [data-component='PH_TrailingAction'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,14 @@
background-color: var(--borderColor-default);
}

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(+ [data-selected])::after,
&:where([data-selected])::after {
background-color: transparent;
}
}

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:focus-within:has(:focus-visible) {
background-color: transparent;
}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/Timeline/Timeline.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
border: 0;
border-top: var(--borderWidth-thicker) solid var(--borderColor-default);

/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(+ [data-condensed]) {
margin-bottom: calc(-1 * var(--base-size-12));
}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/TreeView/TreeView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
* TreeViewItemContent > TreeViewItemContentText, not a direct child.
* This is acceptable as the search is scoped to this element's subtree.
*/
/* stylelint-disable-next-line selector-pseudo-class-disallowed-list -- scoped to CSS Module, audited (github/github-ui#17224) */
&:has(.TreeViewItemSkeleton):hover {
cursor: default;
background-color: transparent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
.TextInput {
padding-left: var(--base-size-8) !important;

/* stylelint-disable-next-line selector-class-pattern, selector-no-qualifying-type */
/* stylelint-disable-next-line selector-class-pattern, selector-no-qualifying-type, selector-pseudo-class-disallowed-list -- :has() scoped to CSS Module, audited (github/github-ui#17224) */
&:has(input:placeholder-shown) :global(.TextInput-action) {
display: none;
}
Expand Down
19 changes: 6 additions & 13 deletions packages/styled-react/src/components/BaseStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,12 @@ const GlobalStyle = createGlobalStyle<{colorScheme?: 'light' | 'dark'}>`
/* stylelint-disable-next-line primer/colors */
color: var(--BaseStyles-fgColor, var(--fgColor-default));

/* Global styles for light mode */
&:has([data-color-mode='light']) {
input & {
color-scheme: light;
}
}

/* Global styles for dark mode */
&:has([data-color-mode='dark']) {
input & {
color-scheme: dark;
}
}
/*
* PERFORMANCE: Removed :has([data-color-mode]) selectors that scanned entire DOM.
* Input color-scheme is already handled by global selectors above:
* [data-color-mode='light'] input { color-scheme: light; }
* [data-color-mode='dark'] input { color-scheme: dark; }
*/

/* Low-specificity default link styling */
:where(a:not([class*='prc-']):not([class*='PRC-']):not([class*='Primer_Brand__'])) {
Expand Down
11 changes: 11 additions & 0 deletions stylelint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ export default {
},
],
'layer-name-pattern': '^[a-z][a-zA-Z0-9.-]*$',
// :has() can cause severe perf issues in Safari (quadratic style invalidation).
// See github/github-ui#17224 for audit. Existing usages are audited and scoped.
// New usages must be explicitly approved and marked with a stylelint-disable comment.
'selector-pseudo-class-disallowed-list': [
['has'],
{
severity: 'error',
message:
':has() selectors can cause severe Safari performance issues (github/github-ui#17224). Verify the selector is scoped (CSS Modules) and does not match broadly, then add a scoped stylelint disable (e.g. "stylelint-disable-next-line" or a minimal "stylelint-disable"/"stylelint-enable" block), not a file-level disable.',
},
],
},
overrides: [
{
Expand Down
Loading