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

Responsive Mixin'ler ve Utility Class Üretmek: Breakpoint Map'i, @content(args), Container Query Mixin'i ve @each/@for ile Utility'ler

Mert Özen 15 Kas 2026 12 dk 8 görüntülenme
Responsive Mixin'ler ve Utility Class Üretmek: Breakpoint Map'i, @content(args), Container Query Mixin'i ve @each/@for ile Utility'ler

Breakpoint'leri tek bir map'ten okuyan up, down, between ve only mixin'lerini range syntax'la yaz; max-width ile min-width'in tam sınırda çakışması böylece biter ve aynı mixin'ler $container ile container query üretir. Bookstore'a @content(args) ile md: önekli utility'ler üreten bir generator ve kart genişliğine bakan bir buton ekliyor, her şeyi responsive üretmenin kural sayısını 3,6 katına çıkardığını ölçüyoruz.

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) ve Playwright 1.65'in Chromium build'iyle denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) eski katalog stilinde breakpoint'ler yıllardır şu kalıpla yazılmış: "md altı" için max-width, "md ve üstü" için min-width.

// eski yöntem: "md altı" için max-width, "md ve üstü" için min-width
.book-list {
    display: grid;
    grid-template-columns: repeat(2, 1fr);

    @media (min-width: 48rem) {
        grid-template-columns: repeat(3, 1fr);
    }

    @media (max-width: 48rem) {
        grid-template-columns: 1fr;
    }
}

48rem varsayılan yazı boyutunda 768 piksel. Tarayıcı penceresi tam 768px genişliğindeyken katalogda kaç kolon olur? Tasarım dokümanı "md ve üstü üç kolon" diyor.

Serinin üçüncü yazısında tek bir from() mixin'iyle başladık ve map ile @each'ten utility class'ları ürettik. Önceki iki yazıda built-in module'leri gördük. Bu yazıda ikisini birleştirip tam bir API kuruyoruz: map'ten okuyan up, down, between ve only mixin'leri, içeriğe değer geçiren @content(args), aynı mixin'le container query ve responsive variant'lı utility class'lar. Sonunda Bookstore'un Angular istemcisinde (bookstore-web) üretilen CSS'in boyutunu ölçüp gereksiz kombinasyonları keseceğiz.

max-width/min-width Çakışması ve Range Syntax

Üç genişlikte kolon sayısını Chromium'a sordum:

node scripts/10-guess.mjs
767px  columns=1
768px  columns=1
769px  columns=3

Tam 768px'te iki media query de doğru: min-width: 48rem "48rem ve üstü", max-width: 48rem "48rem ve altı" demek. İkisi de uygulanıyor ve kaynakta sonra gelen kazanıyor; "md ve üstü" olması gereken genişlikte tek kolon görünüyor. Kuralların sırası değişse hata bu kez 768px'te değil, tasarımın tam tersi yönünde çıkardı. Bu yüzden eski framework'ler "md altı"nı max-width: 47.98px gibi bir değerle yazıyordu.

Media query range syntax bu sorunu ortadan kaldırıyor: (width < 48rem) ve (width >= 48rem) hiçbir genişlikte çakışmıyor. Syntax'ın kendisi CSS3 serisinin responsive design yazısında anlatıldı; Sass'ın işi bu sorguları elle yazdırmamak.

Map'ten Okuyan Tam API: up, down, between, only

Bookstore'un breakpoint dosyasını baştan yazdım. Bütün mixin'ler aynı map'i okuyor ve sorguyu tek bir private mixin'de kuruyor:

@use "sass:list";
@use "sass:map";
@use "sass:meta";

$breakpoints: (
    "sm": 36rem,
    "md": 48rem,
    "lg": 64rem,
    "xl": 80rem,
) !default;

// map'teki bir breakpoint adı ya da doğrudan verilen bir boyut (container'lar için)
@function -size($name) {
    @if meta.type-of($name) == number {
        @return $name;
    }
    @if not map.has-key($breakpoints, $name) {
        @error "Unknown breakpoint '#{$name}'. Expected one of: #{map.keys($breakpoints)}";
    }
    @return map.get($breakpoints, $name);
}

// bir sonraki breakpoint'in adı; sonuncudan sonra null
@function -next($name) {
    $names: map.keys($breakpoints);
    $i: list.index($names, $name);
    @if $i == list.length($names) {
        @return null;
    }
    @return list.nth($names, $i + 1);
}

// varsayılan olarak media query; $container verilirse o isimli container'a sorgu
@mixin -query($condition, $container) {
    @if $container {
        @container #{$container} #{$condition} {
            @content;
        }
    } @else {
        @media #{$condition} {
            @content;
        }
    }
}

@mixin up($name, $container: null) {
    @include -query("(width >= #{-size($name)})", $container) {
        @content;
    }
}

@mixin down($name, $container: null) {
    @include -query("(width < #{-size($name)})", $container) {
        @content;
    }
}

@mixin between($min, $max, $container: null) {
    @if -size($min) >= -size($max) {
        @error "between(): '#{$min}' must be smaller than '#{$max}'";
    }
    @include -query("(#{-size($min)} <= width < #{-size($max)})", $container) {
        @content;
    }
}

@mixin only($name, $container: null) {
    $next: -next($name);
    @if $next {
        @include between($name, $next, $container) {
            @content;
        }
    } @else {
        @include up($name, $container) {
            @content;
        }
    }
}

Birkaç tasarım kararı var. down() exclusive (<), up() inclusive (>=); aynı breakpoint adıyla çağrıldıklarında aralarında boşluk da çakışma da kalmıyor. only() map'teki sıradan bir sonraki breakpoint'i buluyor; map'in sırası bu yüzden anlamlı ve küçükten büyüğe olmalı. Sonuncu breakpoint için only("xl") üst sınırsız bir up()'a dönüşüyor. @content bir mixin'den diğerine aynen aktarılıyor; up()'ın içeriği iki mixin geçip -query()'nin içinde yazılıyor.

@use "breakpoints" as bp;

.book-list {
    display: grid;
    grid-template-columns: repeat(2, 1fr);

    @include bp.down("md") {
        grid-template-columns: 1fr;
    }

    @include bp.up("md") {
        grid-template-columns: repeat(3, 1fr);
    }

    @include bp.only("lg") {
        gap: 24px;
    }

    @include bp.between("sm", "lg") {
        padding-inline: 16px;
    }

    @include bp.only("xl") {
        grid-template-columns: repeat(5, 1fr);
    }
}
@media (width < 48rem) {
  .book-list {
    grid-template-columns: 1fr;
  }
}
@media (width >= 48rem) {
  .book-list {
    grid-template-columns: repeat(3, 1fr);
  }
}
@media (64rem <= width < 80rem) {
  .book-list {
    gap: 24px;
  }
}
@media (36rem <= width < 64rem) {
  .book-list {
    padding-inline: 16px;
  }
}
@media (width >= 80rem) {
  .book-list {
    grid-template-columns: repeat(5, 1fr);
  }
}

Aynı Playwright script'i bu dosyayla 767px columns=1, 768px columns=3, 769px columns=3 yazdı. Ters sırada verilen bir aralık da derleme anında yakalanıyor:

Error: "between(): 'lg' must be smaller than 'md'"
  ╷
4 │     @include bp.between("lg", "md") {
  │     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  ╵
  10/between-error.scss 4:5  root stylesheet

@content(args) ve using: İçeriğe Değer Geçirmek

Responsive utility'ler üretirken mixin'in yalnız media query'yi kurması yetmiyor; içerideki kod hangi breakpoint'te olduğunu bilmeli ki class adına md: önekini koyabilsin. @content argüman alabiliyor, çağıran taraf da onları using ile karşılıyor:

// içerik bloğunu her breakpoint için bir kez çalıştırır, adını ve class önekini verir
@mixin each-breakpoint {
    @each $name, $size in $breakpoints {
        @include up($name) {
            @content($name, "#{$name}\\:");
        }
    }
}
@use "breakpoints" as bp;

$columns: (2, 4);

@include bp.each-breakpoint using ($name, $prefix) {
    @each $n in $columns {
        .#{$prefix}cols-#{$n} {
            grid-template-columns: repeat($n, minmax(0, 1fr));
        }
    }
}
@media (width >= 36rem) {
  .sm\:cols-2 {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .sm\:cols-4 {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}
@media (width >= 48rem) {
  .md\:cols-2 {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
...

"#{$name}\\:" string'indeki çift ters eğik çizgi, Sass string'i içinde tek bir ters eğik çizgi demek; çıktıda .md\:cols-2 oluyor. : selector'de pseudo-class başlattığı için escape şart; önceki yazıda .w-1\/3 için yaptığımızın aynısı. HTML'de class adı escape'siz: class="md:cols-2". using'deki parametreler mixin parametreleri gibi davranıyor; varsayılan değer de alabiliyorlar.

Bir mixin'in içerik bloğu kabul edip etmediğini sass:meta ile sormak mümkün. Mixin'i bir değer olarak alıp başka bir mixin'e geçiren kütüphanelerde işe yarıyor:

.check {
    up: meta.accepts-content(meta.get-mixin("up", "bp"));
    visually-hidden: meta.accepts-content(meta.get-mixin("visually-hidden"));
}
.check {
  up: true;
  visually-hidden: false;
}

@content içermeyen bir mixin'e blok verirsen derleme duruyor: Error: Mixin doesn't accept a content block. Hata mesajı hem mixin'in tanımını hem çağrıldığı yeri gösteriyor. get-mixin() ve meta.apply() ile mixin seçmeyi kendi kütüphanemizi yazarken kullanacağız.

Container Query'ye Düşen Aynı Mixin

Bookstore'un kitap kartı katalogda, sepette ve kurumsal portalın dar yan panelinde kullanılıyor. "Sepete ekle" butonu dar kartta tam genişlik, geniş kartta içeriği kadar olmalı. Bu karar viewport'a değil kartın kendi genişliğine bağlı; yani container query (ayrıntısı CSS3 serisinin container query yazısında). API'yi değiştirmedim: up(), down(), between() ve only()'nin hepsi $container parametresi alıyor ve verildiğinde aynı koşulu @container ile yazıyor. Container boyutları viewport breakpoint'leriyle aynı olmak zorunda değil, bu yüzden -size() sayı da kabul ediyor:

.book-card__add {
    width: 100%;

    // viewport değil, kartın kendi genişliği karar verir
    @include bp.up(20rem, $container: book-card) {
        width: auto;
    }
}
.book-card__add {
  width: 100%;
}
@container book-card (width >= 20rem) {
  .book-card__add {
    width: auto;
  }
}

Container'ın kendisi component'in host element'inde tanımlanıyor: :host { display: block; container: book-card / inline-size; }. Angular'da :host component'in kendi element'i (app-book-card); custom element'ler varsayılan olarak inline olduğu için display: block gerekiyor.

Utility Class Üretmek ve Boyutu Ölçmek

Üçüncü yazıdaki spacing utility'leri iç içe iki @each ile üretiliyordu ve her yeni utility ailesi yeni bir döngü demekti. Bunun yerine her utility'yi bir map girdisi olarak tanımlayan bir generator yazdım; responsive variant isteyenler responsive: true ile işaretleniyor:

$spacers: (0: 0, 1: 4px, 2: 8px, 3: 16px, 4: 24px, 5: 32px);

$utilities: (
    "m": (property: margin, values: $spacers),
    "mt": (property: margin-block-start, values: $spacers),
    "mb": (property: margin-block-end, values: $spacers),
    "mx": (property: margin-inline, values: $spacers),
    "p": (property: padding, values: $spacers),
    "px": (property: padding-inline, values: $spacers),
    "py": (property: padding-block, values: $spacers),
    "gap": (property: gap, values: $spacers, responsive: true),
    "d": (property: display, values: (none: none, block: block, flex: flex, grid: grid), responsive: true),
    "text": (property: color, values: ("muted": var(--color-muted), "brand": var(--color-brand))),
);
@use "sass:list";
@use "sass:map";
@use "breakpoints" as bp;

// her değer için bir kural; $prefix "" ya da escape edilmiş "md\:"
@mixin -rules($utilities, $prefix, $responsive-only) {
    @each $name, $utility in $utilities {
        @if not $responsive-only or map.get($utility, responsive) {
            @each $key, $value in map.get($utility, values) {
                .#{$prefix}#{$name}-#{$key} {
                    #{map.get($utility, property)}: $value;
                }
            }
        }
    }
}

// her utility'nin temel class'ları; responsive variant'lar yalnız işaretli
// utility'ler için ve yalnız $variants'taki breakpoint'lerde
@mixin generate($utilities, $variants) {
    @include -rules($utilities, "", false);
    @include bp.each-breakpoint using ($name, $prefix) {
        @if list.index($variants, $name) {
            @include -rules($utilities, $prefix, true);
        }
    }
}

Property adı da interpolation ile üretiliyor (#{map.get($utility, property)}: $value). Yeni bir utility ailesi artık map'e bir satır. Asıl soru ne kadarını üreteceğin. Aynı veriden üç çıktı ürettim ve compressed boyutlarını ölçtüm: yalnız gap ve display için md ve lg variant'ları (Bookstore'un ihtiyacı), her utility için dört breakpoint'in hepsi ve Tailwind'inkine benzer 35 adımlı bir spacing ölçeğinin 14 property ile dört breakpoint'te üretilmesi:

node scripts/10-sizes.mjs
utilities-trimmed       74 rules   1712 bytes, gzip 477 bytes
utilities-everything   270 rules   7110 bytes, gzip 1162 bytes
utilities-large-scale 2450 rules  72231 bytes, gzip 10240 bytes

"Her şey responsive" kararı aynı veriden 3,6 kat fazla kural üretiyor. Büyük ölçekli sürüm 72 KB; buna bir renk paleti ve hover: gibi durum variant'ları eklendiğinde boyut yüzlerce KB'a çıkıyor. Bu CSS'in her byte'ı render-blocking: global stil dosyası indirilip parse edilmeden sayfa çizilmiyor. Hangi class'ların gerçekten kullanıldığını ise Sass bilemiyor; o bilgi template'lerde.

Bookstore'da: Breakpoint Seti ve Küçük Bir Utility Katmanı

bookstore-web'de abstracts/_breakpoints.scss yukarıdaki API'yle değişti; generator mixin'i abstracts/_utilities.scss'e generate-utilities() adıyla girdi. İkisi de yalnız mixin ve function tanımlıyor; abstracts hâlâ CSS üretmiyor. Component'lerdeki from() çağrıları up() oldu; from() ise eski kod için up()'a yönlendiren bir mixin olarak kaldı. Utility verisi altıncı yazıda kurduğumuz utilities klasöründe ve değerlerini önceki yazıdaki space() ölçeğinden alıyor:

// spacing utility'leri değerlerini space() ölçeğinden alır: .m-0 … .m-5
$spacers: ();
@for $step from 0 through list.length($space-scale) - 1 {
    $spacers: map.set($spacers, $step, space($step));
}

$utilities: (
    "m": (property: margin, values: $spacers),
    "mt": (property: margin-block-start, values: $spacers),
    "mb": (property: margin-block-end, values: $spacers),
    "mx": (property: margin-inline, values: $spacers),
    "my": (property: margin-block, values: $spacers),
    "gap": (property: gap, values: $spacers, responsive: true),
    "d": (property: display, values: (none: none, block: block, flex: flex, grid: grid), responsive: true),
    "text": (property: color, values: ("muted": var(--color-muted), "brand": var(--color-brand))),
);

// responsive variant'lar (.md\:d-none) yalnız display ve gap için, yalnız md ve lg'de
@include generate-utilities($utilities, ("md", "lg"));

Bu dosya giriş dosyasında @layer utilities içinde yükleniyor, yani üretilen bütün class'lar altıncı yazıdaki en güçlü katmanda. Responsive variant'lar aynı katmanda temel class'lardan sonra geldiği için class="d-none md:d-block" beklendiği gibi çalışıyor: aynı specificity'de kaynak sırası kazanıyor. Header'a bu iki class'la yalnız geniş ekranda görünen bir alt başlık, kitap kartına container query'li butonu ekledim ve üç genişlikte ölçtüm:

node scripts/10-web-responsive.mjs
viewport  400px  card 352px  button 122px  tagline none
viewport  700px  card 318px  button 282px  tagline none
viewport 1280px  card 288px  button 252px  tagline block

Container query'nin farkı ilk iki satırda: 400px'lik telefonda kart tek kolonda 352px genişliğinde, 20rem'den büyük ve buton içeriği kadar. 700px'lik tablette iki kolon var, kart 318px'e daralıyor ve buton tam genişliğe dönüyor. Viewport'a bakan bir media query bu iki durumu ayıramazdı. Global stil dosyası 11,71 kB'tan 12,56 kB'a çıktı; utility'ler 25 kuraldan 62 kurala geçti. npm run check:sass2 hâlâ temiz.

Üretimde Dikkat

Utility katmanının boyutunu CI'da izle. Map'e bir değer ya da $variants'a bir breakpoint eklemek, kimsenin fark etmeden onlarca kural eklemesi demek. Yukarıdaki 10-sizes.mjs gibi bir script'in çıktısını bir eşikle karşılaştırmak ya da Angular'ın budgets ayarına global stil dosyası için bir sınır koymak bunu görünür yapıyor.

Breakpoint map'ini tek yerde tut. Container query boyutları dışında hiçbir component media query'yi elle yazmamalı; aksi hâlde map'teki bir değişiklik bazı kurallara yansıyor, bazılarına yansımıyor. grep -rn "@media" src/app boş dönmeli.

Range syntax tarayıcı desteğine bak. Bookstore'un hedef tarayıcıları range syntax'ı destekliyor; çok eski bir tarayıcıyı desteklemen gerekiyorsa değişiklik tek bir yerde, -query() mixin'inde yapılır. API'nin geri kalanı aynı kalır.

Sık Düşülen Tuzaklar

Her breakpoint için her değeri üretmek. Yukarıdaki ölçümde bu karar kural sayısını 3,6 katına çıkardı; büyük ölçekli bir sistemde 72 KB'lık, büyük kısmı kullanılmayan bir CSS demek. Önce temel class'ları üret, responsive variant'ları ihtiyaç doğdukça tek tek aç.

Sass ile Tailwind'i yeniden yazmaya çalışmak. Utility generator'ı büyüdükçe hover:, focus:, renk opaklığı ve arbitrary value istekleri geliyor. Tailwind bunların hepsini template'leri tarayıp yalnız kullanılan class'ları üreterek çözüyor; Sass ise template'leri görmüyor, her şeyi önceden üretmek zorunda. Projede utility'ler stilin çoğunu taşıyorsa doğru araç Tailwind (Tailwind CSS serisinde); Sass'ta yazılan utility katmanı, Bookstore'daki gibi semantik class'ların yanında küçük bir yardımcı olarak kalmalı.

max-width ile min-width'i aynı değerle karıştırmak. Yazının başındaki deney tam sınırda iki kuralın birden uygulandığını gösterdi. down()'ı range syntax'la ve exclusive yazmak bu sınıf hatayı tamamen kaldırıyor.

Escape'siz önek. @content($name, "#{$name}:") yazarsan Sass hata vermiyor ve çıktıda .md:d-block oluyor. Tarayıcı bunu md:d-block class'ı değil, .md class'ı ve :d-block adlı bilinmeyen bir pseudo-class olarak okuyor; geçersiz selector yüzünden kuralı atıyor. 10-no-escape.mjs Chromium'da media query'nin içinde kalan kural sayısını 0 olarak yazdı.

Küçük Bir Deneme

★☆☆ 10/api.scss'te bp.only("md") ile tablet genişliğinde katalogda iki kolon göster. Çıktıdaki media query'yi oku ve 10-guess.mjs'e 1023, 1024 ve 1025 piksellik genişlikleri ekleyerek sınırları doğrula.

★★☆ Generator'a bir $states parametresi ekle: ("hover") verildiğinde işaretli utility'ler için .hover\:text-brand:hover kuralları üretsin. Bookstore'un text utility'sini işaretle ve 10-sizes.mjs ile kural sayısının ne kadar arttığını ölç.

★★★ bookstore-web'de src/app altındaki bütün .html template'lerini tarayıp kullanılan utility class'larının listesini çıkaran bir Node script'i yaz. Listeyi bir _used.scss dosyasına Sass list'i olarak yazsın ve generator yalnız bu listedeki class'ları üretsin. Global stil dosyasının boyutunu önce ve sonra ölç; bu yaklaşımın hangi noktada Tailwind'in yaptığı işi yeniden yazmaya dönüştüğünü tartış.

Bir sonraki yazıda bu yazılarda topladığımız function ve mixin'leri bir kütüphaneye çeviriyoruz: @debug, @warn ve @error ile argüman doğrulama, sass:meta ve sass:selector, @bookstore/styles adlı küçük bir iç paket ve True ile Sass testleri.