Mert Özen Her Satırda Daha İleri
Frontend Sass/SCSS — #05

Sass ile Theming: Map'ler, CSS Custom Property Üretmek, Dark Mode ve Angular Material M3 Teması

Mert Özen 14 Kas 2026 11 dk 10 görüntülenme
Sass ile Theming: Map'ler, CSS Custom Property Üretmek, Dark Mode ve Angular Material M3 Teması

Tema değerlerini Sass map'inde tutup doğrula ve light-dark() ile custom property'ye çevir; runtime'da tema değiştirmek yalnız color-scheme'ı ya da html'deki bir class'ı değiştirmek demek, Sass değişkeni runtime'da değişmez. Bookstore'a eksik token'da build'i kıran bir mixin, üç durumlu tema butonu ve marka renginden üretilmiş bir Angular Material M3 teması ekliyor, iki temada sekiz durumu ölçüyor ve tema başına ayrı bundle'ın neden değmediğini gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Dart Sass 1.108.2, Angular 22.2 (TypeScript 6.0; Angular CLI'ın içindeki Sass 1.104.1), Angular Material 22.2.7 ve Playwright 1.65'in Chromium build'iyle denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'a (bir online kitapçı) dark mode eklenecek. Sass'ı yeni öğrenen bir ekip arkadaşı en doğal görünen yolu denedi: koyu temada marka rengini değiştirmek için .dark class'ının içinde değişkene yeni bir değer verdi.

$brand: #b45309;

.dark {
    // global değişken değil, yeni bir local değişken
    $brand: #f59e0b;
}

.book-card__title {
    color: $brand;
}

<html class="dark"> olduğunda kitap başlığı hangi renkte görünür? Derlenmiş CSS'te .dark diye bir kural var mı?

npx sass 05/runtime-myth.scss
.book-card__title {
  color: #b45309;
}

Başlık her zaman kahverengi ve .dark çıktıda hiç yok. .dark bloğundaki $brand yalnız o bloğun içinde yaşayan yeni bir değişken; blok hiçbir declaration üretmediği için Sass onu atıyor. Daha temel sebep ilk yazıdan tanıdık: derleme bittiğinde ortada değişken kalmıyor ve tarayıcının hangi class'ın aktif olduğunu Sass'a sorma şansı yok. Runtime'da değişen her şey custom property olmak zorunda.

Bu yazıda işi ikiye bölüyoruz: tema değerlerini tutmak, doğrulamak ve custom property'ye çevirmek Sass'ın işi; hangi temanın aktif olduğuna karar vermek CSS'in. Sonra aynı yapıyı Bookstore'un Angular istemcisinde (bookstore-web) Angular Material'ın M3 (Material Design 3; Google'ın tasarım sisteminin üçüncü sürümü) temasıyla birleştiriyoruz.

Token Map'inden Custom Property Üretmek

Her temanın değerlerini bir map'te tutuyorum. İki map'in aynı key'lere sahip olması gerekiyor; bunu birazdan derleyiciye kontrol ettireceğiz.

$themes: (
    light: (
        surface: #ffffff,
        text: #1c1917,
        muted: #57534e,
        border: #d6d3d1,
        brand: #b45309,
    ),
    dark: (
        surface: #1c1917,
        text: #f5f5f4,
        muted: #a8a29e,
        border: #44403c,
        brand: #f59e0b,
    ),
);

Bu map'ten CSS üretmenin iki yolu var. Birincisi yıllardır kullanılan yöntem: her tema için ayrı bir blok.

@use "sass:map";
@use "themes" as *;

@mixin theme-vars($theme) {
    @each $name, $value in $theme {
        --color-#{$name}: #{$value};
    }
}

:root,
.light {
    @include theme-vars(map.get($themes, light));
}

// işletim sistemi koyu tema istiyor ve kullanıcı açık temayı sabitlemedi
@media (prefers-color-scheme: dark) {
    :root:not(.light) {
        @include theme-vars(map.get($themes, dark));
    }
}

.dark {
    @include theme-vars(map.get($themes, dark));
}
:root,
.light {
  --color-surface: #ffffff;
  --color-text: #1c1917;
  --color-muted: #57534e;
  --color-border: #d6d3d1;
  --color-brand: #b45309;
}

@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    --color-surface: #1c1917;
    --color-text: #f5f5f4;
    --color-muted: #a8a29e;
    --color-border: #44403c;
    --color-brand: #f59e0b;
  }
}
.dark {
  --color-surface: #1c1917;
  --color-text: #f5f5f4;
  --color-muted: #a8a29e;
  --color-border: #44403c;
  --color-brand: #f59e0b;
}

Çalışıyor ama koyu tema değerleri iki kez yazılıyor: bir kez işletim sistemi tercihi için, bir kez kullanıcının seçimi için. İkinci yol CSS'in light-dark() fonksiyonunu kullanıyor. Bu fonksiyon iki değer alıyor ve element'in color-scheme'ına göre birini seçiyor; temayı değiştirmek için yalnız color-scheme'ı değiştirmek yetiyor. Ayrıntısı CSS3 serisinin custom property ve theming yazısında. Sass'ın işi iki map'i birleştirip her token için tek bir declaration üretmek:

@use "sass:map";
@use "themes" as *;

@mixin color-tokens($themes) {
    $light: map.get($themes, light);
    $dark: map.get($themes, dark);

    @each $name, $value in $light {
        @if not map.has-key($dark, $name) {
            @error "Dark theme is missing '#{$name}'";
        }
        --color-#{$name}: light-dark(#{$value}, #{map.get($dark, $name)});
    }
}

:root {
    color-scheme: light dark;
    @include color-tokens($themes);
}

.light {
    color-scheme: light;
}

.dark {
    color-scheme: dark;
}
:root {
  color-scheme: light dark;
  --color-surface: light-dark(#ffffff, #1c1917);
  --color-text: light-dark(#1c1917, #f5f5f4);
  --color-muted: light-dark(#57534e, #a8a29e);
  --color-border: light-dark(#d6d3d1, #44403c);
  --color-brand: light-dark(#b45309, #f59e0b);
}

.light {
  color-scheme: light;
}

.dark {
  color-scheme: dark;
}

Compressed çıktıda ilk yöntem 425, ikincisi 303 byte. Fark beş token için küçük ama token sayısıyla doğru orantılı büyüyor. Daha önemlisi okunabilirlik: her token tek satırda, açık ve koyu değeri yan yana. Bir de color-scheme'ın kendisi tarayıcıya form element'lerini, scrollbar'ları ve varsayılan arka planı da temaya uydurmasını söylüyor; blok yönteminde bunu ayrıca yazman gerekiyordu.

@error satırı bu mixin'in asıl değeri. Biri açık temaya yeni bir token ekleyip koyu temayı unutursa build kırılıyor. Koyu temadan brand'ı silip denedim:

Error: "Dark theme is missing 'brand'"
   ╷
18 │     @include color-tokens($themes);
   │     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   ╵
  05/tokens-light-dark.scss 18:5  root stylesheet

Custom property'lerle tek başına çalışsaydık bu eksik token tarayıcıda boş bir değer olur, koyu temada bir başlık sessizce açık temanın rengiyle kalırdı. light-dark() Mayıs 2024'ten beri Baseline; daha eski tarayıcıları desteklemen gerekiyorsa blok yöntemine dönmek, mixin'i değiştirmek kadar kolay.

Bookstore'da: Tema Token'ları abstracts'ta

bookstore-web'de $themes map'i src/styles/abstracts/_tokens.scss'e, color-tokens() mixin'i _theme.scss'e girdi. Mixin, önceki yazının kuralına uygun: abstracts hâlâ CSS üretmiyor, CSS'i mixin'i çağıran dosya üretiyor. Bookstore'un mixin'i yukarıdakinden bir adım fazlasını yapıyor ve ters yönü de kontrol ediyor: koyu temada olup açıkta olmayan bir key de hata.

@use "sass:map";

// --color-* custom property'leri; light-dark() değeri runtime'da color-scheme'a göre seçer
@mixin color-tokens($themes) {
    $light: map.get($themes, light);
    $dark: map.get($themes, dark);

    @each $name in map.keys($dark) {
        @if not map.has-key($light, $name) {
            @error "Light theme is missing '#{$name}'";
        }
    }

    @each $name, $value in $light {
        @if not map.has-key($dark, $name) {
            @error "Dark theme is missing '#{$name}'";
        }
        --color-#{$name}: light-dark(#{$value}, #{map.get($dark, $name)});
    }
}

Component'lerin stil dosyalarında hiçbir şey değişmedi. BookCard zaten var(--color-brand) ve var(--color-border) kullanıyordu; ikinci yazıda koyduğumuz "component'ler yalnız custom property kullanır" kuralının karşılığı burada alındı.

Runtime'da Tema Değiştirmek

Kullanıcının seçimi <html>'e eklenen tek bir class. Header'a üç durumlu bir buton koydum: sistem, açık, koyu.

import { Component, DOCUMENT, effect, inject, signal } from '@angular/core';
import { MatButton } from '@angular/material/button';

type Theme = 'system' | 'light' | 'dark';
const NEXT: Record<Theme, Theme> = { system: 'light', light: 'dark', dark: 'system' };

@Component({
  selector: 'app-theme-toggle',
  imports: [MatButton],
  template: `<button matButton="outlined" (click)="toggle()">Theme: {{ theme() }}</button>`,
})
export class ThemeToggle {
  private readonly root = inject(DOCUMENT).documentElement;
  protected readonly theme = signal<Theme>('system');

  constructor() {
    // runtime'da yalnız bir class değişiyor; bütün renkler zaten CSS'te
    effect(() => {
      this.root.classList.remove('light', 'dark');
      const theme = this.theme();
      if (theme !== 'system') {
        this.root.classList.add(theme);
      }
    });
  }

  protected toggle(): void {
    this.theme.update((current) => NEXT[current]);
  }
}

Bu component'te hiçbir renk yok; Sass değişkeni de yok. JavaScript'in tek görevi class'ı değiştirmek. Playwright script'i sayfayı önce işletim sistemi açık temadayken, sonra koyu temadayken açıyor, her durumda butona üç kez basıp gövde arka planını, kitap başlığının rengini ve Material butonunun arka planını okuyor:

node scripts/05-theme.mjs
OS light               class=-     body=rgb(255, 255, 255) title=rgb(180, 83, 9)    button=rgb(155, 69, 0)
OS light, theme light  class=light body=rgb(255, 255, 255) title=rgb(180, 83, 9)    button=rgb(155, 69, 0)
OS light, theme dark   class=dark  body=rgb(28, 25, 23)    title=rgb(245, 158, 11)  button=rgb(255, 182, 142)
OS light, theme system class=-     body=rgb(255, 255, 255) title=rgb(180, 83, 9)    button=rgb(155, 69, 0)
OS dark                class=-     body=rgb(28, 25, 23)    title=rgb(245, 158, 11)  button=rgb(255, 182, 142)
OS dark, theme light   class=light body=rgb(255, 255, 255) title=rgb(180, 83, 9)    button=rgb(155, 69, 0)
OS dark, theme dark    class=dark  body=rgb(28, 25, 23)    title=rgb(245, 158, 11)  button=rgb(255, 182, 142)
OS dark, theme system  class=-     body=rgb(28, 25, 23)    title=rgb(245, 158, 11)  button=rgb(255, 182, 142)

Sekiz satırın hepsi tutarlı: class yokken işletim sisteminin tercihi, class varken kullanıcının seçimi geçerli. Bizim token'larımız (başlık) ve Angular Material'ın token'ları (buton) aynı anda değişiyor, çünkü ikisi de aynı color-scheme'a bakıyor. Bunun nasıl olduğuna şimdi bakalım.

Angular Material M3 Teması

Angular Material'ı kurup marka renginden bir palet ürettim. theme-color schematic'i verilen renkten M3'ün tonal paletlerini (aynı rengin 0'dan 100'e kadar açıklık tonları) hesaplayıp bir Sass dosyasına yazıyor:

npm install --save-exact @angular/material@22.2.7 @angular/cdk@22.2.7
npx ng generate @angular/material:theme-color --primary-color="#b45309" --tertiary-color="#1d4ed8" --directory=src/styles/themes/
CREATE src/styles/themes/_theme-colors.scss (2535 bytes)

Üretilen dosyanın başı ve sonu:

// This file was generated by running 'ng generate @angular/material:theme-color'.
// Proceed with caution if making changes to this file.

@use 'sass:map';
@use '@angular/material' as mat;

// Note: Color palettes are generated from primary: #b45309, tertiary: #1d4ed8
$_palettes: (
    primary: (
        0: #000000,
        10: #331200,
        20: #532200,
        ...
        40: #9b4500,
        ...
        80: #ffb68e,
        ...
    ),
    ...
);
...
$primary-palette: map.merge(map.get($_palettes, primary), $_rest);
$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest);

$_palettes alt çizgiyle başladığı için private; dosya dışarıya yalnız $primary-palette ve $tertiary-palette'i açıyor. Bir önceki yazının module sistemi kuralları üretilmiş kodda da aynen geçerli. Global styles.scss'in son hâli:

@use "@angular/material" as mat;
@use "abstracts" as *;
@use "themes/theme-colors" as brand;
@use "utilities";

html {
    // varsayılan olarak işletim sistemini izler; .light / .dark sabitler
    color-scheme: light dark;

    @include mat.theme((
        color: (
            primary: brand.$primary-palette,
            tertiary: brand.$tertiary-palette,
        ),
        typography: system-ui,
        density: 0,
    ));
    @include color-tokens($themes);

    // Material butonları hap şekli yerine Bookstore'un köşe yuvarlaklığını kullanır
    @include mat.button-overrides((
        filled-container-shape: $radius,
        outlined-container-shape: $radius,
    ));

    --radius: #{$radius};
    --space: #{$space};

    &.light {
        color-scheme: light;
    }

    &.dark {
        color-scheme: dark;
    }
}

body {
    margin: 0;
    font-family: system-ui, sans-serif;
    background-color: var(--color-surface);
    color: var(--color-text);
}

mat.theme() bir mixin; çağrıldığı selector'ün içine 157 tane --mat-sys-* custom property yazıyor ve renkleri varsayılan olarak light-dark() ile üretiyor. Derlenmiş CSS'ten bir satır:

--mat-sys-primary: light-dark(#9b4500, #ffb68e);

Yukarıdaki ölçümde Material butonunun açık temada rgb(155, 69, 0), koyu temada rgb(255, 182, 142) olmasının sebebi bu: açık temada primary rengi paletin 40 tonu, koyu temada 80 tonu. Dikkat edilecek nokta, ikisinin de bizim verdiğimiz #b45309 olmaması. M3 kaynak rengi bir başlangıç noktası olarak kullanıyor ve kontrast kurallarına uyan tonları paletten seçiyor. Marka renginin tam değeri Bookstore'un kendi token'larında (--color-brand) duruyor; Material component'leri ise kendi tonlarını kullanıyor. Bu ikisinin yan yana nasıl göründüğüne tasarımcıyla birlikte bakmak gerekiyor.

mat.button-overrides() Material'ın token tabanlı özelleştirme API'si. Her component'in kendi *-overrides mixin'i var ve verilen token adlarını derleme anında doğruluyor; filled-container-shap gibi yanlış yazılmış bir ad build'i Invalid token name hatası ve geçerli token'ların listesiyle kırıyor. Butonlar artık Bookstore'un 8px köşe yuvarlaklığını kullanıyor:

node scripts/web-probe.mjs ".book-card__add" border-top-left-radius
Add to cart              border-top-left-radius=8px

Angular Material dokümanı component'lerin iç class'larını ve DOM (sayfanın element ağacı) yapısını private sayıyor ve onları CSS ile ezmeyi açıkça önermiyor; desteklenen yol bu overrides mixin'leri. Kendi component'lerinde Material'ın renklerini kullanmak istersen var(--mat-sys-primary) gibi system token'larını doğrudan kullanabilirsin; onlar da aynı color-scheme'a bakıyor.

Üretimde Dikkat

Kullanıcının seçimini sakla ve Angular açılmadan uygula. Bu yazıdaki ThemeToggle seçimi bellekte tutuyor; sayfa yenilenince sistem temasına dönüyor. Gerçek uygulamada seçimi localStorage'a yazıp index.html'deki küçük bir inline script ile <html>'e class'ı Angular yüklenmeden koymak gerekiyor; yoksa koyu tema seçmiş bir kullanıcı her açılışta bir an açık temayı görüyor. Bu FOUC (Flash of Unstyled Content; sayfanın bir an yanlış ya da stilsiz görünmesi) sorununun ayrıntısı CSS3 serisinin theming yazısında.

Bundle'ı ölç. Angular Material'ı eklemek Bookstore'un ilk JavaScript bundle'ını 127 kB'tan 194 kB'a çıkardı; global CSS 1 kB'tan 9 kB'a çıktı ve bunun çoğu 157 system token'ı. Bu bir uyarı değil, bilinçli bir karar: kullandığın her Material component'i bundle'a bir maliyet ekliyor.

Üretilen tonların kontrastını kontrol et. Schematic tonları M3'ün kurallarına göre seçiyor ama Bookstore'un kendi token'ları (--color-brand gibi) bu kurallardan geçmiyor. İki temada da metin ve arka plan çiftlerini Accessibility (a11y) serisindeki araçlarla ölçmek, yayından önceki son adım olmalı.

Sık Düşülen Tuzaklar

Tema başına ayrı CSS bundle'ı üretmek. Material'da theme-type: light ve theme-type: dark ile iki ayrı stil dosyası üretip runtime'da <link>'i değiştirmek eski bir yöntem. Aynı temayı üç şekilde derleyip boyutlarını karşılaştırdım:

node scripts/05-material-sizes.mjs
material-one     8038 bytes, gzip 1476 bytes, 157 --mat-sys-* properties
material-light   6985 bytes, gzip 1285 bytes, 157 --mat-sys-* properties
material-dark    6985 bytes, gzip 1297 bytes, 157 --mat-sys-* properties

Tek dosyalık light-dark() sürümü, tek bir temadan gzip sonrası yaklaşık 190 byte büyük. Bu 190 byte'lık kazanç için iki ayrı dosya üretmek, tema değişiminde yeni bir dosya indirmek ve indirme sürerken sayfanın stilsiz kalma riskini almak gerekiyor. Tema sayısı arttıkça (yüksek kontrast, kurumsal müşteri teması) build çıktısı da katlanıyor. Tek dosya ve color-scheme çok daha basit.

Sass değişkenini runtime'da değiştirmeye çalışmak. Yazının başındaki örnek bunun en sık görülen şekli. Bir başka şekli, Sass değişkenini JavaScript'ten değiştirmeye çalışmak: $brand derlenmiş CSS'te yok, değiştirilecek bir şey de yok. Kural basit: Sass değeri üretir, custom property taşır, class ya da color-scheme seçer.

Material'ın iç class'larını ezmek. .mdc-button__label gibi class'lara yazılan kurallar bir sonraki Material sürümünde sessizce çalışmayı bırakabiliyor. overrides mixin'leri ise token adı değişirse derleme hatası veriyor; hatayı production'da değil build'de görüyorsun.

theme-color schematic'ine klasörü sonunda / olmadan vermek. --directory=src/styles/themes yazdığımda dosya src/styles/themes_theme-colors.scss adıyla oluştu; schematic klasör adıyla dosya adını doğrudan birleştiriyor. Sonuna / koyunca doğru yere yazdı.

Küçük Bir Deneme

★☆☆ 05/_themes.scss'e yalnız koyu temaya accent: #38bdf8 ekle ve tokens-light-dark.scss'i derle. Lab'deki mixin bunu yakalıyor mu? bookstore-web'deki mixin'le karşılaştır.

★★☆ ThemeToggle'ın seçimini localStorage'a yaz ve index.html'e class'ı Angular yüklenmeden koyan inline bir script ekle. Playwright ile koyu temayı seçip sayfayı yenile ve ilk boyamada gövde arka planının koyu olduğunu doğrula.

★★★ $themes'e üçüncü bir tema ekle: yüksek kontrast. color-tokens() artık light-dark() ile yetinemez; mixin'i yüksek kontrast değerlerini @media (prefers-contrast: more) ve .high-contrast class'ı altında üretecek şekilde genişlet, Material için theme-color schematic'inin --include-high-contrast seçeneğini dene ve iki temanın birlikte nasıl çalıştığını ölç.

Bir sonraki yazıda tek tek dosyalardan stil ağacının bütününe geçiyoruz: 7-1 klasör yapısı, BEM (Block, Element, Modifier; class isimlendirme yöntemi) ve ITCSS'in (Inverted Triangle CSS; stilleri genelden özele katmanlara ayıran yöntem) katman fikri, bu katmanların @layer ile tarayıcıda gerçek hâle gelmesi ve Bookstore'da global, component ve utility stillerinin sınırı.