Tailwind v4 Kurulumu ve CSS-first Configuration: @import "tailwindcss", @source ve Oxide Engine
Tailwind v4'te configuration CSS'te durur: @import "tailwindcss" theme, Preflight ve utility'leri üç layer'a koyar, Oxide engine kaynakları düz metin olarak tarar ve yalnız tam adını gördüğü class'ları üretir. Bookstore.Web'de CLI ile input.css → site.css zincirini --watch ve --minify ile kuruyor, BeforeTargets="Build" yüzünden temiz publish'te site.css'in neden 404 döndüğünü, .gitignore'un taramaya etkisini, @source not/inline'ı ve --cat-color ile dinamik renkleri gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Tailwind CSS 4.3.5 (@tailwindcss/cli ve @tailwindcss/vite), Vite 8.3, Node.js 24, .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 sunucu taraflı arayüz Bookstore.Web'e Tailwind'i ekliyoruz. İnternette en sık görülen kurulum şu: package.json'a bir css:build script'i, csproj'a da Release build'de onu çalıştıran bir Target.
<Target Name="TailwindBuild" BeforeTargets="Build" Condition="'$(Configuration)' == 'Release'">
<Exec Command="npm run css:build" />
</Target>
Repo'yu temiz klonluyoruz (site.css repo'da yok, .gitignore'da), dotnet publish -c Release çalıştırıyoruz. Build log'unda > css:build satırı görünüyor, yayın klasöründe wwwroot/css/site.css dosyası da var. Uygulamayı Production ortamında başlatıp /css/site.css istediğimizde ne döner?
Bu yazıda Tailwind v4'ü kuruyoruz: kurulum yollarını, @import "tailwindcss" satırının ne getirdiğini, ASP.NET Core MVC'de (Model-View-Controller; isteği controller'ın karşıladığı, HTML'i view'ın ürettiği yapı) input.css'ten site.css'e giden zinciri --watch ve --minify ile, Oxide engine'in kaynak dosyaları nasıl taradığını ve tarayıcıya hiç ulaşmayan class'ların sebebini. Tahminin cevabı Release bölümünde.
Kurulum Yolları
Tailwind v4'te bir tailwind.config.js dosyası gerekmiyor; configuration CSS dosyasının içinde. Değişen tek şey, o CSS dosyasını hangi aracın işleyeceği:
| Yol | Paket | Ne zaman |
|---|---|---|
| --- | --- | --- |
| Vite plugin'i | @tailwindcss/vite | Vite kullanan projeler (React, Vue, Svelte, düz Vite) |
| PostCSS plugin'i | @tailwindcss/postcss | Build'i PostCSS'ten geçen araçlar; Angular CLI bu yolu kullanıyor |
| CLI | @tailwindcss/cli | Bundler'ı olmayan projeler: ASP.NET Core MVC, Razor Pages, statik site |
| webpack loader'ı | @tailwindcss/webpack | webpack kullanan projeler; v4.2 ile geldi |
| Standalone CLI | GitHub release'indeki tek dosya | Node.js kurmak istemediğin ortamlar |
Hepsinin arkasında aynı compiler var; aynı input.css aynı CSS'i üretir. Vite yolunu küçük bir sayfayla denedik:
// 02-vite/vite.config.js
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
root: "02-vite",
plugins: [tailwindcss()],
});
npx vite build --config 02-vite/vite.config.js
vite v8.3.8 building client environment for production...
transforming...
✓ 3 modules transformed.
rendering chunks...
computing gzip size...
02-vite/dist/index.html 0.28 kB │ gzip: 0.21 kB
02-vite/dist/assets/index-BreYr4sd.css 4.97 kB │ gzip: 1.72 kB
✓ built in 42ms
PostCSS yolunu serinin Angular yazısında, ng add tailwindcss'in ürettiği .postcssrc.json ile göreceğiz. webpack loader'ı v4.2 duyurusunda PostCSS'i atlayan ayrı bir yol olarak tanıtıldı; bu yazıda çalıştırmadık. Standalone CLI ise her release'te Windows, macOS ve Linux için ayrı birer çalıştırılabilir dosya olarak yayımlanıyor (v4.3.5'te tailwindcss-windows-x64.exe dahil yedi dosya); onu da çalıştırmadık, çünkü Bookstore.Web için npm paketini package-lock.json ile sabitlemek, sürümü ekipte ve CI'da (Continuous Integration; her commit'te otomatik build ve test) aynı tutmanın en kolay yolu.
@import "tailwindcss" Ne Getiriyor?
v3'teki üç @tailwind satırının yerini tek satır aldı. Bu satır aslında üç dosyayı üç cascade layer'a koyuyor:
/* @import "tailwindcss"; satırının açılmış hâli */
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css" layer(utilities);
theme.css, renk paleti, spacing ölçeği, font, breakpoint ve diğer design token'ları @theme bloğu olarak tanımlıyor. preflight.css, ilk yazıda CMS (içerik yönetim sistemi) içeriğinde etkisini ölçtüğümüz temel stil sıfırlaması. utilities.css ise tek bir @tailwind utilities satırı: kaynak dosyalarda bulunan class'ların CSS'i oraya üretiliyor. İlk yazının ölçüm script'i Preflight'ı dışarıda bırakmak için bu açılmış hâli kullanıyordu. @import artık Tailwind'in kendisi tarafından çözüldüğü için postcss-import gibi ek bir plugin'e de gerek kalmadı.
Bookstore.Web'e CLI ile Kurulum
Bookstore.Web'de bundler yok; Razor view'ları sunucuda HTML üretiyor. Bu yüzden CLI'ı proje klasörüne npm paketi olarak kuruyoruz:
{
"name": "bookstore-web-styles",
"private": true,
"scripts": {
"css:watch": "tailwindcss -i ./Styles/input.css -o ./wwwroot/css/site.css --watch",
"css:build": "tailwindcss -i ./Styles/input.css -o ./wwwroot/css/site.css --minify"
},
"devDependencies": {
"@tailwindcss/cli": "4.3.5",
"tailwindcss": "4.3.5"
}
}
Kaynak dosya Styles/input.css, çıktı wwwroot/css/site.css. Kaynağı wwwroot dışında tutmamızın sebebi basit: wwwroot altındaki her dosya yayınlanır. İlk denemede input.css'i wwwroot/css altına koymuştuk ve yayınlanmış uygulama /css/input.css isteğine 200 dönüyordu, üstelik Brotli ve gzip sürümleriyle; derlenmemiş kaynak dosyanın tarayıcıya açık olması için bir sebep yok. Layout tek bir stylesheet bağlıyor:
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true">
.gitignore dosyası da bu kurulumun bir parçası. site.css build çıktısı olduğu için repo'ya girmiyor; bin/ ve obj/ satırlarının neden önemli olduğunu içerik taraması bölümünde göreceğiz:
bin/
obj/
node_modules/
wwwroot/css/site.css
Bu sitenin kendi kurulumu da aynı zincir: bir input.css, CLI ile üretilen bir site.css ve Release build'de --minify çalıştıran bir csproj Target'ı.
Geliştirmede --watch, Release'de --minify
Geliştirme: iki terminal
Geliştirirken iki komut yan yana çalışıyor: bir terminalde dotnet watch run, ötekinde npm run css:watch. CLI ilk build'i yapıyor, sonra dosya değişikliklerini bekliyor. Kart partial'ındaki başlığa underline-offset-4 hover:underline ekledik:
≈ tailwindcss v4.3.5
Done in 25ms
Done in 4ms
İlk build 25 ms, view değişikliğinden sonraki artımlı build 4 ms. Yeni class'lar site.css'e geldi, tarayıcıyı yenilemek yetti.
Bir ayrıntı: watch modu terminalin stdin'i kapanınca kendini kapatıyor. Komutu arka planda, stdin'i kapalı bir süreç olarak başlattığımızda (bazı IDE task'ları ve script'ler böyle başlatır) yalnız banner satırı basıldı ve süreç hemen çıktı. CLI'ın yardım metni bunun için --watch=always seçeneğini veriyor; aynı komutu --watch=always ile başlatınca süreç açık kaldı ve değişiklikleri yakaladı.
Release: csproj Target'ı ve tahminin cevabı
Tahmindeki Target'ı temiz bir klonda denedik. Sonuç:
dotnet publish -c Release -o ../../publish
Bookstore.Web -> .../bin/Release/net10.0/Bookstore.Web.dll
> css:build
Bookstore.Web -> .../publish/
Yayınlanmış uygulamayı ASPNETCORE_ENVIRONMENT=Production ile 127.0.0.1:52861'de başlatıp iki CSS dosyasını istedik:
node scripts/02-assets.mjs
404 /css/site.css
404 /css/input.css
site.css yayın klasöründe olduğu hâlde 404 döndü. Log'daki sıra ipucunu veriyor: css:build, assembly derlendikten sonra çalışmış. BeforeTargets="Build", Build target'ının kendisinden önce çalışır ama Build'in bağımlılıklarından (derleme ve static web asset'lerin toplanması) sonra. MapStaticAssets, build sırasında üretilen bir manifest'ten okuyor; site.css o sırada henüz yoktu, manifest'e girmedi ve uygulama onu hiç tanımadı. Dosya klasörde duruyor ama endpoint'i yok.
Düzeltme iki parçalı. Target, BeforeBuild'den önce çalışmalı; üstelik proje değerlendirildiğinde (evaluation) var olmayan bir dosya Content item'larında yer almadığı için onu elle eklemek gerekiyor:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- npm dosyaları build girdisi, uygulamanın parçası değil -->
<Content Update="package.json;package-lock.json" CopyToOutputDirectory="Never" CopyToPublishDirectory="Never" />
</ItemGroup>
<!-- temiz klon ya da CI: package-lock.json'daki sürümleri kur -->
<Target Name="NpmInstall" BeforeTargets="TailwindBuild" Condition="!Exists('node_modules')">
<Exec Command="npm ci" />
</Target>
<!-- static web asset'ler toplanmadan önce çalışır; build sırasında oluşan dosya Content'e elle eklenmeli -->
<Target Name="TailwindBuild" BeforeTargets="BeforeBuild" Condition="'$(Configuration)' == 'Release' Or !Exists('wwwroot/css/site.css')">
<Exec Command="npm run css:build" />
<ItemGroup>
<Content Include="wwwroot/css/site.css" Exclude="@(Content)" />
</ItemGroup>
</Target>
</Project>
dotnet publish -c Release -o ../../publish
ls ../../publish/wwwroot/css
site.css
site.css.br
site.css.gz
200 /css/site.css
404 /css/input.css
Artık site.css manifest'te; yanında Brotli ve gzip sürümleri ve dosya adında hash taşıyan bir endpoint de var (css/site.jjvbubydq3.css). İki parçayı ayrı ayrı da denedik: yalnız BeforeBuild yapıp Content satırını eklemediğimizde ya da Content satırını ekleyip BeforeTargets="Build" bıraktığımızda dosya yine manifest'e girmedi. Condition'daki !Exists(...) kısmı da temiz klonda Debug build'in bir kez site.css üretmesini sağlıyor; sonrasını watch süreci üstleniyor. package.json'ı Content'ten çıkardık, çünkü Web SDK'sı onu da bir JSON dosyası olarak yayın klasörüne kopyalıyordu.
Automatic Content Detection ve Oxide
v3'te hangi dosyaların taranacağını content dizisinde sen yazıyordun. v4'te Tailwind kendisi buluyor ve ne yaptığını bilmek, hem eksik class'ları hem de gereksiz büyüyen CSS'i açıklıyor.

Tarama, Oxide adı verilen ve Rust ile yazılmış bir parça tarafından yapılıyor. npm'de @tailwindcss/oxide paketi her platform için ayrı bir native paket çekiyor; Windows'ta @tailwindcss/oxide-win32-x64-msvc. Scanner dosyaları programlama dili olarak değil düz metin olarak okuyor ve class adına benzeyen her token'ı aday olarak topluyor. Compiler sonra bu adaylardan yalnız tanıdığı utility'lere karşılık gelenler için CSS üretiyor. Bu yüzden .cshtml, .cs, .ts, .html, hatta .json dosyası fark etmiyor; Razor'un söz dizimini bilmesine gerek yok.
Varsayılan olarak taranmayanlar: .gitignore'daki yollar, node_modules, görsel ve arşiv gibi binary dosyalar, CSS dosyaları ve lock dosyaları. CLI'da tarama, komutun çalıştığı klasörden başlıyor. Bu yüzden .gitignore bir kolaylık değil, kurulumun bir parçası. Bunu obj/ klasörüne içinde bg-lime-300 geçen bir dosya koyarak denedik. Script projeyi önce repo içinde, sonra git dışı geçici bir klasöre kopyalayıp .gitignore'suz ve dotnet new gitignore'un ürettiği dosyayla derliyor:
node scripts/02-scan.mjs
in repo: lime=0
no git: lime=1
no git + dotnet new gitignore: lime=0
Repo içinde obj/ zaten .gitignore'da olduğu için class üretilmedi. Projeyi git dışı bir klasöre kopyalayınca obj/ taranmaya başladı ve bg-lime-300 CSS'e girdi. Aynı klasöre dotnet new gitignore ile .gitignore ekleyince yine dışarıda kaldı; yani Tailwind .gitignore'u bir git repo'su olmasa da okuyor ve o dosyadaki [Oo]bj/ gibi kalıpları da anlıyor.
@source, @source not ve @source inline
Otomatik taramanın yetmediği ya da fazla geldiği yerler için üç directive var. Bookstore.Web'in input.css'inin son hâli:
@import "tailwindcss";
/* wwwroot/lib: üçüncü parti script'ler, bizim kodumuz değil, taranmasın */
@source not "../wwwroot/lib";
/* yalnız CMS içeriğinde geçen class'lar: kaynakta yoklar, yine de üret */
@source inline("{hover:,}bg-amber-{100,200}");
@source not ile başlayalım. ASP.NET Core projelerinde üçüncü parti kütüphaneler genellikle LibMan ile wwwroot/lib altına kopyalanır. Bu klasörde class adına benzeyen kelimeler (hidden, block, container, truncate…) içeren bir vendor script'i varken ve yokken site.css boyutunu ölçtük:
Önce @source not satırını yorum satırı yaparak, sonra satır yerindeyken:
npm run css:build --silent && wc -c wwwroot/css/site.css
15001 wwwroot/css/site.css
10265 wwwroot/css/site.css
Script'teki kelimelerin çoğu gerçek bir utility'ye denk geldiği için CSS 4.7 KB büyümüştü. Bu class'ları hiçbir view kullanmıyordu; yalnız bir string array'inde duruyorlardı.
@source "<yol>" ise tersini yapıyor: varsayılan olarak taranmayan bir yeri ekliyor. En yaygın kullanımı, Tailwind ile yazılmış ve node_modules altına kurulmuş bir component kütüphanesini taramak, ya da CLI'ı proje klasöründen başka bir yerden çalıştırdığında view klasörünü göstermek. Yollar CSS dosyasına göredir. Bu sitenin input.css'inde de @source "../../Views" ve @source "../js" satırları var; CLI proje klasöründen çalıştığı sürece bu klasörler zaten taranıyor, ama satırlar komut başka bir klasörden çalıştırıldığında da view'ların bulunmasını garanti ediyor. @import "tailwindcss" source(none); otomatik taramayı tamamen kapatıyor; ilk yazının ölçüm script'i her adımda yalnız o adımın HTML'ini taramak için bunu kullanmıştı.
@source inline() kaynakta hiç geçmeyen class'ları üretiyor; v3'teki safelist'in karşılığı. Bookstore'un kampanya bantları CMS'ten class'larıyla birlikte geliyor ve bu class'lar repo'da hiçbir dosyada yok. Süslü parantez açılımı birden çok class ve variant'ı tek satırda yazmayı sağlıyor:
npx tailwindcss -i Styles/input.css | grep bg-amber
.bg-amber-100
.bg-amber-200
.hover\:bg-amber-100:hover
.hover\:bg-amber-200:hover
Dinamik Class Adları: Tam Ad Kuralı
Scanner'ın düz metin okuması bir kuralı zorunlu kılıyor: class'ın tam adı kaynakta bir yerde yazılı olmalı. Kitap kartına kategori rozeti ekledik. Kategorinin rengi back office'te seçiliyor; elimizde eski palet adı (sky, emerald, rose) ve hex değeri var. İlk akla gelen Razor kodu:
<span class="category-badge w-fit rounded-full px-2 py-0.5 text-xs font-medium bg-@(Model.Category.ColorName)-100 text-@(Model.Category.ColorName)-800">@Model.Category.Name</span>
Sunucunun ürettiği HTML doğru görünüyor: class="... bg-sky-100 text-sky-800". Ama Playwright ile yayınlanmış uygulamada ölçünce:
node scripts/02-badges.mjs
Dune category: bg rgba(0, 0, 0, 0), text oklch(0.208 0.042 265.755) | stock: -
The Hobbit category: bg rgba(0, 0, 0, 0), text oklch(0.208 0.042 265.755) | stock: -
Emma category: bg rgba(0, 0, 0, 0), text oklch(0.208 0.042 265.755) | stock: -
Arka plan şeffaf, yazı rengi de rozetin değil body'nin rengi (slate-900). Scanner .cshtml dosyasında bg- ve -100 parçalarını gördü ama bg-sky-100'ü hiçbir yerde görmedi; CSS'te böyle bir kural yok. Sunucu tarafında doğru HTML'in üretilmesi bir şey kanıtlamıyor, çünkü CSS build zamanında, HTML'den bağımsız üretiliyor.
İki doğru yol var ve hangisinin seçileceği değer kümesine bağlı. Değerler az ve sabitse (stok durumu gibi) tam class adlarını kodda yaz. Değerler veriden geliyorsa (back office'te seçilen kategori rengi gibi) rengi bir CSS custom property olarak ver ve utility'yi o değişkene bağla:
@model BookCardViewModel
@{
// tam class adları: Tailwind yalnız kaynakta gördüğünü üretir
string stockClasses = Model.InStock
? "bg-emerald-50 text-emerald-700"
: "bg-slate-100 text-slate-500";
}
<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">
<div class="flex flex-wrap gap-2">
<span class="category-badge rounded-full bg-(--cat-color)/15 px-2 py-0.5 text-xs font-medium text-(--cat-color)" style="--cat-color: @Model.Category.Color">@Model.Category.Name</span>
<span class="stock-badge rounded-full px-2 py-0.5 text-xs font-medium @stockClasses">@(Model.InStock ? "In stock" : "Out of stock")</span>
</div>
<!-- başlık, yazar ve footer değişmedi -->
</article>
bg-(--cat-color)/15, v4'ün custom property syntax'ı: parantez içindeki değişkeni var() ile sarıyor, /15 opacity modifier'ı da onu color-mix() ile yüzde 15'e indiriyor. Üretilen CSS:
.bg-\(--cat-color\)\/15 {
background-color: var(--cat-color);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--cat-color) 15%, transparent);
Dune category: bg oklab(0.587604 -0.0652444 -0.122562 / 0.15), text rgb(2, 132, 199) | stock: bg oklch(0.979 0.021 166.113), text oklch(0.508 0.118 165.612)
The Hobbit category: bg oklab(0.595965 -0.121967 0.0367818 / 0.15), text rgb(5, 150, 105) | stock: bg oklch(0.979 0.021 166.113), text oklch(0.508 0.118 165.612)
Emma category: bg oklab(0.585846 0.211683 0.0670975 / 0.15), text rgb(225, 29, 72) | stock: bg oklch(0.968 0.007 247.896), text oklch(0.554 0.046 257.417)
Her kategori kendi rengini aldı; Emma stokta olmadığı için gri rozetle geldi. CSS'te kategori başına bir kural yok, tek bir kural var; yeni bir kategori eklemek build gerektirmiyor. Bu sitenin kategori rozetleri de aynı yöntemle, --cat-color inline değişkeniyle çalışıyor. Custom property'lerin kendisini ve color-mix()'i CSS3 serisinin custom property ve renk yazılarında anlatmıştık.
Üretimde Dikkat
CI'da npm ci'ın çalıştığından emin ol. csproj'daki NpmInstall Target'ı node_modules yoksa onu çağırıyor; yani build makinesinde Node.js kurulu olmalı. Docker imajında bu, SDK imajına Node.js eklemek ya da CSS'i ayrı bir aşamada üretip kopyalamak demek. Hangisini seçersen seç, CSS'i üreten Tailwind sürümünü package-lock.json belirlesin; npx @tailwindcss/cli gibi sürümsüz bir çağrı, her build'de npm'den o günün son sürümünü çekebilir.
npm 11'in yeni install-scripts denetimi npm ci sırasında @parcel/watcher paketinin install script'i için bir uyarı basıyor. Bu paketin hazır derlenmiş platform paketi de kurulduğu için build ve watch modu bizde uyarıya rağmen çalıştı; yine de CI log'unda bu satırı görünce şaşırma.
Son olarak boyutu izle. Bookstore.Web'in minify edilmiş site.css'i Preflight dahil yaklaşık 10 KB. Bir sonraki sürümde bu sayı birden 14 KB olursa, wwwroot/lib örneğindeki gibi birinin istemeden yeni bir klasörü taramaya açtığını düşün.
Sık Düşülen Tuzaklar
İlki bu yazının ana bulgusu: CSS'i üreten Target'ı BeforeTargets="Build" ile bağlamak. Geliştirme makinesinde site.css zaten var olduğu için her şey çalışır; hata yalnız temiz klonda, yani CI'da ve ilk deploy'da çıkar.
İkincisi, --watch'ı stdin'i kapalı bir süreçte çalıştırmak. Süreç hata vermeden çıkar ve CSS bir daha güncellenmez; geliştirici "Tailwind class'ımı görmüyor" diye saatlerce arar. Böyle ortamlarda --watch=always kullan.
Üçüncüsü, .gitignore olmadan çalışmak. bin/ ve obj/ taranırsa yalnız build yavaşlamaz, derleme çıktısındaki rastgele kelimeler de CSS'e class olarak girer.
Dördüncüsü, class adını string birleştirerek üretmek. Bu yalnız Razor'da değil, C#'ta $"text-{color}-600" ya da JavaScript'te template literal ile de aynı sonucu verir. @source inline ile tüm olasılıkları üretmek bir çıkış gibi görünse de değer kümesi büyüdükçe CSS'i şişirir; değer veriden geliyorsa custom property daha temiz.
Küçük Bir Deneme
★☆☆ Styles/input.css'teki @source not satırını yorum satırı yap, npm run css:build çalıştır ve site.css boyutunu ölç. Sonra wwwroot/lib/vendor-widget/widget.js'teki string array'ine animate-spin ekleyip tekrar ölç.
★★☆ Kart partial'ındaki stok rozetinin class'larını bir C# switch ifadesiyle üç duruma çıkar: stokta, az kaldı (5'ten az), tükendi. Her durum için tam class adlarını yaz ve Playwright script'iyle üç rengi doğrula.
★★★ csproj'daki TailwindBuild Target'ında BeforeTargets değerini Build yap, bin/, obj/ ve site.css'i silip dotnet publish -c Release çalıştır. Yayın klasöründeki Bookstore.Web.staticwebassets.endpoints.json dosyasında css/site.css route'unu ara ve bulamadığını doğrula; sonra düzeltilmiş hâline dönüp aynı aramayı yap.
Bir sonraki yazıda @theme bloğuna giriyoruz: Bookstore'un renklerini ve fontlarını design token olarak tanımlayıp hem utility hem CSS değişkeni olarak kullanacağız, varsayılan temayı genişletmek ile sıfırlamak arasındaki farkı, @theme inline ve @theme static'i ve class stratejisiyle dark mode'u kuracağız.