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

Responsive Design: Mobile-first, Media Query Range Syntax, clamp() ile Fluid Typography ve User Preference'lar

Mert Özen 14 Kas 2026 11 dk 1 görüntülenme
Responsive Design: Mobile-first, Media Query Range Syntax, clamp() ile Fluid Typography ve User Preference'lar

Responsive bir sayfa mobile-first yazılır, breakpoint'ler içeriğe göre rem ile seçilir ve font boyutu clamp() içinde rem ile vw toplamıyla verilir; yalnız vw kullanan başlık zoom'da hiç büyümez. Range syntax'ı, media query'deki rem'in neden sayfanın kök font boyutunu dinlemediğini, zoom ölçümünü, prefers-reduced-motion, prefers-contrast, prefers-color-scheme ve hover/pointer'ı Bookstore üzerinde Chromium ve WebKit'te ölçüyoruz.

Sürüm notu: Bu yazı Media Queries Level 4/5 ve CSS Values 4 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 çalışan bir tasarımcı, metinler biraz daha büyük görünsün diye kök font boyutunu değiştirdi: html { font-size: 20px; }. Artık sayfadaki her 1rem 20px. Katalog sayfasının iskeleti ise şu media query ile iki kolona geçiyor: @media (width >= 48rem). Sayfa 800px genişliğinde bir tablette açıldı.

48rem burada kaç piksel? 48 × 20 = 960 ise 800px'lik ekranda iskelet tek kolonda kalır; 48 × 16 = 768 ise iki kolona geçer. Hangisi doğru ve neden?

Serinin şimdiye kadarki yazılarında layout'u kurduk. Bu yazıda o layout'u farklı ekranlara ve farklı kullanıcılara uyduruyoruz: mobile-first yazım, media query'lerin range syntax'ı, clamp() ile ekranla büyüyen ama zoom'u bozmayan font boyutu, kullanıcının işletim sisteminde seçtiği tercihler ve fare ile parmağı ayıran media feature'lar.

Mobile-first ve İçeriğe Göre Breakpoint

Bookstore'un sayfa iskeletini serinin Grid yazısında zaten mobile-first yazmıştık: media query'siz kurallar en dar ekranı, yani tek kolonlu telefonu tarif ediyor; @media (width >= 48rem) bloğu yalnız genişleyen ekranlar için iki kolonu ekliyor. Tersini yapmak, önce masaüstünü yazıp max-width query'leriyle küçültmek de çalışır, ama iki bedeli var. Telefondaki tarayıcı önce masaüstü kurallarını uygular, sonra onları geri alır; geri alınması unutulan her kural telefonda bir hata olarak ortaya çıkar. Ayrıca basit hal (tek kolon) karmaşık halin istisnası gibi yazılır. Mobile-first'te her media query bir şey ekler, hiçbir şeyi geri almaz.

Breakpoint'i bir cihazın genişliğine göre değil, içeriğin bozulduğu yere göre seç. 48rem'i "tablet genişliği" olduğu için değil, şu hesapla seçtim: filtre kolonu 14rem, yanında en az iki tane 14rem'lik kart, aralarında 1rem boşluk ve sonuç alanının iki yanında 1rem padding. Toplam 45rem; 48rem biraz pay bırakıyor. Daha dar ekranda iki kolonlu iskelet sonuç alanına yalnız tek kart sığdırabilir ve filtreler gereksiz yer kaplar. Cihaz listeleri her yıl değişir; içeriğin ihtiyacı değişmez.

Media Query Range Syntax

(width >= 48rem) yazımı, eski (min-width: 48rem)'in Media Queries Level 4 ile gelen karşılığı. İki yazım aynı anlamda, ama yeni olan iki konuda daha net. Birincisi, sınır değerin dahil olup olmadığı okununca anlaşılıyor: min-width: 48rem 48rem'i de kapsıyor ve bunu ezberlemen gerekiyordu; >= bunu açıkça söylüyor. İkincisi, aralık tek ifadeyle yazılabiliyor. Lab sayfasında iki kuralı birlikte denedim:

html { font-size: 20px; }   /* sayfa kendi rem'ini değiştiriyor... */
.layout::after { content: "one column"; }
@media (width >= 48rem) {   /* ...ama buradaki 48rem hâlâ 48 × 16px */
    .layout::after { content: "two columns"; }
}
@media (48rem <= width < 64rem) {
    .layout::before { content: "tablet: "; }
}
node scripts/08-responsive.mjs
--- chromium
760px: root font-size 20px, layout "one column"
800px: root font-size 20px, layout "tablet: two columns"
1100px: root font-size 20px, layout "two columns"
 360px zoom 100%: on screen vw-only 14.4px, fluid 28.0px, body 16.0px
 768px zoom 100%: on screen vw-only 30.7px, fluid 34.6px, body 16.0px
1280px zoom 100%: on screen vw-only 51.2px, fluid 40.0px, body 16.0px
1280px zoom 200%: on screen vw-only 51.2px, fluid 64.0px, body 32.0px  growth 1.00x / 1.60x / 2.00x
1280px zoom 300%: on screen vw-only 51.2px, fluid 84.0px, body 48.0px  growth 1.00x / 2.10x / 3.00x
default                           scroll-behavior smooth, --color-border #e5e7eb, hover yes, dark no, filter link height 18px
less motion, more contrast, dark  scroll-behavior auto, --color-border #6b7280, hover yes, dark yes, filter link height 18px
phone (touch)                     scroll-behavior smooth, --color-border #e5e7eb, hover no, dark no, filter link height 44px
chromium 320px cover 288x432
chromium 1280px cover 320x480
webkit 320px cover 288x432
webkit 1280px cover 320x480

İlk üç satır tahmin sorusunun cevabı. Sayfanın kök font boyutu 20px olmasına rağmen 800px'te iskelet iki kolona geçti; 760px'te geçmedi. Yani 48rem burada 768px. Spesifikasyon bunu açıkça tanımlıyor: media query içindeki göreli birimler (em, rem) sayfanın stillerine değil, başlangıç değerine, yani tarayıcının ya da kullanıcının ayarladığı varsayılan font boyutuna göre hesaplanır. Bu mantıklı, çünkü media query'nin sonucu sayfanın stillerini değiştirebilir; stiller de media query'yi değiştirebilseydi bir döngü oluşurdu. Pratikte şu demek: kullanıcı tarayıcı ayarlarından varsayılan font boyutunu 20px yaparsa 48rem'lik breakpoint 960px'e çıkar ve büyük yazıyla iki kolon sığmayacak ekranlarda sayfa tek kolonda kalır. Piksel tabanlı breakpoint bu ayara tepki vermez; bu yüzden breakpoint'leri rem ya da em ile yaz.

"tablet: " öneki yalnız 800px'te göründü: (48rem <= width < 64rem), 768 ile 1024 piksel arasını tek ifadeyle tarif ediyor. Eski yazımda bu (min-width: 48rem) and (max-width: 63.99rem) gibi bir ifadeydi ve o .99'lu sınırlar, tam sınır değerinde iki kuralın da uygulanmadığı ya da ikisinin birden uygulandığı hatalara yol açıyordu. Range syntax Mart 2023'ten beri Baseline ve Eylül 2025'ten beri widely available.

clamp() ile Fluid Typography ve Zoom

Başlıkların ekran genişledikçe kademeli büyümesi için her breakpoint'te ayrı bir font boyutu yazmak yerine tek bir formül kullanılabilir. clamp(MIN, PREFERRED, MAX) ortadaki tercih edilen değeri hesaplar ve iki sınırın arasında tutar. Lab sayfasında iki başlık var; biri yalnız viewport birimiyle, öteki clamp() ile:

.vw-only { font-size: 4vw; }
.fluid { font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem); }

Çıktının ikinci bloğuna bak. 360px'te 4vw 14.4px veriyor, yani başlık gövde metninden (16px) küçük. 1280px'te 51.2px'e çıkıyor ve büyüdükçe büyüyor. clamp()'li başlık 360px'te alt sınırda (28px), 768px'te 34.6px (1.2rem + 2vw = 19.2 + 15.36), 1280px'te üst sınırda (40px). Asıl fark zoom'da ortaya çıkıyor.

Tarayıcı zoom'u, CSS pikselini büyütür: %200 zoom'da 1280 piksellik bir ekran sayfaya 640 CSS pikseli genişliğinde görünür ve her CSS pikseli iki ekran pikseli kaplar. Script'te zoom'u aynen bu şekilde taklit ettim:

// zoom: layout viewport zoom oranında daralır, her CSS pikseli zoom × cihaz pikseli kaplar
const { browser, page } = await open('lab/08-fluid.html', { engine, width: Math.round(width / zoom), deviceScaleFactor: zoom });

Sonuç: %200 zoom'da gövde metni ekranda 16'dan 32 piksele çıktı, beklendiği gibi 2 kat. 4vw'lik başlık ise 51.2px'te kaldı; zoom ona hiç etki etmedi, çünkü viewport daraldıkça vw değeri tam o oranda küçülüyor. Görme güçlüğü olan bir kullanıcı ne kadar zoom yaparsa yapsın başlık büyümüyor. W3C'nin (World Wide Web Consortium; web standartlarını yayımlayan kuruluş) WCAG (Web Content Accessibility Guidelines; web erişilebilirlik yönergeleri) için yayımladığı F94 (kriteri ihlal eden yaygın hatalardan biri olarak listelenen teknik) tam bu durumu tarif ediyor: font boyutunu esas olarak viewport birimleriyle belirlemek, metnin %200'e kadar büyütülebilmesini isteyen WCAG 1.4.4 kriterini büyük ihtimalle ihlal eder.

clamp()'li başlık %200'de 64px'e çıktı (1.6 kat), %300'de 84px'e (2.1 kat). Büyüyor, çünkü formüldeki 1.2rem zoom'la birlikte büyüyor ve sınırlar da rem cinsinden. Ama 2 kat değil: formüldeki vw payı zoom'da küçülüyor. Buradan çıkan kural basit: fluid bir formülde vw payı ne kadar büyükse zoom'un etkisi o kadar azalır. Tercih edilen değeri her zaman bir rem ile bir vw'nin toplamı olarak yaz, vw payını küçük tut ve en büyük boyuta zoom'la ulaşılabildiğini ölç. Bu yazıdaki script bunun için yeterli. Erişilebilirlik tarafının ayrıntısı ilerideki Accessibility (a11y) serisinde.

Bookstore'da aynı formülü bütün h1'lere verdim:

/* 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);
}

Kullanıcı Tercihleri: prefers-color-scheme, prefers-reduced-motion, prefers-contrast

Bazı media feature'lar ekranı değil, kullanıcının işletim sisteminde ya da tarayıcısında seçtiği tercihleri okur. Bookstore için bunları ayrı bir dosyada topladım ve main.css'te components layer'ının son import'u yaptım; böylece aynı layer'daki component varsayılanlarını kaynak sırasıyla yeniyorlar:

/* kullanıcı tercihleri: bu kurallar components layer'ının sonunda, bu yüzden component varsayılanlarını yener */

/* smooth scroll yalnız daha az hareket istemeyen kullanıcılar için */
@media (prefers-reduced-motion: no-preference) {
    html {
        scroll-behavior: smooth;
    }
}

/* daha fazla kontrast isteyen kullanıcılar için daha koyu kenarlık ve ikincil metin */
@media (prefers-contrast: more) {
    :root {
        --color-muted: #374151;
        --color-border: #6b7280;
    }
}

/* hover efekti yalnız ana giriş cihazı gerçekten hover yapabiliyorsa */
@media (hover: hover) {
    .book-card:hover {
        box-shadow: 0 0 0 2px var(--color-muted);
    }
}

/* ana giriş cihazı parmaksa daha büyük dokunma hedefleri */
@media (pointer: coarse) {
    .filters a {
        display: block;
        min-block-size: 2.75rem;
        align-content: center;
    }
}

Hareket tercihi için no-preference'ı kontrol ettim, reduce'u değil. Fark küçük ama önemli: smooth scroll'u varsayılan yapıp "azaltılmış hareket isteyene kapat" demek yerine yalnız itirazı olmayana açıyorum. Media query'yi desteklemeyen bir ortamda kural hiç uygulanmaz ve sayfa hareketsiz kalır, yani yanlış tarafa düşmez. Animasyonu kapatmak yerine azaltmak serinin animasyon yazısının konusu.

Kontrast tercihinde yalnız iki custom property'nin değerini değiştirdim; bileşenlerin hiçbirine dokunmadım. Bu, tasarım token'larını custom property olarak tanımlamanın en somut faydası ve serinin theming yazısında ayrıntılı olarak işleyeceğiz. prefers-color-scheme da aynı mekanizmayla çalışıyor ve koyu temayı o yazıda kuracağız; burada yalnız tarayıcının bu tercihi doğru raporladığını kontrol ettim. Playwright bu üç tercihi de sayfa açılırken taklit edebiliyor (reducedMotion, contrast, colorScheme). Çıktıdaki "less motion, more contrast, dark" satırında scroll-behavior auto'ya döndü, kenarlık rengi #6b7280 oldu ve prefers-color-scheme: dark eşleşti.

Fare mi, Parmak mı? hover ve pointer

Ekran genişliği, cihazın nasıl kullanıldığını söylemez: 1024px'lik bir tablet parmakla, 390px'lik bir pencere fareyle kullanılabilir. hover ve pointer media feature'ları doğrudan giriş cihazını sorar. hover: hover, ana giriş cihazının bir element'in üstünde durabildiğini (fare, touchpad); pointer: coarse, ana giriş cihazının hassasiyetinin düşük olduğunu (parmak) söyler. any-hover ve any-pointer ise yalnız ana cihaza değil, bağlı olan herhangi birine bakar; dokunmatik ekranlı ve fareli bir dizüstünde pointer: fine ile any-pointer: coarse aynı anda doğru olabilir.

Çıktının "phone (touch)" satırında Playwright'ı dokunmatik bir telefon gibi çalıştırdım (isMobile ve hasTouch). hover: hover eşleşmedi, yani kartın gölgesi hiç uygulanmayacak; telefonda bir kartın üstünde parmağı tutmak çoğu zaman tıklamayla aynı şey olduğu için bu doğru. Filtre bağlantıları ise 18px'lik satır içi yükseklikten 44px'lik bloklara çıktı. Bunu yaparken bir önceki yazıda gördüğümüz, block container'daki align-content: center'ı kullandım: metin 44px'in ortasında. Dokunma hedefi boyutlarının gerekçesi ve WCAG'deki karşılıkları yine Accessibility (a11y) serisinin konusu.

Hover'a bağlı hiçbir işlev koyma: menüyü yalnız hover'da açan bir arayüz dokunmatik ekranda hiç açılmaz. Hover yalnız ek bir görsel ipucu olmalı; aynı işlev tıklamayla ve klavyeyle de çalışmalı.

CSS Tarafında Responsive Görsel: aspect-ratio ve object-fit

Görselin hangi çözünürlükte indirileceği (srcset, sizes, picture) HTML'in işi ve HTML5 ve Semantic HTML serisinin 8. yazısında anlatıldı. CSS'in işi, gelen görselin kutusunu ekrana oturtmak. Kitap detay sayfasındaki kapak bunu serinin box model yazısından beri yapıyor: genişliği min(100%, 20rem), oranı aspect-ratio: 2 / 3, içerik object-fit: cover ile kutuyu dolduruyor. Çıktının son satırları: 320px'lik telefonda kapak 288 × 432px (sayfanın iki yanındaki padding düşünce kalan genişlik), 1280px'te 320 × 480px. İki durumda da oran 2:3 ve sayfa görsel yüklenirken kaymıyor, çünkü kutunun yüksekliği görsel inmeden belli.

Yayınevinden gelen bir kapak 2:3 değilse object-fit: cover görseli kırparak kutuyu doldurur, contain bütün görseli gösterip kenarlarda boşluk bırakır. Kırpılan bölgeyi object-position seçer; kapaklarda başlık genellikle üstte olduğu için object-position: top çoğu zaman ortadan daha iyi sonuç verir.

Üretimde Dikkat

Bu yazıdaki her şey Baseline ve çoğu yıllardır widely available: clamp() 2020'den, hover/pointer 2018'den, prefers-color-scheme ve prefers-reduced-motion 2020'den, prefers-contrast 2022'den, range syntax 2023'ten beri. Fallback yazmana gerek yok.

Responsive davranışı test ederken yalnız pencereyi daraltmak yetmez. Bu yazıdaki script, aynı sayfayı dört farklı biçimde açıyor: farklı genişlik, zoom, kullanıcı tercihleri ve dokunmatik cihaz. Bunları bir CI (Continuous Integration; her değişiklikte otomatik çalışan build ve test hattı) adımına koymak, "masaüstünde düzgün görünüyor" ile yetinmekten çok daha fazla hatayı yakalar. Gerçek cihaz testinin yerini tutmaz ama en sık bozulan şeyleri her commit'te kontrol eder.

Viewport birimlerinde bir tuzak daha var: mobil tarayıcıların adres çubuğu kaydırınca gizlenip göründüğü için 100vh çoğu telefonda görünen alandan uzun. Serinin box model yazısında anlattığımız svh, lvh ve dvh birimleri bunun için var; Bookstore'un sayfa iskeleti min-block-size: 100svh kullanıyor.

Sık Düşülen Tuzaklar

Birincisi, font boyutunu yalnız vw ile vermek. Ölçtüğümüz gibi zoom'da hiç büyümez ve telefonda gövde metninden küçük kalabilir.

İkincisi, breakpoint'leri px ile yazmak. Kullanıcının varsayılan font boyutu ayarına tepki vermezler; rem ya da em kullan ve bu değerlerin sayfanın kök font boyutundan değil, başlangıç değerinden hesaplandığını unutma.

Üçüncüsü, dokunmatik cihazı ekran genişliğinden tahmin etmek. "768px'ten darsa dokunmatiktir" varsayımı hem büyük tabletlerde hem dar masaüstü pencerelerinde yanlış; pointer ve hover'ı sor.

Dördüncüsü, max-width ile min-width query'lerini sınır değerlerinde çakıştırmak. (max-width: 768px) ile (min-width: 768px) tam 768px'te ikisi birden eşleşir; range syntax'ta < ve >= ile bu belirsizlik kalkar.

Küçük Bir Deneme

★☆☆ lab/08-fluid.html'deki .fluid'in formülünü clamp(1.75rem, 1.5rem + 1vw, 2.5rem) yap. %200 zoom'daki büyüme oranının artacağını mı azalacağını mı tahmin et, sonra node scripts/08-responsive.mjs ile kontrol et.

★★☆ Script'e kullanıcının varsayılan font boyutunu 20px yaptığı durumu ekle (Chromium'da bir CDP (Chrome DevTools Protocol; Chrome'u dışarıdan yöneten protokol) oturumu açıp Page.setFontSizes ile) ve catalog.html'in hangi genişlikte iki kolona geçtiğini bul.

★★★ preferences.css'e forced-colors: active için bir kural ekle: Windows'un yüksek kontrast temalarında kart kenarlıkları kaybolmasın. Playwright'ın forcedColors seçeneğiyle kartın kenarlık renginin sistem rengine döndüğünü doğrula.

Bir sonraki yazıda responsive tasarımı sayfadan component'e indiriyoruz: container query'ler. Aynı kitap kartı dar bir kolonda ve geniş bir alanda farklı görünecek; ama bir önceki yazıda kurduğumuz subgrid ile container query'nin aynı element'te neden birlikte çalışmadığını da ölçeceğiz.