Normal Flow, display ve Positioning: sticky, z-index, Stacking Context ve Top Layer
z-index yalnız aynı stacking context içindeki kardeşler arasında karşılaştırılır; sticky ise en yakın scroll container'a göre yapışır, bu yüzden bir atadaki overflow: hidden onu bozar. Bookstore'un katalog sayfasında flow-root ile float'ı kutuda tutuyor, display'in iki değerli syntax'ını ölçüyor, overflow-x: clip ile sticky toolbar'ı onarıyor, isolation: isolate ile rozeti header'ın altına alıyor ve modal dialog'un top layer'da her z-index'i yendiğini Chromium ve WebKit'te gösteriyoruz.
Sürüm notu: Bu yazı CSS Display, Positioned Layout ve CSS 2.1'in stacking kuralları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 verisinden alınmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için katalog sayfasında iki şikâyet var. Birincisi: header'ın altındaki "Sort by" çubuğu position: sticky olduğu halde sayfa kayınca yukarıda kalmıyor, içerikle birlikte gidiyor. İkincisi: Dune kartındaki kırmızı "Bestseller" rozeti, sayfa kayınca sticky header'ın altına girmesi gerekirken header'ın üstünde görünüyor. Header'ın z-index'i 10, rozetinki 50. İki sayfanın CSS'i şöyle:
/* kendi başına çalışır: bu lab main.css'i yüklemez */
body { margin: 0; }
ul { list-style: none; margin: 0; padding: 0; }
.site-header { position: sticky; inset-block-start: 0; block-size: 56px; background: #1f2937; }
.catalog { overflow-x: hidden; }
.catalog-toolbar { position: sticky; inset-block-start: 56px; background: #fef3c7; }
.book-card { block-size: 200px; }
/* kendi başına çalışır: bu lab main.css'i yüklemez */
body { margin: 0; }
ul { list-style: none; margin: 0; padding: 0; }
.site-header { position: sticky; inset-block-start: 0; z-index: 10; block-size: 56px; background: #1f2937; }
.book-card { position: relative; block-size: 240px; }
.badge { position: absolute; inset-block-start: 8px; inset-inline-end: 8px; z-index: 50; background: #b91c1c; color: #fff; }
İlk sayfada sticky'yi bozan satır hangisi? İkincisinde rozeti header'ın altına almak için header'ın z-index'ini 100 yapmak doğru çözüm mü, yoksa rozetin hiçbir zaman header ile yarışmamasını sağlayan başka bir yol var mı?
Önceki yazıda tek bir kutunun boyutunu konuştuk. Bu yazıda kutuların sayfaya nasıl yerleştiğine bakıyoruz: normal flow, display, position ve kutuların üst üste geldiğinde hangisinin üstte çizileceğine karar veren stacking context. Flexbox ve Grid'e geçmeden önce bu temeli oturtmak, "z-index çalışmıyor" ve "sticky çalışmıyor" gibi en sık sorulan iki sorunun cevabını veriyor.
Normal Flow ve Formatting Context'ler
Hiçbir layout property'si yazmadığında tarayıcı element'leri normal flow'a göre yerleştirir. Block kutular (div, p, h1) alt alta dizilir ve bulundukları alanın genişliğini doldurur. Inline kutular (span, a, strong) satırın içinde yan yana akar ve satır bitince alta geçer. Bu iki düzene block formatting context ve inline formatting context denir. Önceki yazıdaki margin collapse da block formatting context'in bir kuralı.
Yeni bir block formatting context (BFC; içeriğini dışarıdan bağımsız yerleştiren kutu) kuran element'in içi, dışarıdan bağımsız bir dünya olur: içerideki float'lar kutunun dışına taşmaz, child'ların margin'leri parent'ın dışına collapse etmez. Bunu en temiz şekilde yapan değer display: flow-root. Kitap yorumlarında kapağı sola float ettiğimiz bir lab sayfasında ölçtüm:
@layer components {
.review { border: 2px solid #94a3b8; padding: 8px; margin-block-end: 24px; }
.review img { float: inline-start; margin-inline-end: 12px; }
.review.flow-root { display: flow-root; }
}
node scripts/04-flow.mjs
node scripts/04-position.mjs
--- chromium
plain article height: 76px, floated cover: 90px
contained article height: 110px, floated cover: 90px
display: block flow computed: block
display: block flow-root computed: flow-root
display: inline flow-root computed: inline-block
display: inline flex computed: inline-flex
display: block grid computed: grid
--- webkit
plain article height: 76px, floated cover: 90px
contained article height: 110px, floated cover: 90px
display: block flow computed: block
display: block flow-root computed: flow-root
display: inline flow-root computed: inline-block
display: inline flex computed: inline-flex
display: block grid computed: grid
Float edilen kapak 90px, ama ilk yorum kutusu yalnız 76px: float normal akıştan çıktığı için kutu onun yüksekliğini hesaba katmadı ve kapak kenarlığın altından taştı. flow-root eklenen ikinci kutu 110px'e uzadı ve kapağı içine aldı. Eskiden bunun için overflow: hidden ya da "clearfix" hack'i kullanılırdı; flow-root yan etkisi olmayan, adı da ne yaptığını söyleyen çözüm ve Ocak 2020'den beri Baseline. Bugün layout için float'a nadiren ihtiyaç var, ama metni bir görselin etrafından akıtmak gibi asıl amacı için hâlâ doğru araç.
display'in İki Değerli Syntax'ı
display aslında iki ayrı şeyi söyler. Dış değer (outer), kutunun kendisinin çevresiyle nasıl davrandığını belirler: block mu inline mı. İç değer (inner), kutunun kendi child'larını nasıl yerleştirdiğini: flow, flow-root, flex, grid. Yıllarca kullandığımız tek kelimelik değerler bu ikilinin kısaltması ve CSS Display Level 3 ikisini ayrı yazmaya izin veriyor. Temmuz 2023'ten beri Baseline, Ocak 2026'dan beri widely available. Lab sayfasındaki span'lere iki değerli display verip tarayıcının ne hesapladığına baktım:
<div id="probes">
<span style="display: block flow">block flow</span>
<span style="display: block flow-root">block flow-root</span>
<span style="display: inline flow-root">inline flow-root</span>
<span style="display: inline flex">inline flex</span>
<span style="display: block grid">block grid</span>
</div>
Çıktının son beş satırı eşleşmeyi gösteriyor: block flow = block, inline flow-root = inline-block, inline flex = inline-flex, block grid = grid. İki motor da hesaplanmış değeri eski, tek kelimelik biçimde döndürüyor. Kodda hangisini yazacağın bir tercih meselesi; ben ekipte herkesin tanıdığı kısa biçimleri kullanıyorum, ama iki değerli syntax'ı bilmek inline-block'un aslında "dışı inline, içi flow-root" demek olduğunu, yani neden içindeki margin'leri ve float'ları kendi içinde tuttuğunu açıklıyor.
position: relative, absolute, fixed ve sticky
position: relative kutuyu normal akıştaki yerinde bırakır; inset değerleriyle yalnız görüntüsünü kaydırır. Asıl işi, içindeki absolute element'lere referans olmak. absolute kutuyu akıştan çıkarır ve position'ı static olmayan en yakın atasına göre konumlar. Bookstore'un rozetini böyle yerleştirdim: kart position: relative, rozet position: absolute ile kartın sağ üst köşesinde. inset-block-start ve inset-inline-end önceki yazıdaki logical property'ler; RTL (sağdan sola yazılan) bir sayfada rozet kendiliğinden sol üste geçer.
fixed kutuyu viewport'a göre konumlar ve sayfa kaysa da yerinde kalır. Bir tuzağı var: atalardan birinde transform, filter ya da perspective varsa, fixed element viewport'a göre değil o ataya göre konumlanır. Bir kartın hover animasyonu için eklenen transform, kartın içindeki fixed bir bildirimi kartla birlikte kaydırmaya başlar.
sticky ise ikisinin karışımı: element normal akışta durur, ama en yakın scroll container'ı kaydırıldığında inset-block-start ile verdiğin çizgiye gelince orada yapışır ve parent'ının sonuna kadar orada kalır. Tahmin sorusunun birinci kısmının cevabı bu tanımdaki "en yakın scroll container" ifadesinde. Script .catalog'un overflow-x değerini değiştirip sayfayı 600px kaydırdı:
import { open } from './browser.mjs';
for (const engine of ['chromium', 'webkit']) {
console.log(`--- ${engine}`);
// 1) overflow-x'li bir <main> içinde sticky toolbar
let { browser, page } = await open('lab/04-sticky.html', { engine, height: 600 });
for (const overflowX of ['hidden', 'clip', 'visible']) {
const top = await page.evaluate(value => {
document.querySelector('.catalog').style.overflowX = value;
window.scrollTo(0, 0);
window.scrollTo(0, 600);
return document.querySelector('.catalog-toolbar').getBoundingClientRect().top;
}, overflowX);
console.log(`overflow-x: ${overflowX.padEnd(8)} toolbar top after scrolling 600px: ${top}px`);
}
await browser.close();
// 2) Rozet sticky header'ın altına girdiğinde onun konumunda hangi element çiziliyor?
({ browser, page } = await open('lab/04-stacking.html', { engine, height: 600 }));
const variants = {
'no stacking context': '',
'.book-list isolation': '.book-list { isolation: isolate; }',
'.book-card transform': '.book-card { transform: translateX(0); }',
'.book-card opacity': '.book-card { opacity: 0.99; }'
};
for (const [name, css] of Object.entries(variants)) {
const hit = await page.evaluate(extra => {
document.getElementById('variant')?.remove();
const style = document.createElement('style');
style.id = 'variant';
style.textContent = extra;
document.head.append(style);
window.scrollTo(0, 30);
const badge = document.querySelector('.badge').getBoundingClientRect();
const el = document.elementFromPoint(badge.left + badge.width / 2, badge.top + badge.height / 2);
return `badge top ${badge.top}px, on top: ${el.className || el.tagName.toLowerCase()}`;
}, css);
console.log(`${name.padEnd(22)} ${hit}`);
}
// 3) Top layer: z-index modal bir dialog ile yarışamaz
const topLayer = await page.evaluate(() => {
document.querySelector('.site-header').style.zIndex = '2147483647';
const dialog = document.getElementById('quick-view');
dialog.showModal();
const r = dialog.getBoundingClientRect();
const el = document.elementFromPoint(r.left + 5, r.top + 5);
return `header z-index ${getComputedStyle(document.querySelector('.site-header')).zIndex}, ` +
`on top: ${el.id || el.tagName.toLowerCase()}`;
});
console.log(`modal dialog ${topLayer}`);
await browser.close();
}
--- chromium
overflow-x: hidden toolbar top after scrolling 600px: -488px
overflow-x: clip toolbar top after scrolling 600px: 56px
overflow-x: visible toolbar top after scrolling 600px: 56px
no stacking context badge top 34px, on top: badge
.book-list isolation badge top 34px, on top: site-header
.book-card transform badge top 34px, on top: site-header
.book-card opacity badge top 34px, on top: site-header
modal dialog header z-index 2147483647, on top: quick-view
--- webkit
overflow-x: hidden toolbar top after scrolling 600px: -488px
overflow-x: clip toolbar top after scrolling 600px: 56px
overflow-x: visible toolbar top after scrolling 600px: 56px
no stacking context badge top 34px, on top: badge
.book-list isolation badge top 34px, on top: site-header
.book-card transform badge top 34px, on top: site-header
.book-card opacity badge top 34px, on top: site-header
modal dialog header z-index 2147483647, on top: quick-view
overflow-x: hidden ile toolbar −488px'te, yani ekranın çok yukarısında. Sebep: overflow'un bir ekseni visible dışında bir değer alınca diğer ekseni de auto'ya döner ve .catalog bir scroll container olur. Sticky toolbar artık sayfaya değil .catalog'a göre yapışmaya çalışıyor, ama .catalog'un kendisi kaymadığı (sayfa kaydığı) için hiç yapışmıyor. Muhtemelen biri yatay kaydırma çubuğunu gizlemek için bu satırı eklemişti. Önceki yazıda değindiğim overflow: clip tam bu yüzden var: taşanı yine keser ama scroll container oluşturmaz. clip ile toolbar 56px'te, header'ın hemen altında; Eylül 2022'den beri Baseline. Bookstore'un katalog stilinde bunu kalıcı hale getirdim:
/* hidden değil clip: hidden .catalog'u bir scroll container yapar ve sticky'yi bozar */
.catalog {
overflow-x: clip;
padding-inline: var(--space);
}
.catalog-toolbar {
position: sticky;
inset-block-start: 3.5rem;
z-index: 5;
padding-block: 0.5rem;
background-color: var(--color-surface);
}
Sticky'nin çalışmamasının başka iki yaygın sebebi daha var: inset-block-start (ya da top) hiç yazılmamış olması, ve sticky element'in parent'ının element'in kendisi kadar kısa olması. Sticky element parent'ından dışarı çıkamaz; parent'ın içinde kayacak yer yoksa yapışacak yer de yoktur.
z-index ve Stacking Context
Tahmin sorusunun ikinci kısmı. z-index, element'leri üçüncü boyutta sıralar, ama tek bir global sıralama yok. Sayfa iç içe stacking context'lerden oluşur ve bir element'in z-index'i yalnız kendi stacking context'i içindeki kardeşleriyle karşılaştırılır. Bir stacking context'in içindeki her şey, dışarıya karşı tek bir katman olarak davranır.

Çıktının ikinci bloğu bunu ölçüyor. Sayfayı rozet header'ın altına girecek kadar kaydırıp elementFromPoint ile rozetin olduğu noktada en üstte hangi element'in çizildiğini sordum. Hiçbir stacking context yokken rozet ile header aynı (kök) context'te karşılaştırılıyor ve 50, 10'u yeniyor: rozet header'ın üstünde. .book-list'e isolation: isolate eklenince liste yeni bir stacking context oldu; rozetin 50'si yalnız listenin içinde sayıldı, dışarıda ise liste bir bütün olarak z-index: auto, yani 0 seviyesinde ve header'ın 10'una kaybetti. Header'ın z-index'ini 100'e çıkarmak da bugünkü hatayı çözerdi, ama yarın birisi 150'lik bir rozet yazdığında aynı hata geri gelir. isolation: isolate ise listenin içindeki hiçbir z-index'in dışarıyla yarışmamasını garanti ediyor. Bookstore'un katalog listesine bunu ekledim:
.book-list {
list-style: none;
margin: 0;
padding: 0;
/* listenin içindeki z-index değerleri sticky header ile hiçbir zaman yarışmaz */
isolation: isolate;
}
.book-card {
position: relative;
padding: var(--space);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background-color: var(--color-surface);
}
/* a card without a cover image gets a tinted background instead */
.book-card:not(:has(> img)) {
background-color: #fef3c7;
}
/* zebra stripes count only the books that are in stock */
.book-card:nth-child(even of .in-stock) {
border-color: #93c5fd;
}
.book-card[data-stock="0"] .price {
text-decoration: line-through;
color: var(--color-muted);
}
.book-card[data-format~="ebook"] .book-title::after {
content: " · e-book";
font-size: 0.75em;
color: var(--color-muted);
}
.badge {
position: absolute;
inset-block-start: 0.5rem;
inset-inline-end: 0.5rem;
z-index: 1;
padding-inline: 0.5rem;
border-radius: var(--radius);
background-color: var(--color-accent);
color: #ffffff;
font-size: 0.75rem;
}
Çıktının üçüncü ve dördüncü satırları daha sinsi bir şeyi gösteriyor: karta transform: translateX(0) ya da opacity: 0.99 yazmak da sonucu değiştirdi. Stacking context yalnız z-index ile oluşmaz. opacity'si 1'den küçük, transform, filter, backdrop-filter, clip-path, mask ya da mix-blend-mode taşıyan her element, position'ı fixed ya da sticky olan her element, z-index'i auto olmayan konumlu element'ler ve flex/grid item'ları, isolation: isolate, contain: paint ve container-type verilmiş container'lar da bir stacking context kurar. Buradaki örnekte bu yan etki işimize yaradı, ama tersi de sık olur: bir karta hover animasyonu için eklenen transform, kartın içindeki bir dropdown'ın z-index: 1000'ini kartın içine hapseder ve dropdown yandaki kartın altında kalır. "z-index çalışmıyor" dediğinde ilk soru şu olmalı: bu element hangi stacking context'in içinde?
Top Layer: dialog ve popover'ın z-index Savaşını Bitirmesi
Çıktının son satırında header'ın z-index'ini mümkün olan en büyük değere, 2147483647'ye çıkardım ve bir dialog'u showModal() ile açtım. En üstte yine dialog var. Modal dialog, popover ve fullscreen'e alınan element'ler top layer denen ayrı bir katmanda çizilir. Top layer bütün stacking context'lerin üstündedir ve z-index ile oraya ulaşılamaz. Hiçbir atadaki overflow: hidden ya da transform da onu kesemez.
Bu, Bookstore'un hızlı önizleme penceresi, sepet menüsü ve onay pencereleri gibi "her şeyin üstünde" olması gereken her şey için z-index yarışını gereksiz kılıyor. Kendi modal component'ini div ve z-index: 9999 ile yazmak yerine native element'leri kullan; dialog ve popover'ın HTML tarafı HTML5 ve Semantic HTML serisinin native etkileşimli element'ler yazısında anlatıldı. Popover'ı bir butona göre konumlamak ise serinin anchor positioning yazısının konusu.
Üretimde Dikkat
Projede bir z-index ölçeği belirleyin ve rastgele sayı yazmayı bırakın. Bookstore'da kural basit: sayfa düzeyindeki katmanlar (sticky header 10, sticky toolbar 5) kök context'te; component'lerin içindeki her şey isolation: isolate ile kendi context'inde ve orada 1, 2 gibi küçük sayılar yeterli. "Her şeyin üstünde" olması gereken her şey top layer'da. Bu ölçeği custom property'lerle isimlendirmeyi serinin theming yazısında göreceğiz.
overflow: hidden'ı bir kutuya eklemeden önce içinde sticky bir element olup olmadığını düşün; yatay taşmayı gizlemek istiyorsan overflow-x: clip kullan. Aynı şekilde transform ve filter gibi property'leri geniş kapsamlı selector'lere vermek (bütün kartlara, bütün section'lara) fark etmeden stacking context ve fixed element'ler için containing block üretir. Bir animasyon için gerekiyorsa yalnız animasyon süresince ver.
Sticky ve stacking sorunları ekranda görünür ama nedeni koddan anlaşılmaz. Chrome DevTools'un Elements panelinde Computed sekmesi bir element'in gerçek position, overflow ve z-index değerlerini gösterir; Layers paneli de sayfadaki katmanları üç boyutlu görüntüler. Bu yazıdaki gibi elementFromPoint ile yapılan küçük bir Playwright kontrolü, bir sticky header'ın altına bir şeyin taşıp taşmadığını regresyon testine çevirmenin kolay bir yolu.
Sık Düşülen Tuzaklar
Birincisi, sticky'nin "çalışmaması" için sticky element'te sebep aramak. Sebep neredeyse her zaman bir atada: overflow'u visible dışında olan bir ata ya da element'le aynı yükseklikteki bir parent.
İkincisi, z-index savaşı. Bir element üste çıkmıyorsa sayıyı büyütmek yerine stacking context'leri incele. Element'in kendisi değil, içinde bulunduğu context karşılaştırılıyor olabilir.
Üçüncüsü, position'ı olmayan bir element'e z-index vermek. Normal akıştaki bir block element'te z-index'in hiçbir etkisi yoktur; flex ve grid item'ları bunun istisnası.
Dördüncüsü, modal ve dropdown'ları z-index: 9999 ile yazmak. Top layer'ı kullanan dialog ve popover varken buna gerek yok.
Küçük Bir Deneme
★☆☆ lab/04-sticky.html'de .catalog'dan overflow-x'i tamamen kaldır, sonra toolbar'dan inset-block-start'ı kaldır. Her adımda node scripts/04-position.mjs'in ilk satırının nasıl değiştiğini not et.
★★☆ lab/04-stacking.html'deki varyant listesine filter: blur(0) ve will-change: transform ekle. Hangileri stacking context kuruyor? Sonucu MDN'nin (Mozilla'nın web geliştirici dokümantasyonu) stacking context listesiyle karşılaştır.
★★★ Katalog kartına bir "Quick view" dropdown'ı ekle ve kartlara hover'da transform: translateY(-2px) ver. Dropdown'ın bir sonraki kartın altında kaldığını elementFromPoint ile kanıtla, sonra dropdown'ı popover ile top layer'a taşıyıp aynı testi tekrarla.
Bir sonraki yazıda layout'un ilk büyük aracına geçiyoruz: Flexbox. Main ve cross axis, flex-grow, flex-shrink ve flex-basis'in boş alanı nasıl paylaştırdığını sayılarla hesaplayacak, header'ı, kart footer'ını ve kupon alanındaki input ile butonu Flexbox ile dizeceğiz.