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

Angular ile Tailwind: ng add tailwindcss, Component Style'larında @reference, Dinamik Class'lar ve Headless Component'ler

Mert Özen 15 Kas 2026 12 dk 5 görüntülenme
Angular ile Tailwind: ng add tailwindcss, Component Style'larında @reference, Dinamik Class'lar ve Headless Component'ler

Angular'da Tailwind global styles.css'te çalışır; component CSS'leri ayrı işlendiği için @apply orada @reference ister ve her component'e yedek değerler ile @property blokları taşır. bookstore-web'de ng new --style tailwind ve ng add'in ne yaptığını, 40 component'le @reference'ın bundle ve build bedelini, SCSS'te Tailwind syntax'ının neden kırıldığını, signal'larla tam adlı dinamik class'ları ve Angular Aria Listbox'ı aria-selected ile stillemeyi gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2, TypeScript 6.0, Tailwind CSS 4.3.5 (@tailwindcss/postcss), Angular Aria 22.2, Node.js 24 ve Playwright 1.65 (Chromium 158) ile denenmiştir. Angular 22.2 TypeScript 6.0 istediği için projede TypeScript 7 değil 6.0 kullanılıyor.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için Angular istemcisi bookstore-web'i Tailwind ile kurduk. Global styles.css dosyasında Tailwind ve Bookstore'un token'ları var:

@import 'tailwindcss';

@theme {
  --color-ink-900: oklch(0.21 0.03 264);
  /* ... marka renkleri, radius ve font */
}

Kitap kartı component'inin kendi CSS dosyasında fiyat için tek bir kural yazdık:

.price {
  @apply text-lg font-bold text-ink-900;
}

ng build çalıştırınca ne olur? Fiyat 18px ve kalın mı çıkar, yoksa kural sessizce boş mu kalır, yoksa build mi kırılır?

Bu yazıda Tailwind'i Angular'a bağlıyoruz: ng new --style tailwind ve ng add tailwindcss'in tam olarak ne yaptığını, component style'larında @reference'ı ve bedelini, Angular rehberinin SCSS (Sass'ın CSS'e benzeyen syntax'ı) önerisinin neden Tailwind dokümanıyla çeliştiğini, signal'larla dinamik class listelerini ve Angular Aria ile kurulan headless bir filtreyi. Angular'ın kendisini Angular serisinde, component sınırlarını UI Mimari Pattern'leri serisinde anlattık; burada yalnız Tailwind tarafına bakıyoruz.

ng new --style tailwind ve ng add tailwindcss

Angular CLI 22.2.7'de ng new'in --style seçenekleri css, less, sass, scss ve tailwind. bookstore-web'i doğrudan Tailwind ile oluşturduk:

npx @angular/cli@22.2.7 new bookstore-web --directory web --style tailwind --ssr false --zoneless --ai-config none --skip-git
CREATE web/.postcssrc.json (53 bytes)
CREATE web/src/styles.css (104 bytes)
CREATE web/src/app/app.css (0 bytes)
...
✔ Packages installed successfully.

Var olan bir projede aynı sonuca ng add tailwindcss ile ulaşılıyor. Düz CSS ile oluşturulmuş boş bir projede çalıştırdık:

npx ng add tailwindcss --skip-confirmation
› Found compatible package version: 4.3.5.
Cleaning up temporary dependency 'tailwindcss'...
The tailwindcss package does not provide `ng add` actions.
The Angular CLI will use built-in actions to add it to your project.
CREATE .postcssrc.json (53 bytes)
UPDATE src/styles.css (104 bytes)
UPDATE package.json (875 bytes)

Log'daki iki satır ilginç: tailwindcss paketinin kendi ng add şeması yok, işi Angular CLI'ın içine gömülü bir şema yapıyor. Yaptığı da üç değişiklik: package.json'a tailwindcss, @tailwindcss/postcss ve postcss ekliyor, bir .postcssrc.json oluşturuyor ve styles.css'e tek satır ekliyor:

{
  "plugins": {
    "@tailwindcss/postcss": {}
  }
}
/* You can add global styles to this file, and also import other style files */

@import 'tailwindcss';

Elle kurulum da bundan ibaret ve Angular rehberindeki adımlar birebir bu üçü. Bir ayrıntı: şema package.json'a "tailwindcss": "^4.1.12" yazıyor; npm bugün bu aralıktan 4.3.5'i kurdu. Ekipte herkesin aynı sürümü kullanması için package-lock.json'ı repo'ya koymayı unutma.

Angular Build'i Tailwind'i Nasıl Çalıştırıyor?

@angular/build stil dosyalarını kendi pipeline'ında işliyor ve proje kökünde .postcssrc.json (ya da postcss.config.json) bulursa içindeki PostCSS plugin'lerini her stil dosyasına uyguluyor. Burada kritik nokta "her stil dosyası": global styles.css bir kez, her component'in styleUrl ile bağlanan CSS'i de ayrı ayrı işleniyor. Tailwind, global dosyada @import 'tailwindcss' ve @theme gördüğü için orada bütün sistemi kuruyor; template'leri tarıyor ve utility'leri styles.css çıktısına yazıyor. Component CSS'i ise başka bir dosya; Tailwind onu işlerken ne @import 'tailwindcss' ne de @theme görüyor.

npx ng build
Initial chunk files | Names         |  Raw size | Estimated transfer size
main-53KIRNCR.js    | main          | 250.93 kB |                69.12 kB
styles-CR5T2JGC.css | styles        |   9.73 kB |                 2.44 kB
                    | Initial total | 260.66 kB |                71.56 kB

Bütün utility'ler styles dosyasında; component template'lerinde yazdığın her class'ın CSS'i oradan geliyor. Component CSS'i ise derlenip JavaScript bundle'ının içine string olarak giriyor ve Angular'ın view encapsulation'ı ile o component'e kapsamlanıyor.

Component Style'larında @apply ve @reference

Tahminin cevabı: build kırılıyor.

✘ [ERROR] Cannot apply unknown utility class `text-lg`. Are you using CSS modules or similar and missing `@reference`? https://tailwindcss.com/docs/functions-and-directives#reference-directive [plugin angular-css]
    src/app/catalog/book-card.css:1:0:
      1 │ .price {
        ╵ ^

book-card.css işlenirken Tailwind'in bildiği bir tema yok; text-lg onun için bilinmeyen bir class. Hata mesajı çözümü de söylüyor: component CSS'ine global stil dosyasını referans olarak bağlamak.

@reference "../../styles.css";

.price {
  @apply text-lg font-bold text-ink-900;
}

@reference, @import'tan farklı olarak dosyanın içeriğini çıktıya kopyalamıyor; yalnız tema ve utility tanımlarını yüklüyor ki @apply çalışsın. Build geçti ve fiyat 18px, kalın ve ink-900 rengiyle göründü. Ama component'in tarayıcıya giden CSS'ine bakınca bedel görünüyor:

@layer properties;.price[_ngcontent-ng-c1490267311]{font-size:var(--text-lg, 1.125rem);line-height:var(--tw-leading, var(--text-lg--line-height, calc(1.75 / 1.125)));--tw-font-weight: var(--font-weight-bold, 700);font-weight:var(--font-weight-bold, 700);color:var(--color-ink-900, oklch(.21 .03 264))}@property --tw-font-weight{syntax: "*"; inherits: false;}@layer properties{@supports ((-webkit-hyphens: none) and (not (margin-trim: block))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))){*[_ngcontent-ng-c1490267311], [_ngcontent-ng-c1490267311]:before, [_ngcontent-ng-c1490267311]:after, [_ngcontent-ng-c1490267311]::backdrop{--tw-font-weight: initial}}}

Üç kuralın sonucu 676 karakter. Tailwind referans modunda her var()'a bir yedek değer ekliyor (var(--text-lg, 1.125rem)), çünkü component CSS'i theme değişkenlerinin :root'ta var olup olmadığını bilmiyor. font-bold gibi @property ile çalışan utility'ler de @property tanımını ve eski tarayıcılar için bir @layer properties yedeğini her component'e ayrıca taşıyor; Angular'ın encapsulation'ı o yedekteki * selector'ını bile component'e kapsamlamış.

@reference'ın Bedeli: 40 Component'le Ölçüm

Bu bedel component sayısıyla nasıl büyüyor? bookstore-web'in bir kopyasına 40 küçük component ekleyip her birine aynı kutu stilini iki yoldan verdik. Birinci yol düz CSS ve theme değişkenleri:

.box { border-radius: var(--radius-card); background: var(--color-brand-50); padding: 1rem; color: var(--color-ink-900); }

İkinci yol @reference ve @apply:

@reference "../../styles.css";
.box { @apply rounded-card bg-brand-50 p-4 text-ink-900 shadow-sm; }

.angular cache'ini her seferinde silip üçer kez ng build çalıştırdık:

Yolmain bundle (ham)main bundle (transfer)Build süresi (3 koşu)
------------
var(--…), Tailwind işlemiyor266.37 kB69.69 kB1.013 s, 0.983 s, 0.955 s
@reference + @apply356.05 kB70.01 kB1.394 s, 1.247 s, 1.055 s

Ham bundle 90 kB büyüdü: her component kendi @property tanımlarını ve yedeklerini taşıyor. Sıkıştırma tekrar eden metni iyi yakaladığı için transfer boyutundaki fark küçük, ama tarayıcı yine de 90 kB'lık fazladan string'i parse edip her component için ayrı bir <style> olarak ekliyor. Build süresi de her koşuda daha uzundu, çünkü Tailwind 40 dosyanın her biri için styles.css'i yeniden yükleyip tasarım sistemini kuruyor. Tailwind'in compatibility sayfası da CSS modules ve Vue/Svelte style blokları için aynı uyarıyı yapıyor: her dosya için Tailwind ayrı çalışıyor.

Sonuç bir tercih sırası. Önce template'te utility kullan. Component CSS'i gerçekten gerekiyorsa (bir ::ng-deep olmadan çözülemeyen üçüncü parti markup, karmaşık bir animasyon) theme değişkenlerini var(--color-brand-50) ile doğrudan kullan; Tailwind o dosyaya hiç dokunmasa da değişkenler :root'ta olduğu için çalışır. @reference ve @apply son çare.

SCSS: Angular Rehberi ve Tailwind Dokümanı

Angular'ın Tailwind rehberi SCSS kullanan projeler için styles.scss içinde @use 'tailwindcss'; yazmayı gösteriyor. Tailwind'in compatibility sayfası ise v4'ün Sass, Less veya Stylus gibi preprocessor'larla birlikte kullanılmak üzere tasarlanmadığını, Tailwind'in kendisini preprocessor olarak düşünmeni söylüyor. İki kaynak birbirini tutmuyor; hangisinin pratikte ne anlama geldiğini denedik.

İlk ilginç bulgu CLI'ın kendisinden geldi. SCSS ile oluşturulmuş bir projede ng add tailwindcss, styles.scss'e dokunmadı:

CREATE src/tailwind.css (23 bytes)
CREATE .postcssrc.json (53 bytes)
UPDATE angular.json (2067 bytes)
UPDATE package.json (876 bytes)

Ayrı bir src/tailwind.css (içinde yalnız @import 'tailwindcss';) oluşturdu ve angular.json'daki styles dizisini ["src/tailwind.css", "src/styles.scss"] yaptı. Yani CLI, Tailwind'i bir CSS dosyasında tutuyor.

Sonra rehberdeki gibi tailwind.css'i kaldırıp @use 'tailwindcss';'i styles.scss'e yazdık. Basit kullanımda build geçti; text-3xl ve @apply ile yazılan bir .price kuralı çıktıda vardı. Sorun, Tailwind v4'ün kendi syntax'ını SCSS dosyasına yazınca başladı. @custom-variant ve @source not geçti, ama namespace sıfırlama ve functional utility Sass derleyicisinde kırıldı:

✘ [ERROR] Error: expected "{".
  ╷
3 │   --color-*: initial;
  │                     ^
  ╵
  ../../../../src/styles.scss 3:21  root stylesheet [plugin angular-sass]
✘ [ERROR] Error: Expected expression.
  ╷
3 │   background-image: repeating-linear-gradient(45deg, --value(--color-*, [color]) 0 6px, transparent 6px 12px);
  │                                                                       ^
  ╵
  ../../../../src/styles.scss 3:71  root stylesheet [plugin angular-sass]

Sebep sıralama: Angular önce Sass'ı çalıştırıyor, PostCSS ve Tailwind sonra geliyor. Tailwind'in syntax'ı Sass için geçerli CSS değilse Tailwind'e sıra hiç gelmiyor. Bizim önerimiz CLI'ın yaptığıyla aynı: Tailwind'in import'u, @theme ve @utility tanımları bir .css dosyasında dursun. Component'lerde SCSS kullanmaya devam edebilirsin, yeter ki o dosyalarda Tailwind directive'i olmasın. Sass'ın hangi işlerini artık native CSS'in üstlendiğini Sass/SCSS serisinin ilk yazısında, Angular build'ine bağlanmasını da son yazısında anlatmıştık.

Dinamik Class'lar: [class.x], [class] ve Signal'lar

Tam ad kuralı Angular'da da geçerli: Tailwind TypeScript dosyasını ve template'i düz metin olarak tarıyor. Kitap kartında stok durumu üç değerli bir küme; class'ları bir map'te tam adlarıyla tutuyor ve signal'larla seçiyoruz:

import { Component, computed, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { Book } from './book';

type StockState = 'in-stock' | 'low' | 'sold-out';

// her durum için tam class adları: Tailwind yalnız kaynakta bulduğunu üretir
const STOCK_CLASSES: Record<StockState, string> = {
  'in-stock': 'bg-emerald-50 text-emerald-700',
  low: 'bg-amber-50 text-amber-800',
  'sold-out': 'bg-slate-100 text-slate-500',
};

@Component({
  selector: 'app-book-card',
  imports: [CurrencyPipe],
  templateUrl: './book-card.html',
  styleUrl: './book-card.css',
})
export class BookCard {
  readonly book = input.required<Book>();

  protected readonly stock = computed<StockState>(() => {
    const count = this.book().stock;
    return count === 0 ? 'sold-out' : count < 5 ? 'low' : 'in-stock';
  });
  protected readonly stockClasses = computed(() => STOCK_CLASSES[this.stock()]);
  protected readonly soldOut = computed(() => this.stock() === 'sold-out');
}
<article class="flex h-full flex-col gap-2 rounded-card border border-slate-200 bg-white p-4 shadow-sm" [class.opacity-60]="soldOut()">
  <span class="stock-badge w-fit rounded-full px-2 py-0.5 text-xs font-medium" [class]="stockClasses()">
    {{ stock() === 'sold-out' ? 'Sold out' : stock() === 'low' ? 'Only ' + book().stock + ' left' : 'In stock' }}
  </span>
  <h3 class="font-display text-lg font-semibold text-ink-900">{{ book().title }}</h3>
  <p class="text-sm text-slate-600">{{ book().author }} · {{ book().format }}</p>
  <p class="price mt-auto">{{ book().price | currency: 'TRY' : 'symbol-narrow' : '1.2-2' : 'en' }}</p>
</article>

İki binding'in farkı: [class.opacity-60] tek bir class'ı bir boolean'a bağlıyor; [class] ise bir string'deki class'ları ekliyor ve değer değişince eskileri kaldırıyor. Angular [class] binding'ini statik class attribute'u ile birleştirdiği için rozetin rounded-full px-2 gibi sabit class'ları korunuyor. 'bg-' + color + '-50' yazmak yerine map'te tam adları tutmak, hem scanner'ın class'ları görmesini hem de TypeScript'in eksik bir durumu (Record<StockState, string>) derleme anında yakalamasını sağlıyor.

Headless Filtre: Angular Aria Listbox

Bookstore'un format filtresi çoklu seçimli bir listbox. Klavye gezintisini, aria-selected'ı, role ve tabindex yönetimini Angular Aria'nın Listbox ve Option directive'leri yapıyor; görünüm tamamen Tailwind variant'larında:

import { Component, model } from '@angular/core';
import { Listbox, Option } from '@angular/aria/listbox';
import { BookFormat } from './book';

@Component({
  selector: 'app-format-filter',
  imports: [Listbox, Option],
  template: `
    <div ngListbox multi selectionMode="explicit" orientation="horizontal" [(value)]="selected" aria-label="Format" class="flex gap-2">
      @for (format of formats; track format) {
        <div ngOption [value]="format" [label]="format"
             class="cursor-pointer rounded-full border border-slate-300 px-3 py-1 text-sm
                    aria-selected:border-brand-600 aria-selected:bg-brand-600 aria-selected:text-white
                    focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500">
          {{ format }}
        </div>
      }
    </div>
  `,
})
export class FormatFilter {
  protected readonly formats: readonly BookFormat[] = ['Hardcover', 'Paperback', 'E-book'];
  readonly selected = model<BookFormat[]>([]);
}

Katalog sayfası <app-format-filter [(selected)]="formats" /> ile bir signal'a bağlanıyor ve görünen kitapları computed ile süzüyor. Uygulamayı build edip 127.0.0.1:52862'de sunduk ve Playwright ile ölçtük:

node scripts/05-catalog.mjs
Dune        In stock     bg oklch(0.979 0.021 166.113)  card opacity 1
The Hobbit  Only 3 left  bg oklch(0.987 0.022 95.277)  card opacity 1
Emma        Sold out     bg oklch(0.968 0.007 247.896)  card opacity 0.6
Middlemarch In stock     bg oklch(0.979 0.021 166.113)  card opacity 1
load             Hardcover[selected=false active=true bg rgba(0, 0, 0, 0)] Paperback[selected=false active=false bg rgba(0, 0, 0, 0)] E-book[selected=false active=false bg rgba(0, 0, 0, 0)] -> 4 of 4 books
click Paperback  Hardcover[selected=false active=false bg rgba(0, 0, 0, 0)] Paperback[selected=true active=true bg oklch(0.54 0.178 264)] E-book[selected=false active=false bg rgba(0, 0, 0, 0)] -> 2 of 4 books
ArrowRight Space Hardcover[selected=false active=false bg rgba(0, 0, 0, 0)] Paperback[selected=true active=false bg oklch(0.54 0.178 264)] E-book[selected=true active=true bg oklch(0.54 0.178 264)] -> 3 of 4 books
focused: E-book, outline solid 2px
.price: 18px 700 oklch(0.21 0.03 264)

Rozetler stok durumuna göre üç renk aldı, tükenen Emma kartı yüzde 60 opaklıkta. Filtrede tıklama ve klavye (sağ ok, Space) aynı state'i değiştiriyor ve renk doğrudan aria-selected'dan geliyor.

Bu ölçüm iki ayarı da belirledi. İlk denemede selectionMode vermemiştik; varsayılan follow modunda odaklanan seçenek otomatik seçiliyor ve sayfa açılır açılmaz "Hardcover" seçili, liste "1 of 4 books" geldi. Çoklu seçimli bir filtre için explicit mod doğru: seçim yalnız tıklama, Space veya Enter ile değişiyor. İkincisi, aktif seçenek data-active="true" taşıyor ama ölçümün ilk satırında görüldüğü gibi bu attribute listbox'a hiç odaklanılmamışken de ilk seçenekte duruyor. Odak halkasını data-[active=true]: ile verseydik sayfa açılışında da görünecekti; roving tabindex tarayıcının gerçek odağını seçenekten seçeneğe taşıdığı için focus-visible: doğru variant.

Üretimde Dikkat

angular.json'daki anyComponentStyle bütçesi varsayılan olarak 4 kB'ta uyarı, 8 kB'ta hata veriyor. @reference ile yazılan component CSS'i yedek değerler ve @property blokları yüzünden beklediğinden hızlı büyür; bütçe uyarısı gördüğünde ilk bakılacak yer @apply satırları.

Global styles.css bütün uygulamanın utility'lerini taşıyor ve lazy loading ile yüklenen route'ların template'leri de taranıyor; yani lazy route'lardaki class'lar da ilk yüklenen CSS'te. bookstore-web'de bu 9.73 kB (transfer 2.44 kB). Çok büyük bir uygulamada bu dosyanın boyutunu her sürümde izlemek, Bookstore.Web için önerdiğimiz kontrolün aynısı.

Tailwind sürümünü package-lock.json sabitliyor, Angular sürümünü de. İkisini aynı pull request'te yükseltme; Tailwind'in bir minor sürümü (4.2'deki logical utility'ler gibi) yeni class adları getiriyor ve diff'te ne değiştiğini ayırmak zorlaşıyor.

Sık Düşülen Tuzaklar

İlki tahmindeki tuzak: component CSS'inde @reference olmadan @apply. Hata mesajı açık, ama her component'e @reference eklemek ölçtüğümüz bedeli getiriyor; önce template'e taşımayı düşün.

İkincisi @reference yolunu yanlış vermek. Yol component CSS dosyasına göre: src/app/catalog/book-card.css için ../../styles.css. Component'i başka bir klasöre taşıdığında yol da değişir ve build aynı hatayla kırılır; bu da @reference'ı az kullanmak için bir sebep daha.

Üçüncüsü Tailwind directive'lerini .scss dosyasına yazmak. Basit @use çalışıyor diye @theme sıfırlamasını ya da @utility'yi oraya eklediğin gün build kırılır.

Dördüncüsü class adını template'te birleştirmek: [class]="'bg-' + color() + '-50'". Sunucu tarafındaki Razor örneğindeki gibi HTML doğru görünür ama CSS'te kural yoktur.

Küçük Bir Deneme

★☆☆ format-filter.ts'te selectionMode="explicit"'i kaldır, ng build ve 05-catalog.mjs'i çalıştır. İlk satırdaki seçili seçeneği ve kitap sayısını not et.

★★☆ book-card.css'teki .price kuralını @reference olmadan, yalnız theme değişkenleriyle yaz (font-size: var(--text-lg) gibi). Build'deki main bundle boyutunu ve tarayıcıdaki component <style> uzunluğunu öncesiyle karşılaştır.

★★★ Format filtresinin yanına Angular Aria'nın Menu directive'iyle bir "Sort by" menüsü ekle. Menünün açık/kapalı durumunu aria-expanded: ile, seçili sıralamayı aria-checked: ile stille ve klavyeyle (Enter, ok tuşları, Escape) çalıştığını Playwright ile doğrula.

Bir sonraki yazıda büyüyen projeye bakıyoruz: variant'lı component API'leri için küçük bir class map yaklaşımı, çakışan class'ları birleştiren tailwind-merge, Prettier ile class sıralama ve npx @tailwindcss/upgrade ile v3'ten v4'e geçiş.