css og layouts

CSS :has() — forelderen som vet hva som skjer inni

Du har et skjemafelt: <div class="field"> med en etikett og en input inni. Kravet høres banalt ut – når input-en er ugyldig, skal hele beholderen få en rød ramme og et advarselsikon ved siden av etiketten, ikke bare selve input-en. Du griper til .field input:invalid – og treffer en vegg. Denne selektoren ville stylet input-en, hvis den ønsket en rød ramme. Men du vil style .field, altså forelderen til den input-en, basert på det som skjer inni den. Og plutselig viser det seg at CSS – til tross for dusinvis av kombinatorer, pseudoklasser og attributtselektorer – aldri i sin 25 år lange historie har gitt en måte å gjøre dette på.

Dette er ikke et hull i CSS-kunnskapen din. Det er en grunnleggende egenskap ved selektor-arkitekturen som gjaldt fra CSS1 helt til 2022.

Hvorfor en kombinator alltid bare ser én vei

Hver eneste kombinator i CSS – mellomrom (etterkommer), > (direkte barn), + (nabo-søsken), ~ (generelt søsken) – beskriver en relasjon mellom to selektorer, men det er alltid elementet til høyre for kombinatoren som stylees. .field input styler input-en inni .field. .field ~ .error styler .error, som er et søsken av .field. Retningen er alltid den samme: fra kontekst til mål, aldri motsatt. Når CSS-motoren møter .field, har den ingen innebygd mekanisme for å «kikke inni» og basert på det endre stylingbeslutningen for .field selv.

Utviklere har i årevis omgått dette på to måter, begge med reelle kostnader. Den første – JavaScript som lytter på input/blur-hendelsen og manuelt legger til klassen .field--invalid på forelderen. Den andre, smartere, men skjørere – det såkalte checkbox/radio-hacket, som bruker ~ til å style søsken basert på :checked-tilstanden, noe som bare fungerer når elementene faktisk er søsken i en flat DOM-struktur, ikke nøstet slik et reelt skjema krever.

jsx

// Løsningen fra før :has() – fungerer, men krever JS til noe
// som er en rent visuell konsekvens av HTML-tilstand
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() fjerner denne koden ikke fordi den er et smartere triks – men fordi den løser problemet ved roten, som den første relasjonelle pseudoklassen i CSS-historien, som lar et element spørre om sitt eget indre.

Hvordan :has() egentlig fungerer: en selektor forankret i elementet, ikke i etterkommeren

Det avgjørende tankeskiftet: .field:has(input:invalid) styler ikke input-en. Den styler .field – nøyaktig det elementet pseudoklassen er festet til – forutsatt at det et sted inni finnes et element som matcher selektoren inne i parentesen. Elementet du «forankrer» deg i (eng. anchor element) forblir alltid det samme; :has() avgjør bare om det i det hele tatt får et treff.

css

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

Som standard leter selektoren inni :has() etter en hvilken som helst etterkommer, på hvilket som helst dybdenivå – akkurat som et vanlig mellomrom. Men du kan innsnevre relasjonen nøyaktig som i vanlig CSS, ved å bruke kombinatorer rett inne i parentesen:

css

/* Kun direkte barn, ikke en hvilken som helst etterkommer */
.card:has(> img) {
grid-template-columns: 120px 1fr;
}
/* Element som DIREKTE etterfølges av .error-message som søsken */
.field:has(+ .field-hint) {
margin-bottom: 4px;
}

Dette gjør at :has() ikke er ett nytt triks, men en generalisering – den lar deg uttrykke i CSS enhver relasjon som tidligere kunne beskrives med en kombinator, bare rettet «innover» eller «bakover», i stedet for utelukkende «fremover».

Praktisk eksempel: et skjemafelt som selv vet at det er ugyldig

La oss sette dette sammen til en komplett, tilgjengelig komponent. Et viktig detalj: :invalid i seg selv utløses umiddelbart når et tomt felt med attributtet required lastes – før brukeren har rukket å skrive noe som helst, noe som ville gitt en rød ramme på et tomt, urørt skjema. Dette løses av :not(:placeholder-shown), som bare matcher feltet når brukeren faktisk allerede har lagt igjen noe 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 "berørt" (noe er skrevet inn) OG ugyldig – 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 bekreftelse er like viktig som en feilmelding */
.field:has(input:not(:placeholder-shown):valid) {
border-color: #2e7d32;
}

Ikke en eneste linje JavaScript ansvarlig for utseendet. Valideringstilstanden finnes allerede nativt i nettleseren – :valid/:invalid har vært der siden CSS3 – :has() lot oss endelig flytte denne eksisterende tilstanden fra input-en til beholderen, dit den visuelt alltid har vært nødvendig.

Det andre mønsteret: en komponent som vet hva den inneholder

Den samme mekanismen løser en helt annen klasse problemer – en layout som avhenger av tilstedeværelsen av bestemt innhold, uten en prop som informerer om det på forhånd.

css

/* Kort med bilde får et topolonneoppsett, kort uten bilde får full tekstbredde */
.card:has(> img) {
display: grid;
grid-template-columns: 96px 1fr;
gap: 12px;
}
/* Seksjonstittelen får bunnmarg bare hvis seksjonen faktisk har en undertittel */
.section:has(> .section__subtitle) .section__title {
margin-bottom: 4px;
}
/* En liste uten elementer viser en tom tilstand i stedet for tomrom */
.product-list:not(:has(li)) {
display: block;
}
.product-list:not(:has(li))::after {
content: "Ingen produkter oppfyller kriteriene.";
color: #666;
}

Uten :has() ville hvert av disse tilfellene krevd enten en prop (hasImage, isEmpty) satt manuelt av den overordnede komponenten, eller en sjekk av array-lengden i JSX-en og betinget rendering av en egen klasse. :has() lar komponenten selv gjenkjenne sitt eget innhold og reagere på det, i stedet for å bli fortalt om det utenfra – dette er nøyaktig samme tankegang som produktkortet som kjenner sin egen beholder fra artikkelen om Container Queries: mindre state overført manuelt, mer logikk som følger direkte av strukturen.

Fallgruver og gode praksiser

  • Spesifisiteten til :has() er spesifisiteten til den mest spesifikke selektoren inni, ikke null. .field:has(input:invalid) har spesifisiteten til to klasser og en pseudoklasse samlet – ikke regn med at :has() «ikke teller» i spesifisiteten, for i praksis overstyrer den regelmessig enklere regler skrevet senere i arket.
  • Dypt nøstet, bred :has() uten kombinator kan koste. .app:has(.some-deeply-nested-element) tvinger i teorien motoren til å vurdere hele undertreet til .app ved hver DOM-endring inni det. Moderne motorer (Chromium, WebKit) optimaliserer dette gjennom såkalte invalidation sets – de regner ikke ut alt blindt – men effekten avhenger av den konkrete selektoren og treets størrelse. Innsnevre relasjonen med en kombinator (>, direkte barn) der det er mulig, i stedet for standard dybdesøk.
  • :has() erstatter ikke :focus-within. Hvis du bare trenger «forelderen reagerer når en etterkommer har fokus», har :focus-within eksistert lenge, er billigere å beregne og mer lesbart – :has(:focus) gir praktisk talt samme effekt, men uten grunn til å gripe til et mer generelt verktøy der et spesialisert allerede finnes.
  • Nettleserstøtte har sluttet å være et problem. :has() landet som den siste store brikken i puslespillet – Safari hadde den fra 15.4 (mars 2022), Chrome fra 105 (august 2022), Firefox ble med sist, i versjon 121 (desember 2023). Siden da er den trygg å bruke uten @supports i ethvert nytt prosjekt.

Oppsummering

I 25 år lot CSS deg bare beskrive relasjoner rettet én vei – fra kontekst til mål, fra forelder til barn, fra forgjenger til etterfølger. :has() er ikke enda en variant av samme idé, men den første pseudoklassen som lar et element spørre om sitt eget indre og reagere på det den finner der – en valideringsfeil i et felt, tilstedeværelsen av et bilde i et kort, fravær av elementer i en liste. Dette flytter en hel klasse av beslutninger som tidligere måtte havne i JavaScript eller i props sendt manuelt ovenfra, tilbake dit de hører hjemme: i HTML-strukturen og CSS-reglene som beskriver den strukturen.