Modern Selector'ler: :is(), :where(), :has(), :not() ve State Pseudo-class'ları
:is() specificity'yi en spesifik argümanından alır, :where() ise her zaman sıfırdır; :not() ve :has() da :is() gibi hesaplanır. Bookstore'un katalog ve checkout sayfalarında specificity'yi DevTools'un protokolüyle ölçüyor, :has() ile kapaksız kitap kartını, :user-invalid ile hatalı form satırını JavaScript'siz stillendiriyor, :focus-visible, :open, :nth-child(of S) ve :empty'nin gerçek davranışını Chromium ve WebKit'te gösteriyoruz.
Sürüm notu: Bu yazı Selectors Level 4 spesifikasyonuna ve Baseline'ın Kasım 2026 durumuna göre yazıldı; örnekler Chrome 158 ve WebKit 27.2 (Playwright 1.65) ile denenmiştir, Firefox desteği web-features verisinden alınmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için katalogdaki bir kitap kartının fiyatına aynı layer'da altı farklı kural renk veriyor. Kart bir li.book-card, içinde bir kapak görseli ve p.price var; sayfada #featured ya da #promo id'li bir element yok, fiyatın .sale class'ı da yok:
@layer components {
.book-card .price { color: #1d4ed8; }
li.book-card .price { color: #0f766e; }
.book-card:has(> img) .price { color: #7c3aed; }
:is(#featured, .book-card) .price { color: #b91c1c; }
:where(#featured, .book-card) .price { color: #ca8a04; }
.price:not(.sale, #promo) { color: #be185d; }
}
Fiyat hangi renkte görünür? İpucu: cevap, kendisinde hiç id geçmeyen bir element için bir id'nin specificity'ye sayılıp sayılmadığına bağlı.
Önceki yazıda cascade'in kazanan değeri nasıl seçtiğini ve layer'ların specificity'den önce geldiğini gördük. Aynı layer'ın içinde ise karar yine specificity'ye kalıyor ve modern selector'ler bu hesabı değiştiriyor. Bu yazıda :is(), :where(), :not() ve :has()'ı, kullanıcının gerçek etkileşimine bakan state pseudo-class'larını ve birkaç yapısal selector'ü Bookstore'un katalog ve checkout sayfalarında deniyoruz.
:is() ve :where(): Specificity'yi Sen Seç
İki pseudo-class da aynı işi yapar: içlerine yazılan selector listesinden herhangi biri eşleşirse eşleşirler. :is(h2, h3) a, h2 a, h3 a yazmanın kısa yolu. Aralarındaki tek fark specificity. :is()'in specificity'si listesindeki en yüksek specificity'li argümana eşittir, o argüman element'e uysun ya da uymasın. :where()'in specificity'si ise her zaman sıfırdır. Önceki yazıda yazdığımız ve Chromium'a DevTools'un kullandığı protokol üzerinden soran yardımcıyla ölçtüm:
node scripts/02-selectors.mjs
--- specificity
computed color: rgb(190, 24, 93)
✓ components .price:not(.sale, #promo) (1,1,0) #be185d 02-specificity.html
✗ components :is(#featured, .book-card) .price (1,1,0) #b91c1c 02-specificity.html
✗ components .book-card:has(> img) .price (0,2,1) #7c3aed 02-specificity.html
✗ components li.book-card .price (0,2,1) #0f766e 02-specificity.html
✗ components .book-card .price (0,2,0) #1d4ed8 02-specificity.html
✗ components :where(#featured, .book-card) .price (0,1,0) #ca8a04 02-specificity.html
--- chromium
Dune bg=rgb(255, 255, 255) border=rgb(229, 231, 235) title::after=" · e-book"
Middlemarch bg=rgb(254, 243, 199) border=rgb(229, 231, 235) title::after=none
Emma bg=rgb(255, 255, 255) border=rgb(147, 197, 253) title::after=" · e-book"
The Hobbit bg=rgb(254, 243, 199) border=rgb(229, 231, 235) title::after=none
cart-count display: none
cart-count with a space: inline
on load invalid: true | user-invalid: false | label color: rgb(31, 41, 55)
focused invalid: true | user-invalid: false | label color: rgb(29, 78, 216)
typed "ada@" invalid: true | user-invalid: false | label color: rgb(29, 78, 216)
left the field invalid: true | user-invalid: true | label color: rgb(185, 28, 28)
Apply, mouse click focus: true | focus-visible: false
coupon, mouse click focus: true | focus-visible: true
Apply, Tab key focus: true | focus-visible: true
details :open: true | summary font-weight: 700
rules kept: :is(.price) | :where(.price)
color: rgb(0, 0, 0), font-weight: 700, font-style: italic, card border: 0px
--- webkit
Dune bg=rgb(255, 255, 255) border=rgb(229, 231, 235) title::after=" · e-book"
Middlemarch bg=rgb(254, 243, 199) border=rgb(229, 231, 235) title::after=none
Emma bg=rgb(255, 255, 255) border=rgb(147, 197, 253) title::after=" · e-book"
The Hobbit bg=rgb(254, 243, 199) border=rgb(229, 231, 235) title::after=none
cart-count display: none
cart-count with a space: inline
on load invalid: true | user-invalid: false | label color: rgb(31, 41, 55)
focused invalid: true | user-invalid: false | label color: rgb(29, 78, 216)
typed "ada@" invalid: true | user-invalid: false | label color: rgb(29, 78, 216)
left the field invalid: true | user-invalid: true | label color: rgb(185, 28, 28)
details :open: true | summary font-weight: 700
rules kept: :is(.price, .price:-bookstore-unknown) | :where(.price, .price:-bookstore-unknown)
color: rgb(0, 0, 0), font-weight: 700, font-style: italic, card border: 0px
Çıktının ilk bloğu tahmin sorusunun cevabı: fiyat pembe. .price:not(.sale, #promo) (1,1,0) aldı, çünkü :not() da en spesifik argümanını sayıyor ve o argüman bir id. Element'te hiç id olmaması bir şeyi değiştirmiyor. :is(#featured, .book-card) .price da aynı sebeple (1,1,0) aldı; ikisi berabere kaldı ve kaynakta sonra gelen :not() kazandı. Aynı listenin :where()'li hâli ise (0,1,0) ile en alta düştü: #featured orada sıfır sayıldı, sayılan tek şey dışarıdaki .price.
Bu fark iki ayrı kullanım alanı çıkarıyor. :is()'i uzun selector listelerini kısaltmak için kullanırken listeye bir id girmemesine dikkat et; yoksa listedeki bütün selector'ler bir id ağırlığı kazanır. :where() ise "kolayca ezilebilen varsayılan" yazmak için ideal. Bookstore'un focus stilini öyle yazdım:
.cart-count:empty {
display: none;
}
/* klavye kullanıcısı belirgin bir çerçeve görür, fare kullanıcısı görmez */
:where(a, button, input, summary):focus-visible {
outline: 3px solid #1d4ed8;
outline-offset: 2px;
}
:where(a, button, input, summary):focus-visible'in specificity'si (0,1,0); yalnız :focus-visible sayılıyor. Hangi component'te farklı bir focus çerçevesi gerekirse tek bir class ile ezebilirsin. Aynı tekniği bir kütüphane yazarken kullanmak, o kütüphaneyi kullananlara büyük kolaylık sağlıyor.
:not() ve Forgiving Selector List
:not() Selectors Level 4 ile birden fazla argüman almaya başladı: .price:not(.sale, #promo) "ne .sale ne de #promo olan fiyat" demek. Specificity'si :is() gibi hesaplanıyor; az önce gördüğümüz gibi argümana yazılan bir id, kuralı beklenmedik derecede güçlendirebilir.
Selector listelerinin bir de geçersiz selector'e verdiği tepki var. Normal bir listede (a, b) parçalardan biri tarayıcının tanımadığı bir şeyse, kuralın tamamı atılır. :is() ve :where() ise forgiving: tanımadıkları argümanı yok sayar, gerisiyle çalışırlar. :has() ise bilinçli olarak forgiving değil. Tarayıcının tanımadığı bir pseudo-class ile dört kural yazdım:
.price, .price:-bookstore-unknown { color: #b91c1c; }
:is(.price, .price:-bookstore-unknown) { font-weight: 700; }
:where(.price, .price:-bookstore-unknown) { font-style: italic; }
.book-card:has(.price, :-bookstore-unknown) { border: 2px solid #b91c1c; }
Çıktıdaki rules kept satırları, iki motorun stylesheet'te tuttuğu kuralları gösteriyor. Düz liste ve :has()'lı kural ikisinde de tamamen atıldı: fiyat kırmızı olmadı, kartın kenarlığı yok. :is() ve :where()'li kurallar çalıştı. Küçük bir fark da var: Chromium geçersiz argümanı serialize ederken siliyor (:is(.price)), WebKit olduğu gibi tutuyor; sonuç ikisinde de aynı. Bu davranışın pratik değeri şu: bir motora özel, vendor-prefix'li bir pseudo-class'ı başka selector'lerle aynı listeye yazacaksan :is() içine al; yoksa o motoru desteklemeyen tarayıcılarda bütün kural kaybolur.
:has(): Parent ve Önceki Kardeşi Seçmek
CSS'te yıllarca bir element'i içindekine göre seçmenin yolu yoktu; bunun için JavaScript ile class eklenirdi. :has() Aralık 2023'ten beri Baseline, Haziran 2026'dan beri widely available. Selector'ün öznesi yine dıştaki element, ama koşul içerideki: .book-card:has(> img) "doğrudan child'ı bir img olan kart" demek. Bookstore'un katalog kartları:
<ul class="book-list">
<li class="book-card in-stock" data-format="hardcover ebook">
<img src="img/dune.svg" alt="Cover of Dune" width="120" height="180">
<h2 class="book-title">Dune</h2>
<p class="book-author">Frank Herbert</p>
<p class="price">349.00 TRY</p>
</li>
<li class="book-card" data-stock="0" data-format="paperback">
<h2 class="book-title">Middlemarch</h2>
<p class="book-author">George Eliot</p>
<p class="price">289.00 TRY</p>
</li>
<li class="book-card in-stock" data-format="paperback ebook">
<img src="img/emma.svg" alt="Cover of Emma" width="120" height="180">
<h2 class="book-title">Emma</h2>
<p class="book-author">Jane Austen</p>
<p class="price">219.00 TRY</p>
</li>
<li class="book-card in-stock" data-format="hardcover">
<h2 class="book-title">The Hobbit</h2>
<p class="book-author">J. R. R. Tolkien</p>
<p class="price">259.00 TRY</p>
</li>
</ul>
.book-list {
list-style: none;
margin: 0;
padding: 0;
}
.book-card {
padding: var(--space);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background-color: var(--color-surface);
}
/* kapak görseli olmayan kart onun yerine renkli bir zemin alır */
.book-card:not(:has(> img)) {
background-color: #fef3c7;
}
/* zebra çizgileri yalnız stokta olan kitapları sayar */
.book-card:nth-child(even of .in-stock) {
border-color: #93c5fd;
}
.book-card[data-stock="0"] .price {
text-decoration: line-through;
color: var(--color-muted);
}
.book-card[data-format~="ebook"] .book-title::after {
content: " · e-book";
font-size: 0.75em;
color: var(--color-muted);
}
Çıktının katalog satırlarında Middlemarch ve The Hobbit kapaksız; ikisi de rgb(254, 243, 199) zemin aldı. HTML'e bir class eklemedim, kartın içeriği karar verdi. Yeni bir kitap kapaksız eklenirse ya da kapak görseli kaldırılırsa stil kendiliğinden değişiyor.
:has()'ın specificity'si de en spesifik argümanından geliyor. İlk bloktaki .book-card:has(> img) .price (0,2,1) aldı: iki class ve img'den gelen bir type. :has kendi başına bir pseudo-class olarak sayılmıyor.
:has() yalnız parent seçmek için değil. İçine bir sibling combinator yazarsan önceki kardeşi seçebilirsin: h2:has(+ .badge), hemen ardından bir .badge gelen başlığı seçer. Form satırları için de çok işe yarıyor; onu bir sonraki bölümde görüyoruz.
State Pseudo-class'ları: :focus-visible, :focus-within, :user-invalid, :open
Checkout form'unda e-posta, posta kodu ve kupon alanları var:
<form class="checkout-form" action="/orders" method="post">
<div class="form-row">
<label for="email">Email</label>
<input id="email" name="email" type="email" required>
</div>
<div class="form-row">
<label for="postcode">Postcode</label>
<input id="postcode" name="postcode" required pattern="[0-9]{5}">
</div>
<div class="form-row">
<label for="coupon">Coupon code</label>
<div class="input-group">
<input id="coupon" name="coupon">
<button class="btn" type="button">Apply</button>
</div>
</div>
<details class="gift-options">
<summary>Gift options</summary>
<label><input type="checkbox" name="gift-wrap"> Gift wrap</label>
</details>
<button class="btn btn-primary" type="submit">Place order</button>
</form>
.form-row {
margin-block-end: var(--space);
}
.form-row label {
display: block;
font-weight: 600;
}
/* kullanıcı satırın içindeyken bütün satırı vurgula */
.form-row:focus-within label {
color: #1d4ed8;
}
/* yalnız kullanıcı alanla etkileşime girdikten sonra */
.form-row:has(> :user-invalid) label {
color: var(--color-accent);
}
.form-row:has(> :user-invalid) input {
border-color: var(--color-accent);
}
.gift-options:open > summary {
font-weight: 700;
}
Klasik hata, geçersiz alanı :invalid ile kırmızı yapmak. required bir alan sayfa açılır açılmaz boş olduğu için geçersizdir; kullanıcı daha hiçbir şey yazmadan form kırmızıya boyanır. :user-invalid ise yalnız kullanıcı alanla etkileşime girdikten sonra eşleşir. Kasım 2023'ten beri Baseline, Mayıs 2026'dan beri widely available. Script'in ilgili kısmı:
// 3) Checkout: :user-invalid ile :invalid, :focus-within, :focus-visible, :open
({ browser, page } = await open('checkout.html', { engine }));
const email = page.locator('#email');
const state = async label => console.log(label.padEnd(22),
'invalid:', await email.evaluate(e => e.matches(':invalid')),
'| user-invalid:', await email.evaluate(e => e.matches(':user-invalid')),
'| label color:', await style(page, 'label[for=email]', 'color'));
await state('on load');
await email.focus();
await state('focused');
await email.pressSequentially('ada@');
await state('typed "ada@"');
await page.locator('#postcode').focus();
await state('left the field');
Çıktıda dört satır var ve iki motorda da aynı. Sayfa açıldığında alan :invalid ama :user-invalid değil, etiket normal renginde. Alana odaklanınca .form-row:focus-within devreye girdi ve etiket maviye döndü; :focus-within, içindeki herhangi bir element odaktayken eşleşiyor. Yarım bir adres yazmak da henüz :user-invalid yapmadı. Ancak kullanıcı alanı geçersiz değerle bırakıp posta koduna geçince :user-invalid eşleşti ve .form-row:has(> :user-invalid) label etiketi kırmızı yaptı. Hatayı kullanıcıya doğru anda gösteren davranış bu ve tek satır JavaScript gerektirmedi.
:focus-visible, tarayıcının "bu odak gösterilmeli mi?" sorusuna verdiği cevap. Chromium satırlarına bak: "Apply" butonuna fareyle tıklayınca buton odakta ama :focus-visible değil, çünkü kullanıcı nerede olduğunu zaten biliyor. Kupon alanına fareyle tıklayınca ise :focus-visible eşleşti: metin alanlarında imleç nerede yazacağını gösterdiği için tarayıcı çerçeveyi her zaman gösteriyor. Tab ile butona gelince yine eşleşti ve 3px'lik çerçeve çizildi. Bu ölçümü yalnız Chromium'da yaptım; WebKit'in macOS üzerinde Tab ile butonlara gitmemesi gibi platform davranışları karşılaştırmayı anlamsız kılıyor. Odak çerçevesinin erişilebilirlik gerekçesi ilerideki Accessibility (a11y) serisinde.
Son satır :open: details, dialog ve açılır select gibi element'ler açıkken eşleşiyor. "Gift options" açılınca summary kalınlaştı. Mayıs 2026'da Baseline newly available oldu; o tarihten önceki tarayıcılarda details[open] attribute selector'ü aynı işi görüyor. Element'lerin kendisi HTML5 ve Semantic HTML serisinin native etkileşimli element'ler yazısında.
Yapısal Selector'ler: :nth-child(of S), :empty ve Attribute Selector'leri
:nth-child(even) yalnız element'in kardeşleri arasındaki sırasına bakar. Katalogda stokta olmayan bir kitap varsa, zebra çizgileri kayar. :nth-child(even of .in-stock) ise sayarken yalnız .in-stock olanları hesaba katar. Çıktıda mavi kenarlığı alan kitap Emma: listede üçüncü, ama stokta olan kitaplar arasında ikinci. Mayıs 2023'ten beri Baseline.
:empty hiç child'ı olmayan element'i seçer; sepet sayacı boşken onu gizlemek için kullandım ve çıktıda display none. Ama sayacın içine tek bir boşluk koyduğumda iki motorda da :empty eşleşmeyi bıraktı ve sayaç inline oldu. Selectors Level 4 yalnız boşluk içeren element'in de :empty sayılmasını öneriyor, ama MDN'ye (Mozilla'nın web geliştirici dokümantasyonu) göre bunu uygulayan tarayıcı yok. Server-side template'lerde araya giren bir satır sonu bu selector'ü sessizce bozar.
Attribute selector'leri de bu yazının konusu: [data-stock="0"] tam eşleşme, [data-format~="ebook"] ise boşlukla ayrılmış bir listede kelime arar. Katalogda data-format'ında ebook geçen Dune ve Emma'nın başlığına ::after ile " · e-book" eklendi. Bunların dışında ^= (ile başlar), $= (ile biter), *= (içerir) ve büyük-küçük harf farkını kaldıran i bayrağı var: a[href$=".pdf" i].
Selector Performansı: Mitler ve Gerçekler
"Uzun selector yavaştır", "* kullanma" gibi kurallar, tarayıcıların çok daha yavaş olduğu yıllardan kalma. Tarayıcı bir selector'ü sağdan sola eşleştirir: önce en sağdaki parçaya (key selector) uyan element'leri bulur, sonra sola doğru koşulları kontrol eder. Bugünkü motorlar bunun üstüne hash tabloları ve önbellekler ekliyor; sıradan bir sayfada selector'lerin ölçülebilir bir maliyeti olmaz. Selector'leri performansa göre değil, okunabilirliğe ve specificity'ye göre seç.
Dikkat isteyen yer DOM'un (tarayıcının sayfadan kurduğu element ağacı) sık değiştiği ve stil hesabının uzun sürdüğü sayfalar. :has() burada özel bir durum, çünkü bir element'in içindeki her değişiklik dıştaki element'in eşleşmesini değiştirebilir. body:has(.toast) ya da :root:has(...) gibi geniş bir özne ve > içermeyen bir argüman, sayfanın herhangi bir yerindeki değişikliği bütün document için kontrol gerektiren bir işe çevirebilir. Özneyi olabildiğince yakın tut (.form-row) ve argümanı > ile sınırla (:has(> :user-invalid)). Şüphelendiğinde ölç: Chrome DevTools'un Performance panelinde "Enable CSS selector stats" ayarını açıp bir kayıt alırsan, Recalculate Style event'lerinde hangi selector'ün ne kadar sürdüğünü Selector Stats sekmesinde görürsün. Bu panelin kullanımı Browser DevTools serisinin Performance paneli yazısında; stil hesabının genel maliyeti ise ilerideki Web Performansı serisinde.
Üretimde Dikkat
Bu yazıdaki selector'lerin hepsi Baseline, ama tarihleri farklı. :is(), :where() ve çok argümanlı :not() 2021'den, :focus-visible 2022'den, :has() ve :user-invalid 2023'ün sonundan, :open ise yalnız Mayıs 2026'dan beri her yerde. Hedef tarayıcı listenin eski sürümler içerdiği bir projede :open yerine [open] kullan. :has() ile yazılmış bir kural desteklemeyen tarayıcıda tamamen yok sayıldığı için, temel görünüm :has()'sız da kabul edilebilir olmalı; kapaksız kartın renkli zemini bir iyileştirme, kartın çalışması için şart değil.
Selector'leri ekipçe okunabilir tutun. :has() ile yazılmış bir kural, HTML yapısına bağlı bir kural demek: biri kapak görselini bir figure'ın içine aldığında .book-card:has(> img) eşleşmeyi bırakır. Bu tür kuralların yanına hangi markup'a güvendiğini anlatan kısa bir yorum yaz.
Sık Düşülen Tuzaklar
Birincisi, :is() listesine bir id koymak. Liste içindeki her selector o id'nin ağırlığını kazanır ve ezmesi zorlaşır. Varsayılan stil yazıyorsan :where() kullan.
İkincisi, :has() ile JavaScript state'ini CSS'e taşırken okunabilirliği kaybetmek. .page:has(#cart-dialog[open]) .header:has(.menu:hover) gibi bir selector çalışır, ama altı ay sonra kimse neyin neyi etkilediğini anlamaz. Bir uygulama durumu, örneğin "sepet açık" bilgisi, zaten JavaScript'te bir state olarak duruyorsa, onu bir class ya da data attribute olarak yazmak çoğu zaman daha açıktır. :has()'ı içerikten doğal olarak çıkan durumlar için kullan: kartta görsel var mı, satırda hatalı alan var mı.
Üçüncüsü, form hatalarını :invalid ile göstermek. Kullanıcı sayfayı açtığı anda kırmızı alanlarla karşılaşır. :user-invalid kullan.
Dördüncüsü, focus çerçevesini outline: none ile tamamen kaldırmak. Fare kullanıcısını rahatsız eden çerçeveyi kaldırmanın doğru yolu :focus-visible; klavye kullanıcısı çerçevesiz kalmamalı.
Küçük Bir Deneme
★☆☆ lab/02-specificity.html'deki :is(#featured, .book-card)'i :where(#featured, .book-card) ile değiştir ve node scripts/02-selectors.mjs'i tekrar çalıştır. Hangi kuralın kazandığını ve neden kazandığını specificity sütunuyla açıkla.
★★☆ Katalogdaki bir kartın kapak görselini bir figure'ın içine al. .book-card:has(> img) ile .book-card:has(img) arasındaki farkı ölç ve hangisinin bu değişikliğe dayanıklı olduğunu not et.
★★★ Checkout form'una posta kodu için bir hata mesajı ekle (<p class="error">) ve onu yalnız .form-row:has(> :user-invalid) olduğunda göster. Script'e, kullanıcı geçersiz bir posta kodu yazıp alandan çıktığında mesajın göründüğünü, düzelttiğinde kaybolduğunu kontrol eden adımlar ekle.
Bir sonraki yazıda selector'lerden kutuya geçiyoruz: box model, box-sizing, margin collapse, min-content ve max-content gibi intrinsic size'lar, kitap kapağının oranını koruyan aspect-ratio ve sayfayı sağdan sola yazılan dillere hazırlayan logical property'ler.