Tagged: pseudo-element

  • Pseudo-elements are not valid inside :is(), :where(), and &

    A pseudo-element is not a valid selector inside :is() or :where(). As per the Selectors Level 4 spec:

    Pseudo-elements cannot be represented by the matches-any pseudo-class; they are not valid within :is().

    The nesting selector & behaves the same way. It desugars to the parent rule’s selector list wrapped in :is().

    However, neither of them fails loudly. :is() and :where() take a forgiving selector list, so an invalid argument is dropped and the rest of the rule carries on. Similarly, nesting inside a pseudo-element rule parses fine. The nested style rule just never matches anything, unless the parent list also contains non-pseudo-element selectors or there are bare declarations nested inside an at-rule.

    /* exmaple 1 */
    .card::before {
    	content: '';
    
    	.dark & {
    		color: red; /* `&` can't represent `.card::before`, so this matches nothing */
    	}
    }

    Either hoist the element out to the top level.

    /* exmaple 2 */
    .card::before {
    	content: '';
    }
    
    .dark .card::before {
    	color: red;
    }

    Or restructure so & refers to the element and the pseudo-element is nested in it.

    /* exmaple 3 */
    .card {
    	&::before {
    		content: '';
    	}
    
    	.dark &::before {
    		color: red;
    	}
    }

    A pseudo-element in the parent selector list doesn’t poison the whole list. & drops just that entry and still represents the rest.

    /* exmaple 4 */
    .card,
    .card::before {
    	color: black;
    
    	.dark & {
    		color: red; /* matches `.card`, but not `.card::before` */
    	}
    }

    The nested rule desugars to .dark :is(.card, .card::before), and the forgiving list throws out the pseudo-element, leaving .dark :is(.card). So the element turns red while its ::before stays black.

    From Chrome 130 / Safari 18.2 / Firefox 132 and onwards, bare declarations nested inside an at-rule are the exception. They do apply to the pseudo-element.

    /* exmaple 5 */
    .card::before {
    	content: '';
    	color: black;
    
    	@media (prefers-color-scheme: dark) {
    		color: white; /* applies to `.card::before` */
    	}
    }

    Wrap those same declarations in & { ... } and they stop applying.

    /* exmaple 6 */
    .card::before {
    	content: '';
    	color: black;
    
    	@media (prefers-color-scheme: dark) {
    		& {
    			color: white; /* matches nothing */
    		}
    	}
    }

    So why does example 6 not work?

    The reason is that declarations can’t sit loose inside a nested at-rule as far as the CSSOM is concerned. A CSSMediaRule exposes rules, and declarations only live on a style rule, so the parser has to wrap them in something. Examples 5 and 6 differ in what that something is.

    Example 5 wraps them in a nested declarations rule:

    It matches the exact same elements and pseudo-elements as its parent style rule, with the same specificity behavior.

    Whereas example 6 wraps them in & { ... } and & obeys selector rules: it desugars to :is(.card::before), which can’t represent the pseudo-element, so nothing matches.

    Further reading: