CSS :has() — de ouder die weet wat er binnenin gebeurt
CSS :has() — de ouder die weet wat er binnenin gebeurt
Je hebt een formulierveld: een <div class="field"> met een label en een input erbinnen. De eis klinkt banaal – zodra de input ongeldig is, moet de hele container een rode rand krijgen en een waarschuwingsicoon naast het label, niet alleen de input zelf. Je grijpt naar .field input:invalid – en loopt tegen een muur op. Die selector zou de input stylen, als die een rode rand had gewild. Maar jij wilt .field stylen, dus de ouder van die input, op basis van wat er in zijn binnenste gebeurt. En dan blijkt opeens dat CSS – ondanks tientallen combinators, pseudo-classes en attribuutselectors – in zijn 25-jarige geschiedenis nooit een manier heeft geboden om dat te doen.
Dat is geen gat in jouw kennis van CSS. Het is een fundamenteel kenmerk van de selectorarchitectuur, dat gold vanaf CSS1 tot 2022.
Waarom een combinator altijd maar één kant op kijkt
Elke combinator in CSS – een spatie (nakomeling), > (direct kind), + (aangrenzend broertje/zusje), ~ (algemeen broertje/zusje) – beschrijft een relatie tussen twee selectors, maar wordt altijd het element rechts van de combinator gestyled. .field input styled de input binnenin .field. .field ~ .error styled .error, dat een broertje/zusje is van .field. De richting is altijd dezelfde: van context naar doel, nooit omgekeerd. De CSS-engine heeft, wanneer hij .field tegenkomt, geen ingebouwd mechanisme om "naar binnen te kijken" en op basis daarvan een stylingsbeslissing over .field zelf te veranderen.
Ontwikkelaars omzeilden dit jarenlang op twee manieren, allebei met reële kosten. De eerste – JavaScript dat luistert naar het input/blur-evenement en handmatig een klasse .field--invalid aan de ouder toevoegt. De tweede, slimmer maar broos – de zogeheten checkbox/radio-hack, die ~ gebruikt om een broertje/zusje te stylen op basis van de :checked-status, wat alleen werkt als de elementen daadwerkelijk broertjes/zusjes zijn in een platte DOM-structuur, en niet genest zoals een echt formulier vereist.
jsx
// Workaround van vóór :has() – werkt, maar vereist JS voor iets// wat een pure visuele consequentie is van HTML-statusimport { 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() elimineert deze code niet omdat het een slimmere truc is – maar omdat het het probleem bij de bron oplost, als eerste relationele pseudo-class in de geschiedenis van CSS, die een element toestaat om naar zijn eigen binnenste te vragen.
Hoe :has() écht werkt: een selector verankerd in het element, niet in de nakomeling
De cruciale denkverschuiving: .field:has(input:invalid) styled niet de input. Het styled .field – precies het element waaraan de pseudo-class is bevestigd – op voorwaarde dat er ergens in zijn binnenste een element bestaat dat overeenkomt met de selector tussen de haakjes. Het element waarop je "verankert" (het anchor element), blijft altijd hetzelfde; :has() beslist alleen of er überhaupt een match is.
css
.field:has(input:invalid) {border-color: #c62828;}.field:has(input:invalid) .field__icon {visibility: visible;}
Standaard zoekt de selector binnen :has() naar elke nakomeling, op elke diepte – net als een gewone spatie. Maar je kunt de relatie precies zo vernauwen als in gewone CSS, door combinators rechtstreeks tussen de haakjes te gebruiken:
css
/* Alleen direct kind, niet een willekeurige nakomeling */.card:has(> img) {grid-template-columns: 120px 1fr;}/* Element dat DIRECT wordt gevolgd door .field-hint als broertje/zusje */.field:has(+ .field-hint) {margin-bottom: 4px;}
Dat maakt van :has() geen nieuwe losse truc, maar een generalisatie – het maakt het mogelijk om in CSS elke relatie uit te drukken die je voorheen met een combinator kon beschrijven, alleen nu gericht "naar binnen" of "achterwaarts", in plaats van alleen "voorwaarts".
Praktisch voorbeeld: een formulierveld dat zelf weet dat het ongeldig is
Laten we dit combineren tot een compleet, toegankelijk component. Belangrijk detail: :invalid op zich treedt al meteen in werking zodra een leeg veld met het attribuut required wordt geladen – nog voordat de gebruiker iets heeft kunnen typen, wat een rode rand zou geven op een leeg, ongebruikt formulier. :not(:placeholder-shown) lost dit op door alleen een veld te matchen wanneer de gebruiker er daadwerkelijk al iets in heeft achtergelaten.
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;}/* Het veld is "aangeraakt" (er is iets ingetypt) EN ongeldig – pas dan reageren */.field:has(input:not(:placeholder-shown):invalid) {border-color: #c62828;background: #fff5f5;}.field:has(input:not(:placeholder-shown):invalid) .field__icon {visibility: visible;}/* Een positieve bevestiging is even belangrijk als een fout */.field:has(input:not(:placeholder-shown):valid) {border-color: #2e7d32;}
Geen enkele regel JavaScript is verantwoordelijk voor het uiterlijk. De validatiestatus bestaat al native in de browser – :valid/:invalid zijn er al sinds CSS3 – :has() heeft simpelweg eindelijk mogelijk gemaakt om die bestaande status van de input naar zijn container te verplaatsen, precies waar hij visueel altijd al nodig was.
Tweede patroon: een component dat weet wat het bevat
Hetzelfde mechanisme lost een compleet andere klasse problemen op – layout die afhangt van de aanwezigheid van specifieke inhoud, zonder een prop die dat van tevoren meldt.
css
/* Een kaart met afbeelding krijgt een tweekoloms layout, een kaart zonder afbeelding de volle tekstbreedte */.card:has(> img) {display: grid;grid-template-columns: 96px 1fr;gap: 12px;}/* De sectiekop krijgt alleen ruimte onderaan als de sectie daadwerkelijk een ondertitel heeft */.section:has(> .section__subtitle) .section__title {margin-bottom: 4px;}/* Een lijst zonder elementen toont een lege-status in plaats van lege ruimte */.product-list:not(:has(li)) {display: block;}.product-list:not(:has(li))::after {content: "Geen producten die aan de criteria voldoen.";color: #666;}
Zonder :has() zou elk van deze gevallen ofwel een prop vereisen (hasImage, isEmpty) die het bovenliggende component handmatig instelt, ofwel het controleren van de lengte van een array in de JSX en het voorwaardelijk renderen van een aparte klasse. :has() stelt een component in staat om zelf zijn eigen inhoud te herkennen en daarop te reageren, in plaats van er van buitenaf over geïnformeerd te worden – exact dezelfde denkrichting als de productkaart die zich bewust is van haar eigen container uit het artikel over Container Queries: minder handmatig doorgegeven state, meer logica die rechtstreeks uit de structuur volgt.
Valkuilen en goede praktijken
- De specificiteit van
:has()is die van de meest specifieke selector erbinnen, niet nul..field:has(input:invalid)heeft de specificiteit van twee klassen en een pseudo-class samen – reken er niet op dat:has()"niet meetelt" voor de specificiteit, want in de praktijk verslaat het regelmatig eenvoudigere regels die later in het stylesheet staan. - Diep geneste, brede
:has()zonder combinator kan kosten met zich meebrengen..app:has(.some-deeply-nested-element)dwingt de engine in theorie om bij elke DOM-wijziging binnen.appde hele subboom te overwegen. Moderne engines (Chromium, WebKit) optimaliseren dit via zogeheten invalidation sets – ze herberekenen niet blindelings alles – maar het effect hangt af van de specifieke selector en de grootte van de boom. Vernauw de relatie met een combinator (>, direct kind) waar mogelijk, in plaats van standaard diep te zoeken. :has()vervangt:focus-withinniet. Als je alleen nodig hebt dat "de ouder reageert wanneer een nakomeling focus heeft", bestaat:focus-withinal lang, is goedkoper te berekenen en leesbaarder –:has(:focus)geeft in de praktijk hetzelfde effect, maar zonder reden om naar het algemenere instrument te grijpen waar er al een gespecialiseerd instrument bestaat.- Browserondersteuning is geen probleem meer.
:has()was het laatste grote puzzelstukje – Safari had het al vanaf 15.4 (maart 2022), Chrome vanaf 105 (augustus 2022), Firefox sloot als laatste aan, in versie 121 (december 2023). Sindsdien is het veilig te gebruiken zonder@supportsin elk nieuw project.
Samenvatting
25 jaar lang liet CSS alleen relaties toe die in één richting wezen – van context naar doel, van ouder naar kind, van voorganger naar opvolger. :has() is geen zoveelste variant van hetzelfde idee, maar de eerste pseudo-class die een element toestaat om naar zijn eigen binnenste te vragen en te reageren op wat het daar aantreft – een validatiefout in een veld, de aanwezigheid van een afbeelding in een kaart, het ontbreken van elementen in een lijst. Dat verschuift een hele klasse beslissingen, die voorheen in JavaScript of in handmatig van bovenaf doorgegeven props moesten belanden, terug naar waar ze horen: de HTML-structuur en de CSS-regels die die structuur beschrijven.