Skip to content

Style a Form Group When a Field Is Invalid with :has()

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.

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.

  1. Selector: Use .parent:has(:user-invalid) to target the container.
  2. Scope: Be specific to avoid performance issues. Target .field-group rather than body.
  3. Fallback: Requires JS to toggle classes on the parent if :has() is not supported.
<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>
/* 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).

Baseline: user-pseudos

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;
}

Baseline: user-pseudos / javascript-fallback

// 1. Initialize the generic fallback
const 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');
});
});
}
  1. 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.

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.