CSS und Layouts

CSS :has() — der Elternteil, der weiß, was im Inneren passiert

CSS :has() — der Elternteil, der weiß, was im Inneren passiert

Du hast ein Formularfeld: ein <div class="field"> mit einem Label und einem Input darin. Die Anforderung klingt banal – wenn der Input ungültig ist, soll der gesamte Container einen roten Rahmen und ein Warnsymbol neben dem Label bekommen, nicht nur der Input selbst. Du greifst zu .field input:invalid – und stößt an eine Wand. Dieser Selektor würde den Input stylen, wenn er einen roten Rahmen wollte. Du willst aber .field stylen, also das Elternelement dieses Inputs, basierend darauf, was in seinem Inneren passiert. Und plötzlich stellt sich heraus, dass CSS – trotz Dutzender Kombinatoren, Pseudo-Klassen und Attributselektoren – in seiner 25-jährigen Geschichte nie einen Weg geboten hat, das zu tun.

Das ist keine Lücke in deinem CSS-Wissen. Das ist ein grundlegendes Merkmal der Selektor-Architektur, das von CSS1 bis 2022 galt.

Warum ein Kombinator immer nur in eine Richtung schaut

Jeder Kombinator in CSS – das Leerzeichen (Nachfahre), > (direktes Kind), + (unmittelbares Geschwister), ~ (allgemeines Geschwister) – beschreibt eine Beziehung zwischen zwei Selektoren, aber gestylt wird immer das Element rechts vom Kombinator. .field input stylt den Input innerhalb von .field. .field ~ .error stylt .error, das ein Geschwister von .field ist. Die Richtung ist immer dieselbe: vom Kontext zum Ziel, niemals umgekehrt. Die CSS-Engine hat, wenn sie auf .field trifft, keinen eingebauten Mechanismus, um „ins Innere zu schauen" und auf dieser Grundlage eine Style-Entscheidung für .field selbst zu treffen.

Entwickler haben das über Jahre auf zwei Arten umgangen, beide mit realen Kosten. Erstens JavaScript, das auf das Ereignis input/blur lauscht und dem Elternelement manuell eine Klasse .field--invalid anhängt. Zweitens, cleverer, aber fragil – der sogenannte Checkbox/Radio-Hack, der ~ nutzt, um Geschwister auf Basis des :checked-Status zu stylen, was aber nur funktioniert, wenn die Elemente tatsächlich Geschwister in einer flachen DOM-Struktur sind, und nicht so verschachtelt, wie es ein echtes Formular verlangt.

jsx

// Workaround vor :has() – funktioniert, braucht aber JS für etwas,
// das eine rein visuelle Konsequenz des HTML-Zustands ist
import { useState } from "react";
const FormField = ({ label, ...inputProps }) => {
const [isInvalid, setIsInvalid] = useState(false);
return (
<div className={`field ${isInvalid ? "field--invalid" : ""}`}>
<label>{label}</label>
<input
{...inputProps}
onBlur={(e) => setIsInvalid(!e.target.validity.valid)}
/>
</div>
);
};

:has() macht diesen Code nicht deshalb überflüssig, weil es ein cleverer Trick ist – sondern weil es das Problem an der Wurzel löst, als erste relationale Pseudo-Klasse in der Geschichte von CSS, die es einem Element erlaubt, nach seinem eigenen Inneren zu fragen.

Wie :has() wirklich funktioniert: ein am Element verankerter Selektor, nicht am Nachfahren

Die entscheidende Denkverschiebung: .field:has(input:invalid) stylt nicht den Input. Es stylt .field – genau das Element, an dem die Pseudo-Klasse hängt – vorausgesetzt, dass irgendwo in seinem Inneren ein Element existiert, das zum Selektor innerhalb der Klammer passt. Das Element, an dem du dich „festmachst" (das Anchor-Element), bleibt immer dasselbe; :has() entscheidet nur, ob es überhaupt eine Übereinstimmung bekommt.

css

.field:has(input:invalid) {
border-color: #c62828;
}
.field:has(input:invalid) .field__icon {
visibility: visible;
}

Standardmäßig sucht der Selektor innerhalb von :has() nach einem beliebigen Nachfahren, auf beliebiger Tiefe – genau wie ein einfaches Leerzeichen. Du kannst die Beziehung aber genauso einschränken wie in gewöhnlichem CSS, indem du Kombinatoren direkt innerhalb der Klammer verwendest:

css

/* Nur direktes Kind, nicht ein beliebiger Nachfahre */
.card:has(> img) {
grid-template-columns: 120px 1fr;
}
/* Element, auf das UNMITTELBAR .field-hint als Geschwister folgt */
.field:has(+ .field-hint) {
margin-bottom: 4px;
}

Das macht :has() nicht zu einem weiteren, neuen Trick, sondern zu einer Verallgemeinerung – es erlaubt, in CSS jede Beziehung auszudrücken, die sich zuvor mit einem Kombinator beschreiben ließ, nur eben „nach innen" oder „rückwärts" gerichtet, statt ausschließlich „nach vorne".

Praktisches Beispiel: ein Formularfeld, das selbst weiß, dass es ungültig ist

Verbinden wir das zu einer vollständigen, barrierefreien Komponente. Wichtiges Detail: :invalid allein greift sofort nach dem Laden eines leeren Feldes mit required-Attribut – noch bevor der Nutzer überhaupt etwas eingetippt hat, was einen roten Rahmen auf einem leeren, unberührten Formular ergäbe. Das löst :not(:placeholder-shown), das ein Feld nur dann trifft, wenn der Nutzer tatsächlich schon etwas darin hinterlassen hat.

jsx

const FormField = ({ label, id, ...inputProps }) => (
<div className="field">
<label htmlFor={id}>{label}</label>
<input id={id} {...inputProps} />
<svg className="field__icon" aria-hidden="true" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 12H9v-2h2v2zm0-4H9V5h2v5z" />
</svg>
</div>
);
export default FormField;

css

.field {
position: relative;
border: 1px solid #ccc;
border-radius: 8px;
padding: 8px 12px;
transition: border-color 0.15s ease;
}
.field__icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 18px;
fill: #c62828;
visibility: hidden;
}
/* Das Feld wurde "berührt" (etwas eingegeben) UND ist ungültig – erst dann reagieren */
.field:has(input:not(:placeholder-shown):invalid) {
border-color: #c62828;
background: #fff5f5;
}
.field:has(input:not(:placeholder-shown):invalid) .field__icon {
visibility: visible;
}
/* Positive Bestätigung ist genauso wichtig wie ein Fehler */
.field:has(input:not(:placeholder-shown):valid) {
border-color: #2e7d32;
}

Keine einzige Zeile JavaScript, die für das Aussehen verantwortlich ist. Der Validierungszustand existiert bereits nativ im Browser – :valid/:invalid gibt es seit CSS3 –, :has() hat lediglich endlich erlaubt, diesen bereits existierenden Zustand vom Input auf seinen Container zu übertragen, dorthin, wo er visuell schon immer gebraucht wurde.

Zweites Muster: eine Komponente, die weiß, was sie enthält

Derselbe Mechanismus löst eine ganz andere Klasse von Problemen – ein Layout, das von der Anwesenheit bestimmter Inhalte abhängt, ohne einen Prop, der das von oben mitteilt.

css

/* Karte mit Bild bekommt ein zweispaltiges Layout, Karte ohne Bild – volle Textbreite */
.card:has(> img) {
display: grid;
grid-template-columns: 96px 1fr;
gap: 12px;
}
/* Abschnittsüberschrift bekommt nur dann Abstand nach unten, wenn der Abschnitt tatsächlich einen Untertitel hat */
.section:has(> .section__subtitle) .section__title {
margin-bottom: 4px;
}
/* Eine Liste ohne jedes Element zeigt einen leeren Zustand statt leeren Raum */
.product-list:not(:has(li)) {
display: block;
}
.product-list:not(:has(li))::after {
content: "Keine Produkte gefunden, die den Kriterien entsprechen.";
color: #666;
}

Ohne :has() würde jeder dieser Fälle entweder einen Prop (hasImage, isEmpty) erfordern, der manuell von der übergeordneten Komponente gesetzt wird, oder eine Prüfung der Array-Länge im JSX mit bedingtem Rendern einer separaten Klasse. :has() erlaubt einer Komponente, ihren eigenen Inhalt selbst zu erkennen und darauf zu reagieren, statt von außen darüber informiert zu werden – genau dieselbe Denkrichtung wie bei der ihren eigenen Container kennenden Produktkarte aus dem Artikel über Container Queries: weniger manuell übergebener State, mehr Logik, die direkt aus der Struktur folgt.

Fallstricke und Best Practices

  • Die Spezifität von :has() ist die Spezifität des spezifischsten Selektors darin, nicht null. .field:has(input:invalid) hat die Spezifität zweier Klassen und einer Pseudo-Klasse zusammen – verlass dich nicht darauf, dass :has() „nicht zur Spezifität zählt", denn in der Praxis schlägt es regelmäßig einfachere Regeln, die später im Stylesheet stehen.
  • Tief verschachteltes, breites :has() ohne Kombinator kann etwas kosten. .app:has(.some-deeply-nested-element) zwingt die Engine theoretisch dazu, bei jeder DOM-Änderung im Inneren den gesamten Unterbaum von .app zu berücksichtigen. Moderne Engines (Chromium, WebKit) optimieren das durch sogenannte Invalidation Sets – sie berechnen nicht blind alles neu –, aber der Effekt hängt vom konkreten Selektor und der Größe des Baums ab. Schränke die Beziehung wo möglich mit einem Kombinator ein (>, direktes Kind), statt dich auf die standardmäßige Tiefensuche zu verlassen.
  • :has() ersetzt :focus-within nicht. Brauchst du nur „Elternteil reagiert, wenn ein Nachfahre den Fokus hat", gibt es :focus-within schon lange, es ist günstiger zu berechnen und lesbarer – :has(:focus) liefert praktisch denselben Effekt, aber ohne Grund, zum allgemeineren Werkzeug zu greifen, wo bereits ein spezialisiertes existiert.
  • Die Browser-Unterstützung ist kein Problem mehr. :has() war das letzte große Puzzlestück – Safari hatte es seit 15.4 (März 2022), Chrome seit 105 (August 2022), Firefox kam als Letzter dazu, in Version 121 (Dezember 2023). Seitdem ist es in jedem neuen Projekt ohne @supports sicher nutzbar.

Fazit

25 Jahre lang erlaubte CSS nur Beziehungen zu beschreiben, die in eine Richtung zeigten – vom Kontext zum Ziel, vom Elternteil zum Kind, vom Vorgänger zum Nachfolger. :has() ist keine weitere Variante derselben Idee, sondern die erste Pseudo-Klasse, die es einem Element erlaubt, nach seinem eigenen Inneren zu fragen und darauf zu reagieren, was es dort findet – ein Validierungsfehler in einem Feld, das Vorhandensein eines Bildes in einer Karte, das Fehlen von Elementen in einer Liste. Das verschiebt eine ganze Klasse von Entscheidungen, die zuvor in JavaScript oder in von oben manuell übergebene Props wandern mussten, zurück dorthin, wo sie hingehören: in die HTML-Struktur und die CSS-Regeln, die diese Struktur beschreiben.