Mert Özen Her Satırda Daha İleri
Frontend CSS3: Flexbox, Grid ve Modern CSS — #10

Custom Property'ler ve Theming: var(), @property, light-dark() ile Dark Mode

Mert Özen 14 Kas 2026 12 dk 1 görüntülenme
Custom Property'ler ve Theming: var(), @property, light-dark() ile Dark Mode

Custom property'ler cascade ve inheritance içinde çalışma anında yaşar; koyu tema için renk token'larını light-dark() ile tanımlayıp color-scheme'i değiştirmek yeter. var() fallback'ini, invalid at computed-value time tuzağını, @property ile tipli ve animasyonlanabilir değişkenleri, kullanıcının seçimini localStorage ile hatırlayıp FOUC'u önleyen inline script'i ve CMS'ten gelen kategori rengini Bookstore üzerinde ölçüyoruz.

Sürüm notu: Bu yazı CSS Custom Properties Level 1, CSS Properties and Values API Level 1 ve CSS Color Adjustment spesifikasyonlarına 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 3.46 verisinden alınmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için fiyat etiketine bir vurgu rengi vermek istiyoruz. Bir arkadaşımız tedbirli davranıp önce sabit bir renk, hemen altına da custom property'li hali yazmış; custom property bir şekilde bozuksa tarayıcının ilk satıra düşeceğini düşünüyor. Ama --accent'e yanlışlıkla bir uzunluk yazılmış:

/* 1) inheritance, scope ve fallback */
.card { --badge-color: rgb(185, 28, 28); }
.badge { color: var(--badge-color, rgb(107, 114, 128)); }

/* 2) invalid at computed-value time: önceki color declaration'ı çoktan elendi */
.price {
    --accent: 20px;
    color: rgb(37, 99, 235);
    color: var(--accent);
}

.badge'ı şimdilik geç, .price'a bak. 20px bir renk değil. Fiyat mavi mi görünür (rgb(37, 99, 235)), yoksa başka bir renk mi? Tarayıcı geçersiz bir değerle karşılaşınca bir önceki declaration'a dönmez mi?

Serinin şimdiye kadarki yazılarında --color-accent, --space gibi custom property'leri kullandık ama nasıl çalıştıklarını ertelemiştik. Bu yazıda onları cascade ve inheritance içinde inceliyoruz, @property ile tip veriyoruz ve Bookstore'a light-dark() ile, kullanıcının seçimini hatırlayan ve sayfa açılırken yanlış renkte görünmeyen bir koyu tema ekliyoruz.

Custom Property'ler Cascade ve Inheritance İçinde Yaşar

Custom property, adı iki tire ile başlayan sıradan bir CSS property'si. Selector'le seçilir, cascade'den geçer, specificity ve layer kurallarına uyar ve varsayılan olarak inherit edilir. var() ise o element'te hesaplanmış değeri okur. Ölçüm script'i lab sayfasını açıp sonuçları yazıyor:

node scripts/10-theme.mjs
--- chromium
badge in card rgb(185, 28, 28), outside rgb(107, 114, 128)
price with --accent: 20px -> color rgb(0, 0, 0)
half way: --stock-level 50%, --plain-level 100%
child of .stock sees --stock-level 0%
--stock-level: 50% then 20px -> 0%
light-dark() token: rgb(255, 255, 255) outside, rgb(17, 24, 39) in the dark band
system light          data-theme=(none) color-scheme light dark: page rgb(255, 255, 255) text rgb(31, 41, 55) | card without cover rgb(254, 243, 199) | toggle aria-pressed=false
system dark           data-theme=(none) color-scheme light dark: page rgb(17, 24, 39) text rgb(229, 231, 235) | card without cover rgb(66, 32, 6) | toggle aria-pressed=true
system dark, clicked     data-theme=light color-scheme light: page rgb(255, 255, 255) text rgb(31, 41, 55) | card without cover rgb(254, 243, 199) | toggle aria-pressed=false
after reload             data-theme=light color-scheme light: page rgb(255, 255, 255) text rgb(31, 41, 55) | card without cover rgb(254, 243, 199) | toggle aria-pressed=false
category dots: Science Fiction rgb(37, 99, 235), Classics rgb(180, 83, 9), Children rgb(107, 114, 128)

İlk satır inheritance ve fallback. --badge-color yalnız .card üzerinde tanımlı; kartın içindeki rozet onu inherit ediyor ve kırmızı (rgb(185, 28, 28)). Kartın dışındaki rozet için bu property hiç tanımlı değil, bu yüzden var()'ın ikinci argümanı, gri fallback kullanıldı. Token'ları bu şekilde scope'lamak mümkün: global token'lar :root'ta, bir component'e özel olanlar component'in kök element'inde. Component içindeki her şey ikincisini, dışarıdaki her şey birincisini görür.

Sass değişkenleriyle aradaki fark burada ortaya çıkıyor. $accent gibi bir Sass değişkeni derleme anında değere dönüşür ve çıktı CSS'te iz bırakmaz; tarayıcı onu hiç görmez. Custom property ise tarayıcıda, çalışma anında yaşar: bir media query, bir class, bir inline style ya da JavaScript onu değiştirebilir ve değişiklik bütün alt ağaca yayılır. Bu yazıdaki koyu temanın tamamı buna dayanıyor. İkisinin ne zaman birlikte kullanılacağı Sass/SCSS serisinin konusu.

"Invalid at Computed-value Time" Tuzağı

İkinci satır tahmin sorusunun cevabı: fiyat ne mavi ne kırmızı, siyah (rgb(0, 0, 0)), yani body'den inherit edilen renk. Sebebi, var() içeren bir declaration'ın ne zaman doğrulandığı. Tarayıcı CSS'i parse ederken color: var(--accent)'in geçerli olup olmadığını bilemez, çünkü --accent'in değeri element'e göre değişebilir. Bu yüzden declaration'ı geçerli sayar ve cascade'i normal şekilde çalıştırır: aynı kuraldaki iki color declaration'ından sonraki kazanır, mavi olan elenir. Değer ancak computed-value aşamasında yerine konur; 20px bir renk olmadığı için declaration o anda geçersiz olur. Buna "invalid at computed-value time" denir ve sonuç, property'nin unset değeri: color inherit edilen bir property olduğu için ebeveynin rengi, inherit edilmeyen bir property'de (örneğin background-color) başlangıç değeri.

Yani "önce sabit değer, altına custom property'li hali" fallback'i işe yaramıyor. Gerçek fallback iki yoldan biriyle yazılır: var(--accent, rgb(37, 99, 235)) ile (yalnız property hiç tanımlı değilse çalışır) ya da property'yi @property ile tiplendirerek. İkincisi bir sonraki bölümün konusu.

@property ile Tipli Custom Property

Kayıtsız bir custom property'nin tipi yoktur; tarayıcı için değeri yalnız bir token dizisidir. @property bir custom property'yi syntax'ı, inherit edilip edilmeyeceği ve başlangıç değeriyle kaydeder:

/* 3) kayıtlı property: tipli, inherit edilmez, animasyonlanabilir */
@property --stock-level {
    syntax: "<percentage>";
    inherits: false;
    initial-value: 0%;
}
.stock { --plain-level: 0%; transition: --stock-level 1s linear, --plain-level 1s linear; }
.stock.full { --stock-level: 100%; --plain-level: 100%; }
/* 20px bir yüzde değil: syntax yalnız computed-value aşamasında kontrol edilir,
   cascade 50%'yi çoktan eledi ve başlangıç değeri kullanılır */
.bad-stock { --stock-level: 50%; --stock-level: 20px; }

Çıktının üçüncü satırı farkı gösteriyor. .stock'a full class'ı eklendiğinde iki custom property de 0%'dan 100%'e geçiyor ve ikisine de 1 saniyelik transition tanımlı. Script iki transition'ı da yarısında (500ms) durduruyor. Kayıtlı --stock-level tam 50%'de; tarayıcı onun bir yüzde olduğunu bildiği için ara değerleri hesaplayabiliyor. Kayıtsız --plain-level ise doğrudan 100%; token dizisi interpole edilemediği için transition hiç oluşmadı. Bir gradient'in renk durağını ya da bir stok çubuğunun doluluk oranını animasyonla değiştirmek için @property şart.

Dördüncü satır: .stock'un child'ı --stock-level'ı 0% görüyor, çünkü inherits: false. Yalnız tanımlandığı element'i ilgilendiren bir değeri (bir animasyonun ilerlemesi gibi) bu şekilde alt ağaca sızdırmamak hem daha doğru hem de tarayıcı için daha ucuz: değer değiştiğinde bütün alt ağacın stilini yeniden hesaplaması gerekmez.

Beşinci satır da az önceki tuzağın kayıtlı property'deki hali. .bad-stock önce 50%, sonra 20px diyor ve sonuç 0%: başlangıç değeri. Properties and Values API spesifikasyonu, kayıtlı syntax'ın parse anında değil computed-value aşamasında kontrol edildiğini açıkça söylüyor. Cascade 20px'i seçmiş, 50% çoktan elenmiş; uyuşmazlık sonradan fark edilince property kayıtlı başlangıç değerine dönüyor. Fark şu: kayıtsız property'de bu durum var()'ı kullanan declaration'ı bozuyordu, kayıtlı property'de ise en azından tanımlı ve tipine uygun bir değer (initial-value) elde ediliyor.

Bookstore'un Token'ları ve light-dark()

Koyu tema için önce bütün sabit renkleri token'lara topladım. Serinin önceki yazılarında bileşen dosyalarına dağılmış birkaç renk vardı (odak halkası, kapaksız kartın zemini); hepsi artık base.css'te:

/* tasarım token'ları: her renk tek yerde; light-dark() kullanılan color-scheme'e göre değer seçer */
:root {
    color-scheme: light dark;
    --color-text: light-dark(#1f2937, #e5e7eb);
    --color-muted: light-dark(#6b7280, #9ca3af);
    --color-surface: light-dark(#ffffff, #111827);
    --color-border: light-dark(#e5e7eb, #374151);
    --color-accent: light-dark(#b91c1c, #f87171);
    --color-focus: light-dark(#1d4ed8, #93c5fd);
    --color-tint: light-dark(#fef3c7, #422006);
    --radius: 0.5rem;
    --space: 1rem;
    font-family: system-ui, sans-serif;
    line-height: 1.5;
    color: var(--color-text);
    background-color: var(--color-surface);
}

/* kullanıcının kendi seçimi (theme.js) sistem tercihini yener */
:root[data-theme="light"] {
    color-scheme: light;
}

:root[data-theme="dark"] {
    color-scheme: dark;
}

h1, h2, h3 {
    line-height: 1.2;
}

/* fluid başlık: viewport ile 28px ve 40px arasında büyür; rem payı zoom'u çalışır tutar */
h1 {
    font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem);
}

color-scheme: light dark, tarayıcıya sayfanın iki temayı da desteklediğini söylüyor; tarayıcı kendi çizdiği şeyleri (form kontrolleri, scrollbar, varsayılan arka plan) buna göre seçiyor. light-dark(A, B) ise element'in kullanılan color-scheme'i açıksa A'yı, koyuysa B'yi veriyor. Kullanılan color-scheme light dark olduğunda kullanıcının işletim sistemindeki tercihe göre belirleniyor. Çıktıdaki "system light" ve "system dark" satırlarında sayfa zemini rgb(255, 255, 255)'ten rgb(17, 24, 39)'a, kapaksız kartın zemini sarıdan koyu kahveye geçti; tek bir media query yazmadım.

Lab'deki son satır önemli bir ayrıntıyı gösteriyor:

/* 4) kayıtsız bir token'daki light-dark() kullanıldığı yerde çözülür */
:root { color-scheme: light; --token: light-dark(rgb(255, 255, 255), rgb(17, 24, 39)); }
.surface { background-color: var(--token); }
.dark-band { color-scheme: dark; }

Token :root'ta tanımlı ve :root açık temada. Buna rağmen .dark-band içindeki .surface'in zemini koyu (rgb(17, 24, 39)). Kayıtsız bir custom property light-dark()'ı çözmeden, olduğu gibi taşır; fonksiyon, token'ın kullanıldığı element'te, o element'in color-scheme'ine göre çözülür. Yani sayfanın tek bir bölümüne color-scheme: dark vermek o bölümü bütün token'larıyla koyu yapar. Bu token'ları @property ile <color> olarak kaydetseydin değer :root'ta bir kere çözülür ve bu davranış kaybolurdu. Renk token'larını kaydetmeden önce iki kere düşün.

prefers-contrast: more için bir önceki yazıda yazdığımız token'ları da light-dark()'a çevirdim; aksi halde yüksek kontrast isteyen bir kullanıcı koyu temada açık renkli kenarlıklar görürdü. Eylül 2026'dan beri light-dark() görsel değerleri de kabul ediyor (background-image: light-dark(url(…), url(…))); iki motor da bunu destekliyor.

Kullanıcının Seçimi: data-theme, localStorage ve FOUC

Sistem tercihini izlemek iyi bir varsayılan ama yetmez: gündüz koyu tema kullanmak isteyen ya da bir sitenin koyu temasını beğenmeyen kullanıcılar var. Kullanıcının seçimi sistem tercihini yenmeli ve bir sonraki ziyarette hatırlanmalı. Bookstore'da seçimi <html>'in data-theme attribute'u taşıyor; base.css'teki iki kural bu attribute'a göre color-scheme'i tek bir değere sabitliyor ve light-dark()'lı bütün token'lar kendiliğinden uyuyor. Footer'daki butonu yöneten script:

// Kullanıcının seçimi sistem tercihini yener; kayıtlı seçim yoksa sayfa sistemi izler.
const root = document.documentElement;
const button = document.querySelector('.theme-toggle');

const isDark = () => root.dataset.theme
    ? root.dataset.theme === 'dark'
    : matchMedia('(prefers-color-scheme: dark)').matches;

const render = () => button.setAttribute('aria-pressed', String(isDark()));

button.addEventListener('click', () => {
    root.dataset.theme = isDark() ? 'light' : 'dark';
    try {
        localStorage.setItem('theme', root.dataset.theme);
    } catch {
        // depolama engellenmiş olabilir (gizli pencere, politikalar); seçim o zaman sayfa yenilenene kadar sürer
    }
    render();
});

// sistem tercihi sayfa açıkken değişebilir (örneğin gün batımında otomatik koyu mod)
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
render();

Burada zor olan kısım, sayfa açılırken yanlış temanın bir an görünmesi; buna FOUC (Flash of Unstyled Content; sayfanın bir an yanlış ya da stilsiz görünmesi) deniyor. theme.js defer ile yükleniyor, yani HTML parse edildikten sonra çalışıyor. Kaydedilmiş seçimi o anda uygularsan tarayıcı sayfayı önce sistem temasıyla çizmiş olabilir ve koyu tema seçen bir kullanıcı her sayfa geçişinde bir beyaz parlama görür. Çözüm, seçimi CSS'ten önce, <head> içinde küçük ve render'ı bekleten bir inline script ile uygulamak:

<script>
    // ilk çizimden önce çalışır, böylece kayıtlı tema hiçbir zaman yanlış renkte görünmez
    try {
        const theme = localStorage.getItem('theme');
        if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme;
    } catch { }
</script>
<link rel="stylesheet" href="css/main.css">
<script src="js/theme.js" defer></script>

Bu script bilerek render-blocking: birkaç satır ve ağdan hiçbir şey indirmiyor, bu yüzden bedeli ihmal edilebilir, karşılığında ilk çizim doğru renkle yapılıyor. Bu sitenin kendisi de aynı yaklaşımı kullanıyor. localStorage erişimi try içinde, çünkü bazı tarayıcı ayarları ve gizli pencereler depolamayı engelleyebiliyor; o durumda sayfa sistem temasıyla açılır, hata vermez.

Çıktıdaki son satırlar akışı gösteriyor. Sistem koyu, kullanıcı butona tıklıyor: data-theme=light, color-scheme artık yalnız light ve sayfa açık. Sayfa yeniden yüklendiğinde inline script seçimi CSS'ten önce uyguluyor ve sayfa doğrudan açık temayla geliyor. Butonun aria-pressed değeri de her durumda görünen temayı yansıtıyor; ekran okuyucu kullanıcısı butonun basılı olup olmadığını duyuyor.

Runtime Değerler: Kategori Rengi

Bazı değerleri CSS yazılırken bilemezsin. Bookstore'da kategorilerin rengini yönetim panelinden editörler seçiyor ve değer CMS'ten (Content Management System; içerik yönetim sistemi) geliyor. Her renk için ayrı bir class üretmek mümkün değil; Tailwind gibi build anında class üreten araçlarda da çalışma anında gelen bir renk için class oluşmaz. Doğru araç inline bir custom property:

<li><a class="cat-link" href="catalog.html?category=science-fiction" style="--cat-color: #2563eb">Science Fiction</a></li>
<li><a class="cat-link" href="catalog.html?category=classics" style="--cat-color: #b45309">Classics</a></li>
<li><a class="cat-link" href="catalog.html?category=children">Children</a></li>
/* kategori rengi çalışma anında CMS'ten inline --cat-color olarak gelir; CSS yalnız nereye gideceğine karar verir */
.cat-link::before {
    content: "";
    display: inline-block;
    inline-size: 0.625rem;
    block-size: 0.625rem;
    margin-inline-end: 0.5rem;
    border-radius: 50%;
    background-color: var(--cat-color, var(--color-muted));
}

Sunucu yalnız değeri yazıyor, CSS rengin nereye gideceğine karar veriyor. Çıktının son satırında Science Fiction'ın noktası mavi, Classics'inki turuncu; rengi tanımlanmamış Children ise var()'ın fallback'i olan gri --color-muted'ı kullanıyor. Aynı değeri ileride bir rozette, bir kenarlıkta ya da hover rengi türetmekte kullanmak için HTML'e dokunmak gerekmiyor; bir sonraki yazıda bu tek renkten color-mix() ve relative color ile tonlar türeteceğiz.

Üretimde Dikkat

Custom property'ler 2017'den beri Baseline ve widely available. color-scheme 2022'den, light-dark() Mayıs 2024'ten (Firefox 120, Safari 17.5), @property Temmuz 2024'ten (Firefox 128) beri Baseline. web-features 3.46'da light-dark() ve @property henüz widely available olarak işaretli değil; ikisinin de 30 aylık eşiği önümüzdeki aylarda doluyor. Daha eski bir tarayıcıyı desteklemen gerekiyorsa bir ayrıntıya dikkat et: custom property her token dizisini kabul ettiği için light-dark()'ı tanımayan bir tarayıcı da --color-text: light-dark(…) tanımını geçerli sayar. Sorun token'ın kullanıldığı yerde çıkar: color: var(--color-text) invalid at computed-value time olur ve metin rengi inherit edilen değere düşer. Çözüm, token'ları böyle tarayıcılar için @supports not (color: light-dark(red, red)) bloğunda düz renklerle yeniden tanımlamak; bu tarayıcılar koyu temayı göremez ama sayfa okunur kalır.

Tema değişince bütün sayfa yeniden stillenir; bu normal ve hızlıdır. Ama sık değişen bir değeri (fare konumu, scroll oranı) :root'taki inherit edilen bir custom property'ye yazarsan her değişiklikte bütün ağacın stili yeniden hesaplanır. Böyle değerleri ya onları kullanan element'in üzerine yaz ya da @property ile inherits: false olarak kaydet. Performans ölçümünün ayrıntısı Web Performansı serisinde.

Sık Düşülen Tuzaklar

Birincisi, var()'lı bir declaration'ın üstüne fallback olarak sabit bir değer yazmak. Ölçtüğümüz gibi cascade sabit değeri çoktan eler; fallback'i var()'ın ikinci argümanına yaz.

İkincisi, koyu temayı her bileşende ayrı bir @media (prefers-color-scheme: dark) bloğuyla yazmak. Hem kullanıcının kendi seçimini uygulamak zorlaşır hem de bir bileşen unutulur. Renkleri token'larda light-dark() ile tanımla, bileşenler yalnız token kullansın.

Üçüncüsü, kaydedilmiş temayı defer'lı ya da framework'ün yüklenmesini bekleyen bir script'le uygulamak. Sonuç, her sayfa açılışında yanlış renkte bir an.

Dördüncüsü, renk token'larını alışkanlıkla @property ile kaydetmek. light-dark() artık :root'ta bir kere çözülür ve sayfanın bir bölümünü color-scheme ile koyu yapmak çalışmaz.

Küçük Bir Deneme

★☆☆ Lab'deki .price'ın color: var(--accent) satırını background-color: var(--accent) yap ve .price'a önceden background-color: rgb(254, 243, 199) ver. Sonucun ne olacağını tahmin et, sonra node scripts/10-theme.mjs'e bir satır ekleyerek kontrol et.

★★☆ theme.js'i üç durumlu yap: "System", "Light", "Dark". "System" seçilince data-theme silinsin ve localStorage'daki kayıt kaldırılsın. Script'te sistem koyu iken üç durumu da ölç.

★★★ Kitap kartına @property --cover-shade (<percentage>, inherits: false) ekle ve hover'da kapağın üstündeki karartmayı 0%'dan 30%'a transition ile geçir. Transition'ın yarısında değerin 15% olduğunu Playwright'ın getAnimations()'ı ile doğrula; aynısını kayıtsız bir property ile dene.

Bir sonraki yazıda renklerin kendisine geçiyoruz: hex'ten oklch()'ye, kategori renginden color-mix() ve relative color ile hover tonu türetmeye, contrast-color()'ın neyi garanti ettiğine ve etmediğine, ardından CSS'in matematik fonksiyonlarına.