Mert Özen Her Satırda Daha İleri
Frontend Tailwind CSS — #04

Variant'lar, Özel Utility'ler ve Modern CSS: group/peer, data-*/aria-*, @container, starting:, @utility ve @variant

Mert Özen 15 Kas 2026 11 dk 5 görüntülenme
Variant'lar, Özel Utility'ler ve Modern CSS: group/peer, data-*/aria-*, @container, starting:, @utility ve @variant

Tailwind'de her variant bir selector ya da at-rule ekler; group-hover iç içe group'larda dıştaki hover'a da tepki verir, bu yüzden group/card gibi isimli group gerekir. Bookstore'un filtre ve kart örneklerinde peer, has-*, not-*, in-*, aria-expanded ve data-[state] ile headless stil, @container ve v4.3'ün @container-size'ı, starting: ile popover animasyonu, logical ve scrollbar utility'leri, @utility (--default), @variant ve @custom-variant'ı Playwright ile ölçüyoruz.

Sürüm notu: Bu yazıdaki örnekler Tailwind CSS 4.3.5, Node.js 24 ve Playwright 1.65 (Chromium 158) ile denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı), katalog sayfasının üstünde "Book of the week" bandını gösteriyor. Bant bir section, içinde bir kitap kartı var. Kartın üzerine gelince başlığın renklenmesini istiyoruz, bunun için karta group, başlığa group-hover:text-indigo-600 verdik. Tasarımcı daha sonra bandın kendisine de hover efekti istedi ve biri section'a da group ekledi:

<section id="featured" class="group rounded-xl bg-slate-100 p-8">
    <article class="group rounded-lg bg-white p-3">
        <h3 id="title-plain" class="group-hover:text-indigo-600">Dune</h3>
    </article>
</section>

Fareyi bandın kenarındaki boşluğa, kartın dışına getiriyoruz. Kitabın başlığı renklenir mi?

Bu yazı variant'larla ilgili: bir utility'nin hangi durumda, hangi koşulda uygulanacağını söyleyen ön ekler. Durum variant'ları, group ve peer, has-*, not-*, in-*, headless component'ler için aria-* ve data-*, built-in container query'ler, starting: ile giriş animasyonu, v4.2 ve v4.3'te gelen logical property ve scrollbar utility'leri, sonunda da kendi utility ve variant'ını yazmak için @utility, @variant ve @custom-variant. Her variant'ın ürettiği CSS'i tek tek basan bir script'le başlıyoruz; altta yatan CSS mekanizmalarının kendisi CSS3 serisinde.

Variant Bir Selector'dır

Variant'ın ne yaptığını görmenin en kısa yolu, tek bir class'ın ürettiği CSS'e bakmak. 04-selectors.mjs her class'ı ayrı ayrı derleyip utility kısmını basıyor:

node scripts/04-selectors.mjs 'focus-visible:outline-2' 'hover:not-disabled:bg-indigo-500'
focus-visible:outline-2
  .focus-visible\:outline-2:focus-visible {
    outline-style: var(--tw-outline-style);
    outline-width: 2px;
  }

hover:not-disabled:bg-indigo-500
  @media (hover: hover) {
    .hover\:not-disabled\:bg-indigo-500:hover:not(:disabled) {
      background-color: var(--color-indigo-500);
    }
  }

Her variant bir pseudo-class, bir attribute selector'ı ya da bir at-rule ekliyor. Zincirlenen variant'lar v4'te soldan sağa uygulanıyor: hover:not-disabled: önce :hover, sonra :not(:disabled) ekliyor. Bu sıra v3'te tersti; serinin bakım yazısında migration tarafına döneceğiz. hover: ise yalnız (hover: hover) destekleyen cihazlarda çalışıyor, dokunmatik ekranda parmak çekildikten sonra "yapışıp kalan" hover durumu yok. Klavye odağı için focus: değil focus-visible: kullan; fareyle tıklanınca halka görünmez, Tab ile gelince görünür.

group ve peer: Başka Bir Element'in Durumuna Göre Stil

Tahmin sorusunun cevabı üretilen selector'da:

node scripts/04-selectors.mjs 'group-hover:text-indigo-600' 'group-hover/card:text-indigo-600'
group-hover:text-indigo-600
  @media (hover: hover) {
    :is(:where(.group):hover .group-hover\:text-indigo-600) {
      color: var(--color-indigo-600);
    }
  }

group-hover/card:text-indigo-600
  @media (hover: hover) {
    :is(:where(.group\/card):hover .group-hover\/card\:text-indigo-600) {
      color: var(--color-indigo-600);
    }
  }

group-hover: "en yakın .group" demiyor; "üzerine gelinen herhangi bir .group atası" diyor. section da .group olduğu için bandın boşluğuna gelmek başlığı renklendiriyor. Çözüm isimli group: kart group/card, bant group/section oluyor ve başlık yalnız group-hover/card: dinliyor. Playwright ile iki bandın kenar boşluğuna geldik:

node scripts/04-variants.mjs
hover #featured        #title-plain color oklch(0.511 0.262 276.966)
hover #featured-named  #title-named color oklch(0.208 0.042 265.755)

İsimsiz sürümde başlık indigo-600 oldu, isimli sürümde slate-900 kaldı. İç içe component'lerde (kart içinde kart, liste içinde menü) isimli group alışkanlık hâline gelmeli; component'i başka bir component'in içine koyduğunda dışarıdaki group'u bilmiyor olabilirsin.

peer aynı fikrin kardeşler arasındaki hâli ve CSS'in ~ combinator'ını kullanıyor. Bookstore'un filtre çubuğundaki "In stock" seçeneği gizli bir checkbox ve onun ardından gelen bir label:

<input id="in-stock" type="checkbox" class="peer sr-only">
<label for="in-stock" class="rounded-full border px-3 py-1 peer-checked:bg-indigo-600 peer-checked:text-white peer-focus-visible:outline-2">In stock</label>
  :is(:where(.peer):checked ~ .peer-checked\:bg-indigo-600) {

~ yalnız sonraki kardeşleri seçebildiği için peer element'i, ona bakan element'ten önce gelmek zorunda. Label'ı input'un önüne koyarsan hiçbir şey olmaz ve hata da görmezsin.

has-*, not-* ve in-*: Ebeveynden, Tersinden ve Atadan Bakmak

Sıra kısıtı olmayan yol has-*: stil, durumu içinde taşıyan element'in kendisinde. "Hardcover" filtresinde checkbox label'ın içinde:

<label id="hardcover" class="rounded-full border px-3 py-1 has-checked:bg-indigo-50 has-checked:border-indigo-600">
    <input type="checkbox" class="accent-indigo-600"> Hardcover
</label>
peer-checked label bg  oklch(0.511 0.262 276.966)
has-checked label bg   oklch(0.962 0.018 272.314)

İki seçeneğe tıkladıktan sonra ikisi de seçili görünüyor. has-*, :has() pseudo-class'ını kullanıyor ve Tailwind v4'ün tarayıcı tabanında zaten var. not-* tersine çeviriyor (hover:not-disabled: örneğindeki gibi); v4.0 duyurusuna göre media ve @supports sorgularını da tersine çevirebiliyor. in-* ise group işaretlemeye gerek bırakmıyor, herhangi bir atanın durumuna bakıyor:

node scripts/04-selectors.mjs 'has-checked:bg-indigo-50' 'in-data-[tenant=school]:text-emerald-700'
has-checked:bg-indigo-50
  .has-checked\:bg-indigo-50:has(:checked) {
    background-color: var(--color-indigo-50);
  }

in-data-[tenant=school]:text-emerald-700
  :where([data-tenant="school"]) .in-data-\[tenant\=school\]\:text-emerald-700 {
    color: var(--color-emerald-700);
  }

in-data-[tenant=school]:, okul portalının kökünde data-tenant="school" varsa içerdeki her element'e uygulanıyor. :where() içinde olduğu için specificity'ye yalnız class'ın kendisi katılıyor.

aria-* ve data-*: Headless Component'leri Stillemek

Angular CDK (Component Dev Kit; Angular'ın görünümsüz araç seti), Angular Aria ya da Radix gibi headless component kütüphaneleri görünüm vermiyor, davranış veriyor: klavye, focus, aria-expanded, data-state gibi attribute'ları güncel tutuyor. Tailwind'in bu attribute'lara bakan variant'ları, stil için ayrıca class eklemeyi gereksiz kılıyor. Bookstore'un "Format" filtresi bir disclosure; script yalnız attribute'ları çeviriyor:

<button id="format-button" type="button" aria-expanded="false" aria-controls="format-panel" class="group/trigger inline-flex items-center gap-1">
    Format
    <span id="chevron" class="inline-block transition-transform group-aria-expanded/trigger:rotate-180">▾</span>
</button>
<div id="format-panel" data-state="closed" class="hidden data-[state=open]:block">
    <p>Hardcover, Paperback, E-book</p>
</div>

<script>
    // disclosure yalnız attribute'ları çevirir; stili Tailwind variant'ları verir
    const button = document.getElementById("format-button");
    const panel = document.getElementById("format-panel");
    button.addEventListener("click", () => {
        const open = button.getAttribute("aria-expanded") !== "true";
        button.setAttribute("aria-expanded", String(open));
        panel.dataset.state = open ? "open" : "closed";
    });
</script>
aria-expanded=false  chevron rotate none  panel display none
aria-expanded=true  chevron rotate 180deg  panel display block

aria-expanded: bilinen ARIA (Accessible Rich Internet Applications; ekran okuyucular için durum bildiren attribute'lar) durumları için hazır bir variant ve [aria-expanded="true"] üretiyor. group-aria-expanded/trigger: ise aynı durumu butonun içindeki ok için okuyor. data-[state=open]: herhangi bir data attribute'u ve değeri için çalışıyor; data-current: gibi değersiz yazım yalnız attribute'un varlığına bakıyor:

aria-expanded:rotate-180
  .aria-expanded\:rotate-180[aria-expanded="true"] {
    rotate: 180deg;
  }

data-current:font-semibold
  .data-current\:font-semibold[data-current] {
    --tw-font-weight: var(--font-weight-semibold);
    font-weight: var(--font-weight-semibold);
  }

Bu yaklaşımın asıl kazancı erişilebilirlik: görünüm, ekran okuyucunun duyduğu durumdan türediği için ikisi birbirinden ayrışamıyor. Panel açık görünüp aria-expanded="false" kalan bir menü bu yazımla mümkün değil. Angular yazısında aynı filtreyi Angular'ın headless araçlarıyla kuracağız.

Built-in Container Query'ler

v4'te container query için plugin gerekmiyor. @container bir element'i inline-size container yapıyor, @md: gibi variant'lar onun genişliğine bakıyor. Aynı kartı 1280px'lik viewport'ta 300px ve 600px genişliğinde iki yuvaya koyduk:

<div id="narrow" class="@container w-[300px]">
    <article class="flex flex-col gap-3 @md:flex-row">...</article>
</div>
<div id="wide" class="@container w-[600px]">
    <article class="flex flex-col gap-3 @md:flex-row">...</article>
</div>
@container #narrow width 300px  article flex-direction column
@container #wide   width 600px  article flex-direction row

@md container ölçeğinde 28rem (448px); viewport aynıyken kartlar yuvalarına göre karar verdi. Birden fazla container iç içeyse isim veriyorsun: @container/shelf ve @lg/shelf:grid-cols-3. Üretilen CSS @container shelf (width >= 32rem). Breakpoint ölçeğinden farklı olarak bu ölçek --container-* namespace'inden geliyor.

v4.3 bir eksikliği kapattı. @container yalnız genişliği sorgulanabilir yapıyor, bu yüzden yüksekliğe bağlı cqb ve cqh birimleri container'ı değil viewport'u referans alıyor. @container-size ise container-type: size veriyor:

h-[50cqb] in @container-size: 150px, in @container: 400px

300px yüksekliğindeki iki kutuda h-[50cqb] birinde 150px (kutunun yarısı), ötekinde 400px (800px'lik viewport'un yarısı). Size container'ın bedeli, element'in yüksekliğinin artık içerikten gelmemesi; bu yüzden ona açık bir yükseklik vermelisin. Container query'lerin mekanizmasını ve subgrid'le çakışmasını CSS3 serisinin container query yazısında ölçmüştük.

starting:, Logical Property'ler ve Scrollbar

starting: variant'ı @starting-style üretiyor: element ilk kez göründüğünde geçişin hangi değerden başlayacağını söylüyor. Sepet önizlemesi bir popover:

<button type="button" popovertarget="cart-preview">Cart</button>
<div id="cart-preview" popover class="rounded-lg p-4 opacity-100 shadow-lg transition-all transition-discrete duration-300 starting:open:opacity-0">
    2 books in your cart
</div>
starting:open:opacity-0
  @starting-style {
    .starting\:open\:opacity-0:is([open], :popover-open, :open) {
      opacity: 0%;
    }
  }
popover open, running transitions: opacity 0 -> 1

Popover açıldığı an opacity için 0'dan 1'e bir transition çalışıyor; JavaScript yok. open: variant'ı [open], :popover-open ve :open'ı birlikte kapsadığı için aynı class dialog ve details için de çalışıyor. transition-discrete, display gibi kesikli property'lerin de geçişe katılmasını sağlıyor; kapanış animasyonu için bu gerekli.

v4.2 logical property utility'lerini tamamladı: ps-*, ms-* gibi inline yöndekilere ek olarak artık blok yönünde pbs-*, mbs-*, border-bs, inset-s-* ve logical boyutlar (inline-*, block-*) var. Bunlar metin yönüne göre çalışıyor:

<div dir="rtl">
    <p id="rtl-note" class="border-s-4 border-amber-500 ps-4 pbs-2">Free shipping over 500 TRY</p>
</div>
rtl ps-4: padding-left 0px, padding-right 16px, border-right-width 4px

Sağdan sola yazılan blokta "start" sağ taraf: boşluk ve kenarlık sağa geçti. Arapça veya İbranice dil desteği gelecekse pl-* yerine ps-* yazmak, sonradan bütün template'leri taramaktan çok daha ucuz.

v4.3 scrollbar utility'lerini ekledi. Sipariş listesinin kaydırma çubuğu:

<ul id="order-list" class="h-24 overflow-auto scrollbar-thin scrollbar-thumb-slate-400 scrollbar-track-slate-100">
scrollbar: width thin, color oklch(0.704 0.04 256.788) oklch(0.968 0.007 247.896)

scrollbar-thumb-* ve scrollbar-track-* ayrı class'lar olduğu hâlde ikisi de standart scrollbar-color property'sine yazıyor; bunu kendi değişkenleri (--tw-scrollbar-thumb, --tw-scrollbar-track) üzerinden birleştirerek yapıyorlar.

@utility, @variant ve @custom-variant

Tailwind'in vermediği bir utility'ye ihtiyacın varsa onu @utility ile kendin tanımlıyorsun. Stokta olmayan kitapların kapağına çizgili bir arka plan koymak istiyoruz; renk paletten seçilebilsin, verilmezse gri olsun:

@import "tailwindcss" source(none);
@source "./custom.html";

/* kendi variant'ımız: okul portalının içindeki her şey */
@custom-variant school (&:where([data-tenant="school"] *));

/* varsayılan değerli functional utility (v4.3) */
@utility stripes-* {
    background-image: repeating-linear-gradient(45deg, --value(--color-*, [color], --default(var(--color-slate-300))) 0 6px, transparent 6px 12px);
}

/* eski checkout'tan kalan, düz CSS olarak tutulan bir class */
@layer components {
    .legacy-checkout-button {
        background-color: var(--color-indigo-600);
        @variant hover, focus-visible {
            background-color: var(--color-indigo-500);
        }
        @variant school {
            background-color: var(--color-emerald-600);
        }
    }
}

stripes-* bir functional utility: --value(--color-*) theme'deki bir rengi, [color] arbitrary bir rengi kabul ediyor, --default(...) ise v4.3 ile gelen ve değer verilmediğinde kullanılan değer. Böylece hem stripes hem stripes-rose-300 çalışıyor. Bir ayrıntı: --default(--color-slate-300) yazdığımızda Tailwind değeri olduğu gibi kopyaladı ve geçersiz bir CSS (--color-slate-300 0 6px) üretti; theme değişkenini var() içinde vermek gerekti.

@variant, variant'ları CSS dosyasının içinde kullanmayı sağlıyor. v4.3 ile virgülle ayrılmış birden çok variant aynı bloğu paylaşabiliyor (@variant hover, focus-visible). @custom-variant ise yeni bir variant tanımlıyor; tanımladıktan sonra school: hem template'te class olarak hem de CSS'te @variant school olarak kullanılabiliyor:

@layer components {
  .legacy-checkout-button {
    background-color: var(--color-indigo-600);
    &:hover {
      @media (hover: hover) {
        background-color: var(--color-indigo-500);
      }
    }
    &:focus-visible {
      background-color: var(--color-indigo-500);
    }
    &:where([data-tenant="school"] *) {
      background-color: var(--color-emerald-600);
    }
  }
}
node scripts/04-custom.mjs
checkout          bg oklch(0.511 0.262 276.966)
checkout (focus)  bg oklch(0.585 0.233 277.117)
checkout-school   bg oklch(0.596 0.145 163.225), weight 700
stripes           repeating-linear-gradient(45deg, oklch(0.869 0.022 252.894) 0px, oklch(0.869 0.022 252.894) 6px, rgba(0, 0, 0, 0) 6px, rgba(0, 0, 0, 0) 12px)

Klavye odağı rengi değiştirdi, okul portalındaki buton yeşil ve kalın, çizgili arka plan varsayılan gri. @utility ile tanımlanan class'lar gerçek utility gibi davranıyor: variant alabiliyor (hover:stripes-rose-300), utilities layer'ına giriyor ve kullanılmazsa üretilmiyor. Aynı şeyi @layer components içinde düz bir class olarak yazsaydın bunların hiçbiri olmazdı.

Üretimde Dikkat

Variant'lar CSS'i çarpan bir etki yapıyor: hover:, focus-visible:, dark: ve iki breakpoint'le kullanılan bir renk, CSS'te altı ayrı kural demek. Bu genellikle sorun değil, ama boyutu izliyorsan artışın kaynağı çoğu zaman yeni utility'ler değil yeni variant kombinasyonları.

starting:, @container-size ve scrollbar utility'leri yeni CSS özelliklerine dayanıyor. Tailwind'in compatibility sayfası @starting-style gibi özelliklerin hedef tarayıcılarda desteğini ayrıca kontrol etmeni söylüyor. Bookstore'da bunlar zarif biçimde bozuluyor: @starting-style yoksa popover animasyonsuz açılır, scrollbar-color yoksa varsayılan kaydırma çubuğu görünür. Destek olmadığında işlevin kaybolduğu yerlerde (yalnız @container-size ile ayarlanan bir yükseklik gibi) yedek bir değer ver.

Headless component'lerde stili attribute'lara bağlamanın yan etkisi testlerin de güçlenmesi: Playwright testi "panel görünür mü" yerine "aria-expanded doğru mu" diye sorabiliyor ve ikisi aynı anda doğrulanmış oluyor.

Sık Düşülen Tuzaklar

İlki tahmindeki tuzak: iç içe group'larda isimsiz group-hover: kullanmak. Dıştaki herhangi bir .group'un hover'ı içeriyi tetikler.

İkincisi peer'ı yanlış sıraya koymak. peer her zaman ondan etkilenen element'ten önce gelmeli; sıra değiştirilemiyorsa ortak ebeveyne has-* yaz.

Üçüncüsü arbitrary value'ları alışkanlık hâline getirmek: w-[37px], mt-[0.75rem], bg-[#fff]. Kullandığımız 4.3.5 CLI'ında bulunan canonicalize komutu bunların bir kısmını token karşılığına çeviriyor:

npx tailwindcss canonicalize 'mt-[0.75rem]'
mt-3

Aynı komutu birkaç class için çalıştırdık: p-[1rem] → p-4, bg-[#fff] → bg-white, rounded-[0.25rem] → rounded-sm, grid-cols-[repeat(3,minmax(0,1fr))] → grid-cols-3, bg-[var(--cat-color)] → bg-(--cat-color). w-[16px] ve mt-[12px] ise değişmedi: spacing ölçeği rem tabanlı olduğu için araç pikseli kendi başına rem'e çevirmiyor. w-[37px] gibi ölçekte karşılığı olmayan bir değer de olduğu gibi kaldı; o değer gerçekten gerekiyorsa bir token adayı, değilse ölçekteki en yakın değere yuvarlanmalı. Code review'da her arbitrary value'yu "bu neden token değil?" sorusuyla okumak, tasarımın dağılmasını erken yakalıyor.

Dördüncüsü @layer components içine yazılmış bir class'ın utility gibi davranmasını beklemek. hover:legacy-checkout-button üretilmez; variant alabilen kendi class'ın lazımsa @utility kullan.

Küçük Bir Deneme

★☆☆ 04/index.html'deki "In stock" input'unu label'ın arkasına taşı, npm run build:04 ve node scripts/04-variants.mjs çalıştır ve peer-checked satırının ne gösterdiğine bak.

★★☆ Format filtresine klavye desteği ekle: Escape tuşu paneli kapatsın ve odak butona dönsün. Paneldeki seçili format için aria-current="true" kullan ve stilini aria-current: ile ver.

★★★ @utility stripes-*'a ikinci bir boyut ekle: stripes-rose-300/50 gibi bir opacity modifier'ı çalışsın. Tailwind dokümanındaki --modifier(...) fonksiyonunu kullan ve üretilen CSS'i 04-selectors.mjs ile doğrula.

Bir sonraki yazıda bütün bunları bookstore-web'e taşıyoruz: ng add tailwindcss, component style'larında @reference, signal'larla dinamik class listeleri ve Angular'ın headless araçlarıyla stillenmiş bir filtre menüsü.