css och layouter

CSS :has() — föräldern som vet vad som händer inuti

Du har ett formulärfält: <div class="field"> med en etikett och en input inuti. Kravet låter banalt – när input:en är ogiltig ska hela behållaren få en röd ram och en varningsikon bredvid etiketten, inte bara själva input:en. Du tar till .field input:invalid – och kör huvudet i väggen. Den selektorn skulle stylat input:en, om du velat ge den en röd ram. Men du vill stylea .field, alltså input:ens förälder, utifrån vad som händer i dess inre. Och plötsligt visar det sig att CSS – trots dussintals kombinatorer, pseudoklasser och attributselektorer – aldrig under sina 25 år har erbjudit ett sätt att göra det.

Det är inte en lucka i din CSS-kunskap. Det är ett grundläggande drag i selektorernas arkitektur, som gällde från CSS1 ända fram till 2022.

Varför en kombinator alltid bara tittar åt ett håll

Varje kombinator i CSS – mellanslag (ättling), > (direkt barn), + (närmaste syskon), ~ (allmänt syskon) – beskriver en relation mellan två selektorer, men det är alltid elementet till höger om kombinatorn som stylas. .field input stylar input:en inuti .field. .field ~ .error stylar .error, som är syskon till .field. Riktningen är alltid densamma: från kontext till mål, aldrig tvärtom. CSS-motorn har, när den stöter på .field, ingen inbyggd mekanism för att "kika in" och utifrån det ändra beslutet om stylingen av .field självt.

Utvecklare har i åratal kringgått detta på två sätt, båda med verkliga kostnader. Det första – JavaScript som lyssnar på händelserna input/blur och manuellt lägger till klassen .field--invalid på föräldern. Det andra, smartare men skörare – det så kallade checkbox/radio-tricket, som använder ~ för att stylea syskon utifrån :checked-tillstånd, vilket bara fungerar om elementen faktiskt är syskon i en platt DOM-struktur, och inte nästlade så som ett riktigt formulär kräver.

jsx

// Lösningen före :has() – fungerar, men kräver JS för något
// som är en rent visuell konsekvens av HTML-tillståndet
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() eliminerar den koden inte för att den är ett smartare trick – utan för att den löser problemet vid källan, som den första relationella pseudoklassen i CSS historia som låter ett element fråga om sitt eget inre.

Hur :has() egentligen fungerar: en selektor förankrad i elementet, inte i ättlingen

Den avgörande tankeförskjutningen: .field:has(input:invalid) stylar inte input:en. Den stylar .field – exakt det element som pseudoklassen sitter på – förutsatt att det någonstans i dess inre finns ett element som matchar selektorn inom parentesen. Elementet du "förankrar" på (det så kallade anchor element) förblir alltid detsamma; :has() avgör bara om det överhuvudtaget blir en träff.

css

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

Som standard letar selektorn inuti :has() efter vilken ättling som helst, på vilket djup som helst – precis som ett vanligt mellanslag. Men du kan begränsa relationen exakt som i vanlig CSS, genom att använda kombinatorer direkt inuti parentesen:

css

/* Bara ett direkt barn, inte vilken ättling som helst */
.card:has(> img) {
grid-template-columns: 120px 1fr;
}
/* Ett element som OMEDELBART följs av .field-hint som syskon */
.field:has(+ .field-hint) {
margin-bottom: 4px;
}

Det gör att :has() inte är ett enda nytt trick, utan en generalisering – den låter dig uttrycka varje relation i CSS som tidigare gick att beskriva med en kombinator, fast riktad "inåt" eller "bakåt" i stället för enbart "framåt".

Praktiskt exempel: ett formulärfält som själv vet att det är ogiltigt

Låt oss sätta ihop det till en komplett, tillgänglig komponent. En viktig detalj: :invalid i sig utlöses direkt när ett tomt fält med attributet required laddas – innan användaren hunnit skriva något, vilket skulle ge en röd ram på ett tomt, orört formulär. Det löses av :not(:placeholder-shown), som bara matchar fältet när användaren faktiskt redan lämnat något 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;
}
/* Fältet är "berört" (något har skrivits) OCH ogiltigt – reagera först då */
.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 är lika viktig som ett fel */
.field:has(input:not(:placeholder-shown):valid) {
border-color: #2e7d32;
}

Inte en enda rad JavaScript ansvarig för utseendet. Valideringstillståndet finns redan nativt i webbläsaren – :valid/:invalid har funnits sedan CSS3 – :has() gjorde det äntligen möjligt att flytta det befintliga tillståndet från input:en till dess behållare, dit det visuellt alltid borde ha hört.

Ett andra mönster: en komponent som vet vad den innehåller

Samma mekanism löser en helt annan klass problem – layout som beror på förekomsten av visst innehåll, utan en prop som talar om det i förväg.

css

/* Ett kort med bild får en tvåkolumnslayout, ett kort utan bild får full textbredd */
.card:has(> img) {
display: grid;
grid-template-columns: 96px 1fr;
gap: 12px;
}
/* Sektionsrubriken får marginal nedåt bara om sektionen faktiskt har en undertitel */
.section:has(> .section__subtitle) .section__title {
margin-bottom: 4px;
}
/* En lista utan ett enda element visar ett tomt-tillstånd i stället för tomt utrymme */
.product-list:not(:has(li)) {
display: block;
}
.product-list:not(:has(li))::after {
content: "Inga produkter matchar kriterierna.";
color: #666;
}

Utan :has() skulle vart och ett av de här fallen kräva antingen en prop (hasImage, isEmpty) som sätts manuellt av den överordnade komponenten, eller en kontroll av arrayens längd i JSX och villkorlig rendering av en separat klass. :has() låter komponenten själv känna igen sitt eget innehåll och reagera, i stället för att bli informerad om det utifrån – exakt samma tankesätt som produktkortet, medvetet om sin egen container, i artikeln om Container Queries: mindre state som skickas för hand, mer logik som följer direkt av strukturen.

Fallgropar och god praxis

  • Specificiteten hos :has() är specificiteten hos den mest specifika selektorn inuti, inte noll. .field:has(input:invalid) har specificiteten av två klasser och en pseudoklass tillsammans – räkna inte med att :has() "inte räknas" i specificiteten, för i praktiken slår den regelbundet enklare regler som skrivs senare i stilmallen.
  • Ett djupt nästlat, brett :has() utan kombinator kan kosta. .app:has(.some-deeply-nested-element) tvingar teoretiskt motorn att överväga hela underträdet till .app vid varje DOM-ändring i dess inre. Moderna motorer (Chromium, WebKit) optimerar det via så kallade invalidation sets – de räknar inte om allt blint – men effekten beror på den specifika selektorn och trädets storlek. Begränsa relationen med en kombinator (>, direkt barn) där det går, i stället för att som standard söka på djupet.
  • :has() ersätter inte :focus-within. Om du bara behöver "föräldern reagerar när en ättling har fokus" finns :focus-within sedan länge, är billigare att beräkna och mer läsbart – :has(:focus) ger praktiskt taget samma effekt, men utan anledning att ta till ett mer generellt verktyg där ett specialiserat redan finns.
  • Webbläsarstöd har slutat vara ett problem. :has() blev den sista stora pusselbiten på plats – Safari hade den från 15.4 (mars 2022), Chrome från 105 (augusti 2022), Firefox anslöt sist, i version 121 (december 2023). Sedan dess är den säker att använda utan @supports i alla nya projekt.

Sammanfattning

I 25 år lät CSS dig bara beskriva relationer riktade åt ett håll – från kontext till mål, från förälder till barn, från föregångare till efterföljare. :has() är inte ännu en variant av samma idé, utan den första pseudoklassen som låter ett element fråga om sitt eget inre och reagera på vad det hittar där – ett valideringsfel i ett fält, förekomsten av en bild i ett kort, avsaknaden av element i en lista. Det flyttar en hel klass av beslut som tidigare var tvungna att hamna i JavaScript eller i props som skickades ner manuellt, tillbaka dit de hör hemma: till HTML-strukturen och de CSS-regler som beskriver den strukturen.