CSS :has() — forælderen, der ved, hvad der sker indeni
CSS :has() — forælderen, der ved, hvad der sker indeni
Du har et formularfelt: <div class="field"> med en label og et input indeni. Kravet lyder banalt – når inputtet er ugyldigt, skal hele containeren have en rød kant og et advarselsikon ved siden af labelen, ikke kun selve inputtet. Du griber til .field input:invalid – og rammer en mur. Den selektor ville style inputtet, hvis det skulle have en rød kant. Men du vil style .field, altså forælderen til det input, baseret på, hvad der sker i dens indre. Og pludselig viser det sig, at CSS – på trods af snesevis af kombinatorer, pseudoklasser og attributselektorer – aldrig i sin 25-årige historie har givet en måde at gøre det på.
Det er ikke en mangel i din CSS-viden. Det er et grundlæggende træk ved selektor-arkitekturen, som gjaldt fra CSS1 helt frem til 2022.
Hvorfor en kombinator altid kun kigger én vej
Enhver kombinator i CSS – mellemrum (efterkommer), > (direkte barn), + (nabo-søskende), ~ (generel søskende) – beskriver en relation mellem to selektorer, men det er altid elementet til højre for kombinatoren, der bliver stylet. .field input styler inputtet inde i .field. .field ~ .error styler .error, som er søskende til .field. Retningen er altid den samme: fra kontekst til mål, aldrig omvendt. CSS-motoren har, når den støder på .field, ingen indbygget mekanisme til at "kigge indeni" og på det grundlag ændre beslutningen om, hvordan .field selv skal stylet.
Udviklere har i årevis omgået det på to måder, begge med reelle omkostninger. Den første – JavaScript, der lytter efter input/blur-hændelsen og manuelt tilføjer klassen .field--invalid til forælderen. Den anden, mere snedig, men skrøbelig – det såkaldte checkbox/radio-hack, der bruger ~ til at style søskende baseret på :checked-tilstanden, hvilket kun virker, når elementerne faktisk er søskende i en flad DOM-struktur, og ikke indlejret, sådan som en rigtig formular kræver det.
jsx
// Løsningen fra før :has() – virker, men kræver JS til noget,// der er en ren visuel konsekvens af HTML-tilstandenimport { 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() eliminerer den kode, ikke fordi den er et smartere trick – men fordi den løser problemet ved kilden, som den første relationelle pseudoklasse i CSS' historie, der lader et element spørge til sit eget indre.
Sådan virker :has() egentlig: en selektor forankret i elementet, ikke i efterkommeren
Det centrale skifte i tankegangen: .field:has(input:invalid) styler ikke inputtet. Den styler .field – præcis det element, pseudoklassen er hæftet på – forudsat at der et sted i dets indre findes et element, der matcher selektoren inde i parentesen. Det element, du "forankrer" på (det såkaldte anchor element), forbliver altid det samme; :has() afgør blot, om det overhovedet får et match.
css
.field:has(input:invalid) {border-color: #c62828;}.field:has(input:invalid) .field__icon {visibility: visible;}
Som standard leder selektoren inde i :has() efter en vilkårlig efterkommer, i vilkårlig dybde – ligesom et almindeligt mellemrum. Men du kan indsnævre relationen på præcis samme måde som i almindelig CSS, ved at bruge kombinatorer direkte inde i parentesen:
css
/* Kun et direkte barn, ikke en vilkårlig efterkommer */.card:has(> img) {grid-template-columns: 120px 1fr;}/* Elementet, der UMIDDELBART efterfølges af .field-hint som søskende */.field:has(+ .field-hint) {margin-bottom: 4px;}
Det gør, at :has() ikke er endnu et nyt trick, men en generalisering – den lader dig udtrykke i CSS enhver relation, du tidligere kunne beskrive med en kombinator, blot rettet "indad" eller "bagud" i stedet for udelukkende "fremad".
Praktisk eksempel: et formularfelt, der selv ved, at det er ugyldigt
Lad os samle det til en komplet, tilgængelig komponent. En vigtig detalje: :invalid udløses i sig selv straks, når et tomt felt med attributten required indlæses – før brugeren overhovedet har nået at skrive noget, hvilket ville give en rød kant på en tom, urørt formular. Det løses med :not(:placeholder-shown), som kun matcher feltet, når brugeren rent faktisk allerede har efterladt noget i det.
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;}/* Feltet er "rørt" (der er skrevet noget) OG ugyldigt – reager først da */.field:has(input:not(:placeholder-shown):invalid) {border-color: #c62828;background: #fff5f5;}.field:has(input:not(:placeholder-shown):invalid) .field__icon {visibility: visible;}/* Positiv bekræftelse er lige så vigtig som en fejl */.field:has(input:not(:placeholder-shown):valid) {border-color: #2e7d32;}
Ikke en eneste linje JavaScript er ansvarlig for udseendet. Valideringstilstanden findes allerede nativt i browseren – :valid/:invalid har eksisteret siden CSS3 – :has() gjorde det bare endelig muligt at flytte den eksisterende tilstand fra inputtet til dets container, dér hvor den visuelt altid har været nødvendig.
Det andet mønster: en komponent, der ved, hvad den indeholder
Den samme mekanisme løser en helt anden klasse af problemer – layout, der afhænger af tilstedeværelsen af bestemt indhold, uden en prop, der fortæller det på forhånd.
css
/* Et kort med billede får et tokolonnelayout, et kort uden billede får fuld tekstbredde */.card:has(> img) {display: grid;grid-template-columns: 96px 1fr;gap: 12px;}/* Sektionens overskrift får afstand til bunden kun, hvis sektionen faktisk har en undertitel */.section:has(> .section__subtitle) .section__title {margin-bottom: 4px;}/* En liste uden et eneste element viser en tom-tilstand i stedet for tomt rum */.product-list:not(:has(li)) {display: block;}.product-list:not(:has(li))::after {content: "Ingen produkter opfylder kriterierne.";color: #666;}
Uden :has() ville hvert af disse tilfælde kræve enten en prop (hasImage, isEmpty), sat manuelt af den overordnede komponent, eller kontrol af array-længden i JSX'en og betinget rendering af en separat klasse. :has() lader komponenten selv genkende sit eget indhold og reagere, i stedet for at blive fortalt om det udefra – det er præcis den samme tankegang som det produktkort, der kender sin egen container, fra artiklen om Container Queries: mindre tilstand overført manuelt, mere logik, der følger direkte af strukturen.
Faldgruber og god praksis
- Specificiteten af
:has()er specificiteten af den mest specifikke selektor indeni, ikke nul..field:has(input:invalid)har specificiteten af to klasser og en pseudoklasse tilsammen – regn ikke med, at:has()"ikke tæller" i specificiteten, for i praksis slår den regelmæssigt simplere regler skrevet senere i arket. - Dybt indlejrede, brede
:has()-kald uden kombinator kan koste noget..app:has(.some-deeply-nested-element)beder i teorien motoren om at overveje hele undertræet af.appved enhver DOM-ændring i dets indre. Moderne motorer (Chromium, WebKit) optimerer dette gennem såkaldte invalidation sets – de genberegner ikke alt blindt – men effekten afhænger af den konkrete selektor og træets størrelse. Indsnævr relationen med en kombinator (>, direkte barn), hvor det er muligt, i stedet for standardsøgning i dybden. :has()erstatter ikke:focus-within. Hvis du kun har brug for "forælderen reagerer, når en efterkommer har fokus", har:focus-withineksisteret længe, er billigere at beregne og mere læsbart –:has(:focus)giver praktisk talt den samme effekt, men uden grund til at gribe til det mere generelle værktøj, hvor det specialiserede allerede findes.- Browserunderstøttelse er ikke længere et problem.
:has()var den sidste store brik i puslespillet – Safari havde den fra 15.4 (marts 2022), Chrome fra 105 (august 2022), Firefox kom med sidst, i version 121 (december 2023). Siden da er den sikker at bruge uden@supportsi ethvert nyt projekt.
Opsummering
I 25 år tillod CSS kun at beskrive relationer rettet én vej – fra kontekst til mål, fra forælder til barn, fra forgænger til efterfølger. :has() er ikke endnu en variant af den samme idé, men den første pseudoklasse, der lader et element spørge til sit eget indre og reagere på, hvad det finder der – en valideringsfejl i et felt, tilstedeværelsen af et billede i et kort, fravær af elementer i en liste. Det flytter en hel klasse af beslutninger, der tidligere måtte havne i JavaScript eller i props sendt manuelt oppefra, tilbage dér, hvor de hører hjemme: i HTML-strukturen og de CSS-regler, der beskriver den struktur.