Color ve Contrast: Palet Kurmak, Semantic Renkler, OKLCH ve WCAG Contrast Oranları
Bir arayüz paleti marka rengi, nötr skala ve semantic renklerden oluşur; OKLCH ile aynı açıklık adımlarını kullanınca her rengin aynı numaralı tonu beyaza karşı neredeyse aynı kontrastı verir. Bookstore kataloğunda WCAG 2.2 oranlarını kendi script'imiz ve axe-core ile ölçüyor, 8 başarısız metni ve yalnız renkle gösterilen stok durumunu düzeltiyor, dark mode'u ters çevirmeden yeniden ayarlıyor ve APCA'nın bugünkü durumunu anlatıyoruz.
Sürüm notu: Bu yazıdaki örnek ekranlar Playwright 1.65 ile Chromium 158'de render edilip ölçülmüştür; kontrast oranları WCAG (Web Content Accessibility Guidelines; web erişilebilirlik standardı) 2.2 formülünü uygulayan kendi script'imizle, çapraz kontrol axe-core 4.14 ile yapılmıştır. Script'ler Node.js 24 ile çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için bookstore-web'in "New arrivals" bölümünde üç kitap kartı var. Kartlarda yazar adı, fiyat, indirim etiketi, stok durumu ve bir buton bulunuyor. Renkler şöyle seçilmiş:
.author { color: #9ca3af; }
.price s { color: #d1d5db; }
.badge { position: absolute; top: 16px; left: 16px; padding: 2px 8px; background: #f59e0b; color: #fff; font-size: 12px; font-weight: 700; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.dot.in { background: #22c55e; }
.dot.low { background: #f59e0b; }
.dot.out { background: #ef4444; }
.btn { margin-top: 6px; width: 100%; padding: 6px; border: 1px solid #d1d5db; background: transparent; color: #3b82f6; font: inherit; font-size: 14px; }
/* dark mode: siyahla beyazı yer değiştir, vurgu renklerini koru */
@media (prefers-color-scheme: dark) {
body { background: #000; color: #fff; }
.card { border-color: #fff; }
.btn { border-color: #374151; color: #1d4ed8; }
.price s { color: #374151; }
}
Bu kartlarda kaç metin WCAG'nin kontrast eşiğinin altında kalır? Stok durumunu, yani kitabın sepete eklenip eklenemeyeceğini, renk görmeyen bir kullanıcı nasıl öğrenir? Dark mode'da vurgu renkleri korunduğuna göre orada sorun azalır mı, artar mı?
Bir önceki yazıda typography'yi token'lara bağladık. Bu yazıda renk paletini kuruyor, contrast oranlarını kendi script'imizle ölçüyor ve dark mode'u açık temanın ters çevrilmiş hâli olarak değil, ayrı ayarlanmış bir tema olarak tasarlıyoruz.
Paletin Katmanları: Marka, Nötr ve Semantic
Before sayfasındaki renkler tek tek seçilmiş: biri Tailwind'in grisinden, biri bir kampanya görselinden, biri "yeşil iyi görünür" diye. Bir palet ise üç gruptan oluşur ve her rengin bir görevi vardır.
Marka rengi ürünü tanıtır ve aksiyonları işaretler; Bookstore'da mavi. Nötr skala arayüzün çoğunu taşır: metin, ikincil metin, zemin, yüzey, kenarlık. Bir ekranın yüzde sekseni nötrdür ve bu yüzden nötr skalanın ton sayısı en az marka rengininki kadar önemli. Semantic renkler ise anlam taşır: success (yeşil), warning (amber), danger (kırmızı) ve info (çoğu zaman marka mavisi). "Stokta var" yeşil, "son 2 kitap" amber, "tükendi" kırmızı.
Her rengin tek bir değeri değil, 50'den 950'ye on bir tonluk bir skalası var. Sebep basit: aynı mavi bir yerde buton zemini, bir yerde link metni, bir yerde seçili satırın açık arka planı, dark mode'da açık bir vurgu oluyor. Bu görevlerin her biri farklı bir açıklık istiyor. Skalanın bir ton adı (blue-600) ile kullanım yeri (color-action) arasındaki ayrım design token katmanlarının konusu; bu serinin design system yazısında ayrıntılı ele alacağız.
OKLCH ile Düzgün Tonlar Üretmek
Bir skala kurmanın en kolay yolu, her ton için aynı açıklık adımlarını kullanmak. Bunun için açıklığı insan gözünün algıladığı gibi ölçen bir renk uzayı gerekiyor. HSL'in (ton, doygunluk ve açıklıkla renk tarifi) lightness'ı bu işi yapmıyor; aynı lightness'taki sarı ile mavinin beyaz metinle kontrastının 1.07 ile 8.59 arasında değiştiğini "CSS3: Flexbox, Grid ve Modern CSS" serisinin modern renk yazısında ölçmüştük. OKLCH (Oklab renk uzayının açıklık, kroma ve ton biçimi) bunun için tasarlanmış. Palet script'i beş renk için aynı on bir açıklık değerini kullanıyor, kromayı ortadaki tonlarda en yüksek tutuyor:
// 11 tonluk skalalar: her renk için aynı açıklık adımları, kroma ortada en yüksek
const steps = { 50: 0.97, 100: 0.93, 200: 0.87, 300: 0.79, 400: 0.7, 500: 0.62, 600: 0.54, 700: 0.47, 800: 0.4, 900: 0.33, 950: 0.25 };
const hues = { blue: [264, 0.2], gray: [264, 0.012], green: [150, 0.16], amber: [75, 0.17], red: [27, 0.2] };
const white = [255, 255, 255];
const css = [];
const ours = [];
for (const [name, [h, peak]] of Object.entries(hues)) {
const row = [];
for (const [step, l] of Object.entries(steps)) {
const c = peak * Math.max(0.15, 1 - Math.abs(l - 0.6) * 1.8);
const { rgb, chroma } = toSrgb(l, c, h);
css.push(` --${name}-${step}: oklch(${l} ${chroma} ${h}); /* ${hex(rgb)} */`);
ours.push([`oklch(${l} ${chroma} ${h})`, rgb]);
row.push(`${step}:${ratio(rgb, white).toFixed(2)}`);
}
console.log(`${name.padEnd(5)} vs white ${row.join(' ')}`);
}
node scripts/04-palette.mjs --check
blue vs white 50:1.09 100:1.23 200:1.48 300:1.94 400:2.72 500:3.77 600:5.21 700:7.00 800:9.41 900:12.46 950:16.12
gray vs white 50:1.09 100:1.22 200:1.48 300:1.92 400:2.68 500:3.64 600:5.05 700:6.81 800:9.16 900:12.24 950:15.96
green vs white 50:1.07 100:1.20 200:1.43 300:1.85 400:2.53 500:3.40 600:4.71 700:6.44 800:8.82 900:11.74 950:15.73
amber vs white 50:1.09 100:1.23 200:1.49 300:1.95 400:2.73 500:3.71 600:5.16 700:6.95 800:9.32 900:12.36 950:16.13
red vs white 50:1.09 100:1.23 200:1.51 300:2.03 400:2.87 500:3.98 600:5.52 700:7.37 800:9.76 900:12.74 950:16.41
Chromium check: 55 colors, largest channel difference 1/255
Tablonun okunuşu: aynı numaralı ton, rengi ne olursa olsun beyaza karşı hemen hemen aynı kontrastı veriyor. 600 tonu beş renkte de 4.71 ile 5.52 arasında, yani beyaz zeminde normal metin için yeterli. 500 tonu 3.40 ile 3.98 arasında; büyük metin ve UI bileşeni için yeterli, normal metin için değil. Bu, tasarım kararını kolaylaştırıyor: "beyaz zemin üzerinde metin rengi en az 600" kuralı her renk için geçerli. HSL'de aynı kuralı yazamazdın, çünkü sarının 600'ü ile mavinin 600'ü birbirine benzemezdi.
Script'teki toSrgb fonksiyonu OKLCH'yi sRGB'ye CSS Color 4'ün kullandığı OKLab matrisleriyle çeviriyor. Bir renk ekranın gösterebileceği sRGB aralığının dışına düşerse açıklığı ve tonu koruyup kromayı düşürüyor; yeşilin 50 tonunda kroma bu yüzden hedeflenenden düşük çıkıyor. --check parametresi aynı 55 değeri Chromium'a da çevirtiyor (bir canvas'a boyayıp piksel okuyarak) ve en büyük fark 255 üzerinden 1. Kendi formülünü bir tarayıcıyla karşılaştırmadan kontrast hesaplarına güvenme.
WCAG 2.2 Eşikleri ve Kataloğu Ölçmek
WCAG 2.2'de metin için iki eşik var: normal metin en az 4.5:1, büyük metin en az 3:1. Büyük metin 18 punto ya da 14 punto kalın; W3C'nin (web standartlarını yayımlayan kuruluş) açıklamasına göre bu yaklaşık 24 piksel ya da 18.66 piksel kalın demek. Metin dışı öğeler için 1.4.11 Non-text Contrast kriteri, bir UI bileşenini ve durumunu tanımak için gereken görsel bilginin (örneğin bir input'un ya da zeminsiz bir butonun kenarlığı) çevresine karşı en az 3:1 olmasını istiyor. Pasif durumdaki bileşenler ve logolar bu kurallardan muaf.
Oran, iki rengin bağıl parlaklığından (luminance) hesaplanıyor. Kendi script'imizdeki formül W3C'nin tanımının birebir karşılığı:
// WCAG 2.2 bağıl parlaklık ve kontrast oranı (sRGB, eşik 0.04045)
export function channel(c8) {
const c = c8 / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
export function luminance([r, g, b]) {
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
export function contrast(a, b) {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
// 4.499'u 4.50 değil 4.49 göstermek için aşağı yuvarlar
export const ratio = (a, b) => Math.floor(contrast(a, b) * 100) / 100;
Son satırdaki aşağı yuvarlama bilerek yapıldı. W3C'nin açıklaması hesaplanan değerin yuvarlanmamasını söylüyor: 4.499:1, 4.5:1 eşiğini geçmez. Birçok araç sonucu iki haneye yukarı yuvarlayarak gösterir ve sınırdaki bir rengi geçmiş gibi gösterebilir. Eşik sabiti 0.04045; W3C, eski metinlerde geçen 0.03928'in 2021'de düzeltildiğini ve pratikte sonucu değiştirmediğini belirtiyor.
Ölçüm script'i her görünür metnin rengini ve arka planını okuyor, eşiğe göre karşılaştırıyor, butonların kenarlığını kartın zeminine karşı ölçüyor ve sonucu axe-core'un color-contrast kuralıyla çapraz kontrol ediyor. oklch() ile yazılmış renkleri byte'a çevirmek için yine canvas kullanıyor:
node scripts/04-contrast.mjs
--- before / light: 8 of 15 texts below WCAG, axe color-contrast nodes: 8
text Frank Herbert #9ca3af on #ffffff 2.53 < 4.5
text Add to cart #3b82f6 on #ffffff 3.67 < 4.5
text Sale #ffffff on #f59e0b 2.14 < 4.5
text Jane Austen #9ca3af on #ffffff 2.53 < 4.5
text ₺249.90 #d1d5db on #ffffff 1.47 < 4.5
text Add to cart #3b82f6 on #ffffff 3.67 < 4.5
text George Eliot #9ca3af on #ffffff 2.53 < 4.5
text Add to cart #3b82f6 on #ffffff 3.67 < 4.5
button border vs card, lowest 1.47 (needs 3)
stock 3 statuses shown only as a colored dot
--- after / light: 0 of 21 texts below WCAG, axe color-contrast nodes: 0
button border vs card, lowest 3.35 (needs 3)
--- before / dark: 5 of 15 texts below WCAG, axe color-contrast nodes: 5
text Add to cart #1d4ed8 on #000000 3.13 < 4.5
text Sale #ffffff on #f59e0b 2.14 < 4.5
text ₺249.90 #374151 on #000000 2.03 < 4.5
text Add to cart #1d4ed8 on #000000 3.13 < 4.5
text Add to cart #1d4ed8 on #000000 3.13 < 4.5
button border vs card, lowest 2.03 (needs 3)
stock 3 statuses shown only as a colored dot
--- after / dark: 0 of 21 texts below WCAG, axe color-contrast nodes: 0
button border vs card, lowest 3.35 (needs 3)
Tahminin ilk cevabı: açık temada 15 metnin 8'i eşiğin altında ve axe da tam olarak 8 node buluyor. En kötüsü üstü çizili eski fiyat, 1.47:1; "eski fiyat önemsiz, soluk olsun" kararı, okunmayan bir fiyat üretmiş. Beyaz "Sale" yazısı amber zeminde 2.14:1. Butonun kenarlığı kartın zeminine karşı 1.47:1; buton, sınırı görünmeyen mavi bir yazıya dönüşüyor.
Bilgiyi Yalnız Renkle Vermemek
İkinci sorunun cevabı: öğrenemez. Before'da stok durumu yalnız yeşil, amber ve kırmızı bir nokta; metin olarak yalnız fareyle üzerine gelince çıkan bir title var. WCAG'nin A seviyesindeki 1.4.1 Use of Color kriteri, rengin bir bilgiyi iletmenin tek görsel yolu olmamasını istiyor. Bu yalnız renk körlüğü olan kullanıcıların sorunu değil: güneş altında telefona bakan, gri tonlu bir e-ink ekran kullanan ya da kırmızı ile yeşilin ne anlama geldiğini bilmeyen herkes aynı noktada kalıyor.
Çözüm üçlü: ikon, metin ve renk birlikte. After'da her stok durumu bir işaret (✓, !, ✕), kısa bir metin ("In stock", "Only 2 left", "Out of stock") ve semantic bir renk çiftiyle geliyor. Renk hâlâ en hızlı ipucu, ama tek ipucu değil. Tükenmiş kitabın butonu da "Add to cart"tan "Notify me"ye döndü; renkle verilen bir yasağın yerini işe yarar bir aksiyon aldı.
Semantic Renkler ve After Sayfası
Component'ler doğrudan --blue-600 gibi bir ton kullanmıyor; ona bir görev adı veren semantic token'ı kullanıyor. Böylece dark mode'da yalnız bu eşleme değişiyor, component stili aynı kalıyor:
/* 04: semantic renkler, açık tema */
:root {
color-scheme: light;
--color-bg: #ffffff;
--color-surface: var(--gray-50);
--color-text: var(--gray-950);
--color-text-muted: var(--gray-600);
--color-border: var(--gray-200); /* süs amaçlı ayırıcılar */
--color-border-strong: var(--gray-500); /* bir kontrolü tanımlayan kenarlıklar */
--color-action: var(--blue-600);
--color-action-hover: var(--blue-700);
--color-on-action: #ffffff;
--color-link: var(--blue-700);
--color-success-text: var(--green-700);
--color-success-bg: var(--green-50);
--color-warning-text: var(--amber-800);
--color-warning-bg: var(--amber-100);
--color-danger-text: var(--red-700);
--color-danger-bg: var(--red-50);
}
İki kenarlık token'ı arasındaki fark 1.4.11'den geliyor. Kartları ayıran ince çizgi süs; tanımlamak için gerekli değil, o yüzden gray-200 olabilir. Zeminsiz butonun kenarlığı ise butonu buton yapan tek görsel bilgi; 3:1'i geçmesi gerekiyor ve gray-500 kartın gray-50 zeminine karşı 3.35:1 veriyor. Kart stili artık yalnız token adlarını biliyor:
.author { color: var(--color-text-muted); }
.price s { color: var(--color-text-muted); }
.badge { position: absolute; top: 16px; left: 16px; padding: 2px 8px; background: var(--color-warning-bg); color: var(--color-warning-text); font-size: 12px; font-weight: 700; }
.stock { display: inline-flex; gap: 4px; padding: 0 6px; font-weight: 600; }
.stock.in { background: var(--color-success-bg); color: var(--color-success-text); }
.stock.low { background: var(--color-warning-bg); color: var(--color-warning-text); }
.stock.out { background: var(--color-danger-bg); color: var(--color-danger-text); }
.btn { margin-top: 6px; width: 100%; padding: 6px; border: 1px solid var(--color-border-strong); background: transparent; color: var(--color-link); font: inherit; font-size: 14px; font-weight: 600; }
"Sale" etiketi beyaz yazı ve parlak amber zemin yerine koyu amber yazı ve açık amber zemin oldu (amber-100 zemin üzerinde amber-800 yazı, 7.53:1). Etiketin dikkat çekme görevi rengin tonundan değil, kapağın üstündeki konumundan ve kalınlığından geliyor.
Dark Mode: Ters Çevirmek Değil, Yeniden Ayarlamak
Üçüncü sorunun cevabı ölçümde: before'un dark mode'unda sorun azalmıyor, yer değiştiriyor. Gri yazar adları siyah zeminde okunur hâle geldi, ama koyu mavi buton yazısı (#1d4ed8) siyah üzerinde 3.13:1'e düştü, eski fiyat 2.03:1 oldu. Açık tema için seçilmiş koyu tonlar koyu zeminde kayboluyor. Saf siyah zeminle saf beyaz metin 21:1 ile en yüksek kontrastı veriyor, ama siyahın altında daha koyu bir ton kalmadığı için kartları ve açılır menüleri zeminden ayırmanın yolu da kalmıyor: siyah zeminde gölge görünmez.
Material Design'ın ikinci sürümünün (M2) dark theme rehberi bu yüzden saf siyah yerine koyu gri (#121212) bir yüzey öneriyor, vurgu renklerinin doygunluğu düşük açık tonlarını (200 tonu) kullanıyor ve yükselen yüzeyleri gölgeye ek olarak daha açık bir tonla gösteriyor. Material 3 aynı fikri sabit bir hex değeri yerine tonal palet üzerinden kuruyor: yüzeyler surface ve beş surface container rolüyle kademeleniyor. Bookstore'un dark temasında aynı mantık:
/* 04: dark tema, ters çevrilmedi, yeniden ayarlandı */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg: var(--gray-950);
--color-surface: var(--gray-900);
--color-text: var(--gray-50);
--color-text-muted: var(--gray-300);
--color-border: var(--gray-800);
--color-border-strong: var(--gray-500);
--color-action: var(--blue-300);
--color-action-hover: var(--blue-200);
--color-on-action: var(--gray-950);
--color-link: var(--blue-300);
--color-success-text: var(--green-300);
--color-success-bg: var(--green-950);
--color-warning-text: var(--amber-200);
--color-warning-bg: var(--amber-950);
--color-danger-text: var(--red-300);
--color-danger-bg: var(--red-950);
}
}
Zemin gray-950, kartlar bir ton açık gray-900: yüzey farkı gölge yerine tonla. Ana metin saf beyaz değil gray-50 (14.64:1). Link ve buton yazısı blue-700'den blue-300'e geçti ve kart zemininde 6.30:1 veriyor. Semantic renklerde açık temadaki "koyu yazı, açık zemin" çifti "açık yazı, koyu zemin" oldu. Bunların hiçbiri otomatik bir ters çevirme değil; her eşleme koyu zemin için ayrı seçildi ve aynı script'le ölçüldü. :root:not([data-theme="light"]) seçicisi kullanıcının sitede açıkça açık temayı seçebilmesi için; tema seçicisinin ve light-dark() fonksiyonunun mekaniği "CSS3: Flexbox, Grid ve Modern CSS" serisinin custom property ve theming yazısında anlatıldı.

APCA ve WCAG 3: Bugün Neye Bakmalı?
WCAG 2'nin kontrast formülü bilinen sınırlara sahip: koyu zeminde açık metin için gerçekte okunması zor bazı çiftleri geçerli sayabiliyor, font boyutu ve kalınlığını yalnız "büyük metin" eşiğiyle hesaba katıyor. APCA (Accessible Perceptual Contrast Algorithm; algısal kontrastı ölçmek için önerilen yeni bir yöntem) bu yüzden bir süre WCAG 3 taslağının adayı olarak konuşuldu.
Bugünkü durum: W3C'nin yayın geçmişine göre WCAG 3.0'ın en son taslağı 10 Eylül 2026 tarihli bir Working Draft; standart değil. Bu taslakta metin kontrastı gereksinimleri "developing" durumunda ve kullanılacak kontrast ölçüsü belirlenmemiş; editör notu algoritmanın henüz kararlaştırılmadığını söylüyor. APCA da taslakta yok; Adrian Roselli'nin Nisan 2026 tarihli değerlendirmesine göre çalışma grubu onu Temmuz 2023 taslağından çıkarmıştı. Pratik sonuç: yasal yükümlülükler ve denetimler bugün WCAG 2.2'ye bakıyor. APCA'yı ek bir bakış olarak kullanmak istersen seçtiğin renklerin WCAG 2.2 eşiklerini de geçtiğinden emin ol.
Üretimde Dikkat
Kontrastı tasarım aşamasında değil, render edilmiş sayfada ölç. Tasarım dosyasındaki renk çifti doğru olabilir; ama yarı saydam bir overlay, bir hover durumu ya da bir görselin üstüne düşen metin gerçek sayfada başka bir kontrast verir. Bu yazıdaki gibi bir script ya da axe-core'u CI'da çalıştırmak, regresyonu yayından önce yakalar. Otomasyonun sınırları ve CI kurulumu "Accessibility (a11y)" serisinin test otomasyonu yazısında ele alınıyor.
Her temayı ayrı ölç. Before'un açık temasındaki sorunlar dark mode'da başka yerlere taşındı; yalnız açık temayı ölçen bir kontrol bunu görmez. Hover, focus ve disabled durumlarını da listeye ekle; bu durumların tasarımı bu serinin interaction design yazısında.
Marka rengini palete uydur, paleti marka rengine değil. Marka mavisi beyaz zeminde 4.5:1 vermiyorsa onu metin rengi yapmak yerine, aynı tondan daha koyu bir adımı metin için, markanın kendisini büyük yüzeyler ve logolar için kullan.
Sık Düşülen Tuzaklar
Birincisi, ikincil bilgiyi "soluk olsun" diye okunmaz yapmak. İkincil metin geri çekilmeli, kaybolmamalı; gray-400 beyazda 2.68:1, gray-600 5.05:1.
İkincisi, beyaz yazıyı her renkli zeminin üstüne koymak. Amber, sarı ve açık yeşil zeminde beyaz metin neredeyse her zaman eşiğin altında kalır.
Üçüncüsü, durumu yalnız renkle göstermek. Stok, hata, başarı ve seçili durumun her biri bir metin ya da ikonla da ifade edilmeli.
Dördüncüsü, dark mode'u ters çevirmek. Açık tema için seçilmiş koyu vurgu tonları koyu zeminde düşer; her semantic eşleme koyu tema için yeniden seçilmeli.
Beşincisi, araçların yuvarladığı oranlara güvenmek. 4.499:1 geçmez; sınırdaki renkleri ham değerle kontrol et.
Küçük Bir Deneme
★☆☆ tokens.css'te --color-text-muted'ı gray-500 yap. Açık temada kaç metnin eşiğin altına düşeceğini tahmin et, sonra 04-contrast.mjs ile ölç.
★★☆ Palete mor bir info rengi ekle (hues'a ton açısı 300 civarında bir satır). Hangi tonun beyaz zeminde metin için ilk geçen ton olacağını tahmin et ve script çıktısıyla karşılaştır.
★★★ 04-contrast.mjs'e buton ve stok etiketlerinin hover durumunu da ölçen bir adım ekle: page.hover() ile her butonun üzerine gel, hesaplanan renkleri oku ve --color-action-hover için bir hover stili yazarak iki temada da 4.5:1'i geçtiğini göster.
Bir sonraki yazıda renkten boşluğa geçiyoruz: 4 ve 8'in katlarından oluşan bir spacing sistemi, "ilişkili öğeler yakın, gruplar uzak" kuralı, 12 kolonluk grid ile içerikten dışa layout arasındaki seçim ve Bookstore kataloğunda kenarlık yerine boşluk ve zemin tonuyla ayrım yapmak.