Utility-first Yaklaşım: Tailwind Neyi Çözer, Bedeli Nedir ve Ne Zaman Kullanılmamalı?
Utility-first, stili component'in içinde token'a bağlı küçük class'larla yazar; CSS boyutu component sayısıyla değil farklı class sayısıyla büyür, bedeli uzun class listeleri ve öğrenme eğrisidir. Bookstore'un üç component'inde iki modelin CSS ve HTML maliyetini ölçüyor, tekrarı Razor partial ile çözüyor, @apply'ın neden son çare olduğunu, CMS içeriğinde Preflight'ın etkisini ve layer'sız eski CSS'in utility'leri neden ezdiğini gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Tailwind CSS 4.3.5 (@tailwindcss/cli), .NET 10 ve Playwright 1.65 (Chromium 158) ile denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için üç küçük ekran parçası yazdık: katalogdaki kitap kartı, sepetteki kitap satırı ve checkout sayfasındaki sipariş özeti. Her birini iki kez yazdık: bir kez klasik yoldan, anlamlı class adları ve ayrı bir CSS dosyasıyla; bir kez de Tailwind'in utility class'larıyla. Sonra CSS boyutunu her adımda ölçtük. İlk adımın sonucu şu:
step 1 (+book-card)
semantic CSS : 724 B, gzip 365 B
utility CSS : 4170 B, gzip 1231 B (29 distinct classes)
Tailwind'in ürettiği CSS, elle yazılan CSS'in beş katından büyük. Sepet satırını ve sipariş özetini de ekleyince iki dosya nasıl büyür? Üçüncü component'te elle yazılan CSS kaç byte artar, Tailwind'inki kaç byte?
Bu yazı Tailwind CSS serisinin ilk yazısı. Kurulumdan önce yaklaşımın kendisini konuşuyoruz: utility-first neyi çözüyor, karşılığında ne istiyor ve hangi projede hiç girmemek daha doğru. Ölçümleri bu yazıyla birlikte gelen lab klasöründeki script'lerle aldık; Tailwind'i kurmayı ve input.css dosyasını ayrıntılı olarak bir sonraki yazıda anlatacağız.
İki Model: Anlamlı Class Adları ve Utility Class'lar
Klasik modelde HTML içeriği anlatır, görünüm ayrı bir dosyadadır. Kitap kartı BEM (Block Element Modifier; class adlarını blok, element ve durum olarak kuran isimlendirme kuralı) ile şöyle görünür:
<article class="book-card">
<img class="book-card__cover" src="../../covers/dune.svg" alt="Cover of Dune" width="120" height="180">
<h3 class="book-card__title">Dune</h3>
<p class="book-card__author">Frank Herbert</p>
<div class="book-card__footer">
<span class="book-card__price">349.00 TRY</span>
<button class="book-card__button">Add to cart</button>
</div>
</article>
.book-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
background: #fff;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.book-card__title {
font-size: 1rem;
font-weight: 600;
color: #0f172a;
}
/* ... kapak, yazar, footer, fiyat ve buton için altı kural daha */
Utility-first modelde aynı kart tek bir dosyada durur. Her class tek bir işi yapar ve adı o işi söyler:
<article class="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<img class="aspect-2/3 w-full rounded-md object-cover" src="../../covers/dune.svg" alt="Cover of Dune" width="120" height="180">
<h3 class="text-base font-semibold text-slate-900">Dune</h3>
<p class="text-sm text-slate-600">Frank Herbert</p>
<div class="mt-auto flex items-center justify-between">
<span class="text-lg font-bold text-slate-900">349.00 TRY</span>
<button class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Add to cart</button>
</div>
</article>
İlk bakışta bu, inline style'ın biraz daha kısa yazılmış hâli gibi duruyor. Fark, Tailwind'in ürettiği CSS'e bakınca görünüyor:
@layer theme {
:root, :host {
--color-indigo-600: oklch(51.1% 0.262 276.966);
--color-slate-200: oklch(92.9% 0.013 255.508);
--spacing: 0.25rem;
--radius-xl: 0.75rem;
/* ... yalnız kullanılan değişkenler */
}
}
@layer utilities {
.gap-3 {
gap: calc(var(--spacing) * 3);
}
.rounded-xl {
border-radius: var(--radius-xl);
}
.border-slate-200 {
border-color: var(--color-slate-200);
}
@media (hover: hover) {
.hover\:bg-indigo-500:hover {
background-color: var(--color-indigo-500);
}
}
}
Üç şey dikkat çekiyor. Birincisi, değerler serbest değil: gap-3 yazdığında boşluk --spacing ölçeğinin üç katı oluyor, slate-200 paletten gelen bir renk. Yani her utility bir design token'a bağlı ve ekipteki herkes aynı ölçekten seçiyor; birinin 13px, ötekinin 0.8rem yazması zorlaşıyor. İkincisi, inline style'ın yapamadığı şeyleri yapıyor: hover: bir variant ve arkasında gerçek bir :hover selector'ı, hatta yalnız hover destekleyen cihazlar için bir media query var. Üçüncüsü, kurallar @layer utilities içinde duruyor; bu, aşağıdaki tuzaklardan birinin de kaynağı.
Separation of Concerns Tartışmasının İki Tarafı
Utility-first'e en sık gelen itiraz, "stil HTML'e karıştı, separation of concerns (sorumlulukların ayrılması) bozuldu" itirazı. Bu itirazın haklı bir tarafı var: anlamlı class adıyla yazılmış bir sayfayı yalnız CSS dosyasını değiştirerek baştan sona yeniden giydirebilirsin. 2000'lerin CSS Zen Garden sitesi bu fikrin vitriniydi; aynı HTML, yüzlerce farklı tasarım.
Tailwind'in yazarı Adam Wathan, 2017'de yayımladığı "CSS Utility Classes and Separation of Concerns" yazısında soruyu başka bir yerden soruyor: endişeler ayrılmış mı, değil; bağımlılık hangi yönde? Anlamlı class adlarında CSS, HTML'e bağımlıdır. .book-card__title yalnız kitap kartında işe yarar; sepet satırı için ayrı bir .cart-line__title yazarsın, içine yine aynı üç satırı koyarsın. HTML yeniden giydirilebilir ama CSS yeniden kullanılamaz. Utility class'larda yön tersine döner: HTML, CSS'e bağımlıdır. CSS tamamen yeniden kullanılabilir, ama HTML'i yalnız CSS'ten yeniden giydiremezsin.
Hangi tarafın daha iyi olduğu projeye göre değişiyor. Tasarımın sık değiştiği, ekranların component'lerden kurulduğu bir Angular ya da Razor uygulamasında asıl iş yeni ekran eklemek; orada CSS'in yeniden kullanılması daha değerli. İçeriği başkasının ürettiği, aynı HTML'in farklı temalarla gösterildiği bir yayın sitesinde ise HTML'in yeniden giydirilebilmesi daha değerli. Utility-first'ü seçmek, ayrımı bırakmak değil, ayrımı dosya türüne göre değil component sınırına göre yapmak demek: kartın HTML'i ve görünümü aynı component'te, kart ile sepet birbirinden ayrı.
Ölçüm: CSS Ne Kadar Büyüyor?
Tahmin sorusuna dönelim. Script her adımda bir component daha ekliyor. Klasik tarafta o component'in CSS dosyasını birleştirip minify ediyor; Tailwind tarafında yalnız HTML'i birleştiriyor ve CSS'i Tailwind CLI'a ürettiriyor:
// Tailwind tarafı: yalnız theme ve utilities, Preflight yok;
// source(none) ile otomatik tarama kapalı, yalnız o adımın HTML'i taranıyor
writeFileSync(`${dir}/input.css`, [
"@layer theme, base, components, utilities;",
'@import "tailwindcss/theme.css" layer(theme);',
'@import "tailwindcss/utilities.css" layer(utilities) source(none);',
'@source "./index.html";',
].join("\n"));
const utility = build(`${dir}/input.css`, ".");
node scripts/01-growth.mjs
step 1 (+book-card)
semantic CSS : 724 B, gzip 365 B
utility CSS : 4170 B, gzip 1231 B (29 distinct classes)
step 2 (+cart-line)
semantic CSS : 1342 B, gzip 499 B
utility CSS : 4620 B, gzip 1351 B (38 distinct classes)
step 3 (+order-summary)
semantic CSS : 2234 B, gzip 600 B
utility CSS : 4770 B, gzip 1373 B (41 distinct classes)
Üçüncü adımda elle yazılan CSS 892 byte büyüdü, Tailwind'in CSS'i 150 byte. Sipariş özeti neredeyse tamamen kart ve sepet satırının zaten kullandığı class'lardan oluşuyor; yeni olan yalnız üç class (pt-3, border-t, gap-2). Klasik tarafta ise yeni component yeni isimler ve aynı declaration'ların yeni kopyaları demek: .order-summary__checkout, .book-card__button ile satır satır aynı.
Bu ölçümün dürüst okuması şu: üç component'lik bir sayfada Tailwind daha büyük CSS üretiyor. Başlangıçtaki yaklaşık 4 KB'ın büyük kısmı class'lardan değil, @property tanımlarından ve theme değişkenlerinden geliyor; gölge ve border gibi utility'ler kendi aralarında bu değişkenlerle konuşuyor. Kazanç sayfa sayısı arttıkça ortaya çıkıyor: CSS'in boyutu component sayısıyla değil, kullanılan farklı class sayısıyla büyüyor ve bu sayı bir süre sonra neredeyse duruyor. Elle yazılan CSS ise her yeni ekranla büyümeye devam ediyor, üstelik kullanılmayan kuralları bulup silmek de sana kalıyor. Tailwind'de bir class'ı son kullanan yerden sildiğinde bir sonraki build'de CSS'ten de çıkıyor.
Bedel: Uzun Class Listeleri ve HTML'de Tekrar
Kazancın karşılığında ödenen bedel de ortada. "Add to cart" butonunda sekiz class var; kartın tamamında 29. Bir kod incelemesinde rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 satırını okuyup "bu buton neden öteki butondan farklı görünüyor?" sorusunu cevaplamak, .btn-primary görmekten daha uzun sürüyor. Class sayısı arttıkça diff'ler de genişliyor.
HTML boyutu da artıyor. Aynı kartı farklı kitaplarla 24 kez basan bir sayfayı iki yoldan ölçtük:
node scripts/01-html-size.mjs
semantic x1 : 422 B, gzip 239 B
semantic x24: 10357 B, gzip 603 B
utility x1 : 652 B, gzip 383 B
utility x24: 15877 B, gzip 763 B
Ham HTML yüzde elli büyüyor ama gzip sonrası fark 160 byte. Aynı class dizisi her kartta tekrarlandığı için sıkıştırma onu çok iyi yakalıyor. Yani ağ maliyeti küçük; asıl maliyet okuma ve bakım tarafında.
Son bedel öğrenme eğrisi. Ekibin justify-between'ın justify-content: space-between olduğunu, p-4'ün 1rem olduğunu, sm:'nin "küçük ekranda" değil "640px ve üstünde" anlamına geldiğini öğrenmesi gerekiyor. Altta yatan CSS'i bilmeyen biri için Tailwind ikinci bir dil öğrenmek demek; CSS'i bilen biri için ise çoğunlukla bir kısaltma sözlüğü. Bu yüzden serinin her yazısında utility'nin ürettiği CSS'i de göstereceğiz; mekanizmanın kendisi CSS3: Flexbox, Grid ve Modern CSS ile CSS Property'leri: Uygulamalı Rehber serilerinde.
Tekrarı Component'te Çözmek
Utility-first'te tekrarın asıl cevabı CSS değil, template. Bookstore'un sunucu taraflı arayüzü Bookstore.Web, ASP.NET Core MVC (Model-View-Controller; isteği controller'ın karşıladığı, HTML'i view'ın ürettiği yapı) ile yazılmış bir uygulama. Katalog sayfasında kart bir partial'da duruyor ve bir döngüyle basılıyor:
@model BookCardViewModel
<article class="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<img class="aspect-2/3 w-full rounded-md object-cover" src="@Model.CoverUrl" alt="Cover of @Model.Title" width="120" height="180" loading="lazy">
<h3 class="text-base font-semibold text-slate-900">@Model.Title</h3>
<p class="text-sm text-slate-600">@Model.Author</p>
<div class="mt-auto flex items-center justify-between">
<span class="text-lg font-bold text-slate-900">@Model.Price.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) TRY</span>
<button class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Add to cart</button>
</div>
</article>
@model IReadOnlyList<BookCardViewModel>
<h1 class="mb-6 text-3xl font-bold">Catalog</h1>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
@foreach (BookCardViewModel book in Model)
{
<partial name="_BookCard" model="book" />
}
</div>
cd aspnet/Bookstore.Web
dotnet run
curl -s http://127.0.0.1:52861/ | grep -c '<article'
3
Kartın class listesi kaynakta bir kez yazılı; sayfada üç kez, yarın otuz kez çıkabilir. Bir rengi değiştirmek için tek dosyaya dokunuyorsun. Angular'da aynı işi bir component yapıyor; serinin Angular yazısında bookstore-web'in kartını signal'larla ve dinamik class'larla kuracağız. Tailwind dokümanı da tekrarı önce döngüyle, sonra aynı dosyada çoklu imleçle, dosyalar arası tekrarda ise component veya partial ile çözmeyi öneriyor. Partial'ın ya da component'in dışarıya verdiği şey artık bir class adı değil, BookCardViewModel gibi bir contract; component'lerin dış dünyayla nasıl konuştuğunu UI Mimari Pattern'leri serisinin Component Architecture yazısında anlatmıştık.
@apply Neden Son Çare?
Uzun class listesinden kaçmanın ilk akla gelen yolu @apply: utility'leri bir CSS kuralının içine kopyalamak.
@import "tailwindcss" source(none);
@layer components {
/* Tekrarı CSS'e taşımak: iki isim, aynı declaration'lar */
.btn-primary {
@apply rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500;
}
.btn-checkout {
@apply rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500;
}
}
npx tailwindcss -i 01/apply.css
@layer components {
.btn-primary {
border-radius: var(--radius-md);
background-color: var(--color-indigo-600);
padding-inline: calc(var(--spacing) * 3);
padding-block: calc(var(--spacing) * 1.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
color: var(--color-white);
&:hover {
@media (hover: hover) {
background-color: var(--color-indigo-500);
}
}
}
.btn-checkout {
border-radius: var(--radius-md);
/* ... .btn-primary ile aynı dokuz declaration */
}
}
@apply declaration'ları kopyalıyor; her yeni isim CSS'e yeni bir kopya ekliyor. Böylece ölçümde gördüğümüz kazancı kendi elinle geri veriyorsun: CSS yine component sayısıyla büyüyor, isim bulma yükü geri geliyor ve stil yine iki dosyaya bölünüyor. Üstüne bir de "bu class nerede tanımlı?" sorusu ekleniyor, çünkü .btn-primary artık ne HTML'de ne Tailwind dokümanında.
@apply'ın yeri dar ama var: template'ini kontrol etmediğin HTML (üçüncü parti bir widget'ın ürettiği markup) ya da tek bir element için küçük, sık kullanılan bir class. Tailwind dokümanı da tek element'lik küçük şeyler için @layer components içinde theme değişkenleriyle yazılmış bir class'ı kabul edilebilir buluyor, daha karmaşık her şey için partial öneriyor. Angular'ın component style'larında @apply ayrıca bir @reference satırı istiyor ve her component dosyası için Tailwind'i bir kez daha çalıştırıyor; bunu Angular yazısında ölçeceğiz.
Ne Zaman Kullanılmamalı?
Üç durumda Tailwind'i seçmeden önce iki kez düşünmek gerekiyor.
İlki içerik ağırlıklı sayfalar. Bookstore'un blogundaki inceleme yazıları bir CMS'ten (içerik yönetim sistemi) düz HTML olarak geliyor; içinde class yok, h2, p ve ul var. Tailwind'in temel stil sıfırlaması Preflight, bu element'lerin tarayıcı varsayılanlarını kaldırıyor:
node scripts/01-preflight.mjs
h1 (class) : font-size 30px, weight 700, margin-bottom 0px, list-style disc
cms h2 : font-size 16px, weight 400, margin-bottom 0px, list-style disc
cms p : font-size 16px, weight 400, margin-bottom 0px, list-style disc
cms ul : font-size 16px, weight 400, margin-bottom 0px, list-style none
Class'lı h1 30px ve kalın; CMS'ten gelen h2 ise düz paragrafla aynı: 16px, normal ağırlık, boşluk yok. Listenin madde işaretleri de gitmiş. Gövdeye class ekleyemediğin için utility-first burada işe yaramıyor. Çözüm @tailwindcss/typography plugin'inin prose class'ı: gövdeyi tek bir class'la sarıyorsun ve plugin içindeki element'leri tipografik olarak stilliyor. Bu sitenin blog gövdesi de bu durumda; plugin'i serinin sonundaki günlük utility yazılarında ele alacağız. Sayfanın çoğu CMS içeriğiyse, Tailwind'i yalnız iskelet için kullanıp gövdeyi plugin'e bırakmak ya da hiç kullanmamak daha temiz.
İkincisi, büyük ve çalışan bir BEM veya Sass codebase'i. Tailwind'i yanına eklemek iki stil sistemini aynı anda bakımda tutmak demek; üstelik sonraki bölümdeki cascade layer davranışı yüzünden eski CSS çoğu zaman yeni utility'leri ezer. Sass/SCSS serisinin büyük projede stil mimarisi yazısında anlattığımız 7-1 ve ITCSS (Inverted Triangle CSS; stilleri genelden özele katmanlara ayıran yapı) düzeni oturmuşsa, yeni ekranlar için bile o düzeni sürdürmek genellikle daha ucuz.
Üçüncüsü, Angular Material'ın stil sistemine yaslanan bir uygulama. Material component'leri görünümlerini kendi theme ve token sistemiyle alıyor; bir mat-button'ı utility class'larla değiştirmeye çalışmak, Material'ın kendi override mekanizmasıyla yarışmak demek. Sayfa düzeni (grid, boşluk, kart iskeleti) için Tailwind'i Material'ın yanında kullanmak mümkün, ama Material component'lerinin içini Tailwind'le stillemeye kalkma. Angular Material ve CDK (Component Dev Kit; Angular'ın davranış sağlayan, görünüm dayatmayan araç seti) serisinde bu ayrımı ayrıca konuşacağız.
Üretimde Dikkat
Utility CSS'in boyutu kullanılan class sayısına bağlı; dolayısıyla üretimde izlenecek sayı da bu. Build çıktısının boyutunu her sürümde kaydetmek, birinin bir arbitrary value döngüsüyle yüzlerce yeni class ürettiğini fark etmenin en kolay yolu. Preflight dahil tam bir build'in ilk maliyetini de bil: CMS sayfası için ürettiğimiz, Preflight ve birkaç utility içeren dosya minify edilmiş hâliyle 4846 byte, gzip sonrası 1699 byte.
İkinci konu cache. Tailwind'in ürettiği tek bir site.css dosyası var ve sayfalar arasında değişmiyor; bu yüzden uzun süreli cache ve dosya adına hash ekleme (ASP.NET Core'da MapStaticAssets ve asp-append-version) çok iyi çalışıyor. Sayfaya özel CSS dosyalarının yerini, HTML'deki class listesi alıyor.
Üçüncüsü ekip anlaşması. Hangi değerlerin token olduğu, ne zaman partial çıkarılacağı ve class'ların hangi sırayla yazılacağı kararlaştırılmazsa utility-first de dağılır. Class sıralama ve çakışan class'ları birleştirme araçlarını serinin bakım yazısında göreceğiz.
Sık Düşülen Tuzaklar
En sinsi tuzak, Tailwind'i eski CSS'in yanına eklerken çıkıyor. Tailwind'in utility'leri @layer utilities içinde; eski CSS ise hiçbir layer'da değil. Cascade'de layer'sız kurallar, layer'daki kuralları specificity'ye bakmadan yener:
<style>
/* eski CSS, hiçbir layer'da değil */
h3 { color: crimson; }
</style>
<h3 id="title" class="text-slate-900">Dune</h3>
node scripts/01-unlayered.mjs
h3.text-slate-900 color: rgb(220, 20, 60)
Class selector'ı element selector'ından daha spesifik ama yine de crimson kazandı. Sebep CSS3 serisinin cascade yazısında anlattığımız sıra: layer, specificity'den önce gelir. Eski CSS'i bir layer'a almak (@import "legacy.css" layer(legacy); gibi) ya da utility'lere ! modifier'ı eklemek bu çakışmayı çözer; ilki kalıcı çözüm.
İkinci tuzak, her tekrarı @apply ile bir isme toplamak. Bir ay sonra elinde Tailwind syntax'ıyla yazılmış klasik bir CSS dosyası oluyor ve iki modelin bedelini birden ödüyorsun.
Üçüncüsü, class adını string birleştirerek üretmek: 'bg-' + color + '-600'. Tailwind kaynak kodu metin olarak tarar ve tam class adını görmediği bir utility'yi üretmez. Bunu ve çözümünü bir sonraki yazıda canlı göstereceğiz.
Dördüncüsü, token ölçeğinin dışına çıkmak. Tailwind w-[37px] gibi arbitrary value'lara izin veriyor; ama her değeri böyle yazarsan design token'ların verdiği tutarlılığı kaybedersin ve elinde class adı biçiminde inline style kalır. Variant'lar yazısında bu konuya tekrar döneceğiz.
Küçük Bir Deneme
★☆☆ 01-growth.mjs script'ine dördüncü bir component ekle: kitap detay sayfasındaki "Similar books" satırı. Önce hangi class'ların yeni olacağını tahmin et, sonra script'i çalıştırıp utility CSS'in kaç byte büyüdüğüne bak.
★★☆ 01/apply.css dosyasındaki iki @apply kuralını tek bir .btn-primary class'ına indir ve checkout butonunda bu class'ın yanına bg-emerald-600 ekle. Hangi renk kazanır ve neden? Cevabı @layer components ile @layer utilities sırasında ara.
★★★ 01/unlayered.html sayfasındaki eski kuralı bir @layer legacy bloğuna al ve @layer sırasını legacy, theme, base, components, utilities olacak biçimde tanımla. Playwright çıktısının nasıl değiştiğini ölç; sonra sırayı theme, base, components, utilities, legacy yapıp tekrar ölç.
Bir sonraki yazıda Tailwind v4'ü kuruyoruz: @import "tailwindcss" satırının ne getirdiğini, Oxide engine'in kaynak dosyaları nasıl taradığını, @source ile hangi klasörleri ekleyip çıkaracağını ve Bookstore.Web'de input.css'ten site.css'e giden build zincirini --watch ve Release --minify ile kuracağız.