Style a Form Group When a Field Is Invalid with :has()
The Problem
Section titled “The Problem”Often, an error state requires styling elements outside the input itself—for example, changing the color of a parent fieldset border, highlighting the <label>, or showing a global error icon in the card header. Historically, this required JavaScript to toggle classes on parent elements.
The Solution
Section titled “The Solution”By combining :has() with :user-invalid, we can declaratively style any ancestor based on the validity state of a specific descendant. This keeps all presentation logic in CSS.
Implementation Strategy
Section titled “Implementation Strategy”- Selector: Use
.parent:has(:user-invalid)to target the container. - Scope: Be specific to avoid performance issues. Target
.field-grouprather thanbody. - Fallback: Requires JS to toggle classes on the parent if
:has()is not supported.
Implementation Guide
Section titled “Implementation Guide”1. HTML Structure
Section titled “1. HTML Structure”<form> <div class="card-section"> <div class="header"> <h3>Profile Settings</h3> <span class="status-icon"></span> </div>
<div class="field"> <label for="username">Username</label> <input type="text" id="username" required> </div> </div></form>2. CSS
Section titled “2. CSS”/* Default State */.card-section { border: 1px solid #ccc; border-left: 4px solid #ccc;}
/* Parent Styling Logic: If the card contains ANY user-invalid input, turn the whole card's edge red.*/.card-section:has(:user-invalid) { border-left-color: #d93025; background-color: #fff8f8;}
/* Change the icon too */.card-section:has(:user-invalid) .status-icon::after { content: "⚠️" / "Error";}Parent styling only adds emphasis: it doesn’t say which field is wrong or why, so each field still needs its own visible error text. See required-field-feedback (guide not yet published on this site).
Fallbacking & Browser Support
Section titled “Fallbacking & Browser Support”Baseline: user-pseudos
CSS for Fallback
Section titled “CSS for Fallback”We use a class .has-error on the parent to mimic the :has() behavior.
/* Native */.card-section:has(:user-invalid) { border-left-color: #d93025;}
/* Fallback */.card-section.has-error-fallback { border-left-color: #d93025;}JavaScript Fallback
Section titled “JavaScript Fallback”Baseline: user-pseudos / javascript-fallback
// 1. Initialize the generic fallbackconst form = document.querySelector('#demo-form');UserInvalidFallback.init(form);
// 2. Add specialized "parent styling" logic (Separate from fallback)const supportsHas = CSS.supports('selector(:has(a))');if (!supportsHas) { // Listen for changes to form validity after interaction form.addEventListener('blur', (e) => { if (!e.target.matches('input, select, textarea')) return;
// Find the container we want to style (sync with CSS) const container = e.target.closest('.card-section'); if (!container) return;
// Check if ANY input in this container is invalid. Match :user-invalid as well as the // fallback class: a browser can support :user-invalid but not :has(). const hasError = container.querySelector(':is(:user-invalid, .user-invalid-fallback)'); container.classList.toggle('has-error-fallback', !!hasError); }, true); // Capture phase to ensure we run after the fallback's blur listener
// Also handle input events for immediate cleanup form.addEventListener('input', (e) => { const container = e.target.closest('.card-section'); if (container) { const hasError = container.querySelector(':is(:user-invalid, .user-invalid-fallback)'); container.classList.toggle('has-error-fallback', !!hasError); } });
// A submit attempt flags every failing field through the invalid event, the only event // it fires on fields the user never touched. invalid doesn't bubble, so use the capture phase. form.addEventListener('invalid', (e) => { const container = e.target.closest('.card-section'); if (container) { const hasError = container.querySelector(':is(:user-invalid, .user-invalid-fallback)'); container.classList.toggle('has-error-fallback', !!hasError); } }, true);
// Handle form resets form.addEventListener('reset', () => { form.querySelectorAll('.has-error-fallback').forEach(el => { el.classList.remove('has-error-fallback'); }); });}Other Considerations
Section titled “Other Considerations”- Accessibility: Baseline: user-pseudos / aria-invalid
Compatibility notes: Badges like Baseline: … mark web features referenced by this guide. Baseline status describes availability across the core browser set — it does not mean every browser or older version supports the feature. Feature data as published upstream, synced 2026-11-12.
Live demo
Section titled “Live demo”Open the demo standalone · Demo source on GitHub
Counter-example (negative demo) — what this guide tells you to avoid.
Source: GoogleChrome/modern-web-guidance-src (Apache-2.0), upstream commit 179dbc6, synced 2026-11-12. Local changes: macros expanded to links/badges, first heading de-duplicated, demo and this footer added. This is an independent, unofficial site — not a Google publication.