Style Parent Elements Conditionally with the CSS :has() Selector

The CSS :has() pseudo-class — often called the “parent selector” — allows a CSS rule to select an element based on the presence, state, or attributes of its descendants or subsequent siblings, something previously possible only with JavaScript DOM manipulation. :has() is supported in all evergreen browsers since 2023 (Chrome 105, Safari 15.4, Firefox 121) and enables entirely new patterns in WordPress theme CSS: a .card:has(img) rule styles cards differently when they contain an image; form:has(:invalid) highlights a form when any of its inputs are in an invalid state; section:has(> h2) adds padding only to sections that have a direct-child heading. For WordPress specifically, useful applications include styling .wp-block-group containers based on which child blocks they contain, adjusting navigation menus based on whether they include a search icon, applying a full-bleed layout to .entry-content when it directly contains a .wp-block-cover, and showing or hiding widget areas based on whether they have widget content. :has() can be combined with other selectors and pseudo-classes: .parent:has(> .child:hover) highlights the parent when a direct child is hovered; li:has(+ li:last-child) selects the second-to-last list item using the adjacent sibling combinator inside :has(). The relative selector syntax inside :has() uses the scope element (:scope) implicitly, so :has(img) means “has a descendant img”, while :has(> img) means “has a direct-child img” — the distinction matters for deeply nested WordPress block structures where a cover block and a paragraph both contain images at different nesting depths. Performance consideration: :has() with complex descendant selectors can be slower than simple class selectors because the browser must evaluate the inner selector for every candidate — avoid :has(*) or :has([class]) on high-frequency elements. The container queries post combines naturally with :has(): a container can use :has() to apply different containment styles based on its content.

Problem: A WordPress theme needs to apply different visual treatments to post cards based on whether they have a thumbnail, whether the post has no excerpt, and whether the entry content contains a code block — currently solved with PHP conditional classes added via post_class filter, resulting in a growing list of modifier classes and JavaScript for interactive states.

Solution: Replace the PHP-generated conditional classes with CSS :has() rules that detect the structural conditions directly in the stylesheet — reducing PHP logic and JavaScript and making the intent self-documenting in the CSS.

/* ── Post card: different layout when it has a thumbnail ─────────────────── */
.post-card:has(.post-thumbnail) {
    grid-template-columns: 200px 1fr;
}
/* No thumbnail: single-column, extra top padding for the title */
.post-card:not(:has(.post-thumbnail)) .post-card__title {
    padding-top: var(--space-md);
}

/* ── Highlight a form when any field is invalid ────────────────────────── */
.comment-form:has(:invalid:not(:placeholder-shown)) {
    outline: 2px solid var(--color-error);
    outline-offset: 4px;
}
/* Show submit button only when all required fields are valid */
.comment-form:has(:required:invalid) .submit-btn {
    opacity: 0.5;
    pointer-events: none;
}

/* ── WordPress block: full-bleed when entry directly wraps a cover block ─── */
.entry-content:has(> .wp-block-cover) {
    padding-inline: 0;
    max-width: none;
}

/* ── Navigation: reduce gap when it contains the search block ─────────────── */
.wp-block-navigation:has(.wp-block-search) {
    gap: var(--space-sm);
}

/* ── Sidebar: hide entirely when it has no widgets ─────────────────────── */
.widget-area:not(:has(.widget)) {
    display: none;
}

/* ── Table of contents: sticky only when section has enough content ─────── */
.entry-layout:has(.toc-list li:nth-child(4)) .toc-panel {
    position: sticky;
    top: var(--header-height, 64px);
}

/* ── Hover on child propagates to parent ─────────────────────────────────── */
.post-grid .post-card:has(a:hover) {
    box-shadow: 0 4px 20px rgb(0 0 0 / 0.12);
    transform: translateY(-2px);
    transition: box-shadow 0.2s ease, transform 0.2s ease;
}

/* ── Adjacent sibling detection: style penultimate nav item ──────────────── */
.wp-block-navigation-item:has(+ .wp-block-navigation-item:last-child) {
    border-right: 1px solid var(--color-border);
}

NOTE: In Firefox, :has() support arrived in version 121 (released December 2023) — before that, Firefox required enabling it via layout.css.has-selector.enabled in about:config. Use @supports selector(:has(a)) to feature-detect :has() support and provide fallback styles for older Firefox versions that are still in your analytics. The @supports block approach ensures the baseline layout works without :has() and the enhanced layout progressively layers on top.