Mert Özen Her Satırda Daha İleri
Frontend CSS Property'leri: Uygulamalı Rehber — #10

Pseudo-element'ler ve Üretilmiş İçerik: ::before/::after, content, Counter'lar, @counter-style, ::marker ve list-style

Mert Özen 15 Kas 2026 13 dk 15 görüntülenme
Pseudo-element'ler ve Üretilmiş İçerik: ::before/::after, content, Counter'lar, @counter-style, ::marker ve list-style

::before ve ::after yalnız content property'si none dışında bir değer aldığında oluşur; üretilen içerik screen reader'a ulaşır ama kopyalanmaz ve aranmaz, bu yüzden süs ve sayı için kullanılır, bilgi HTML'de kalır. Yazı alternatif metni (content: "★" / ""), counters() ile iç içe numaralamayı, Türk alfabesiyle @counter-style'ı, string marker ve ::marker'ın Safari sınırını ve Bookstore'un checkout adımlarını ölçümlerle anlatır.

Sürüm notu: Bu yazı CSS Pseudo-Elements Level 4, CSS Generated Content Level 3, CSS Lists and Counters Level 3 ve CSS Counter Styles Level 3 spesifikasyonlarına ve Baseline'ın Kasım 2026 durumuna (web-features 3.46.0) göre yazıldı; örnekler Chromium 158 ve WebKit 27.2 (Playwright 1.65) ile denenmiştir, Firefox desteği web-features ve MDN'in (Mozilla'nın web dokümantasyon projesi) browser-compat-data 8.1.9 verisinden alınmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için checkout sayfasına numaralı adımlar, kataloğa özel işaretli bir okuma listesi ve "New" etiketine küçük bir köşe kıvrımı ekleyeceğiz. Hepsi markup'a dokunmadan CSS'in ürettiği içerikle yapılıyor. Önce yeni başlayanların neredeyse hepsinin bir kez düştüğü bir durum:

/* 1) content yoksa pseudo-element de yok */
.dot::before { display: inline-block; inline-size: 20px; block-size: 10px; background: #b91c1c; }
#with-content::before { content: ""; }

İki span'in de ::before'u 20×10'luk kırmızı bir kutu olacak gibi duruyor. İkisinde de kutu görünür mü?

content Olmadan Pseudo-element Yok

::before ve ::after, element'in içeriğinin başına ve sonuna eklenen kutulardır. Kutunun oluşması için content property'sinin none ya da normal dışında bir değeri olmalı; boş string de sayılır. Lab'deki ölçüm:

node scripts/10-generated.mjs
no-content   ::before content none, span width 38
with-content ::before content "", span width 58

content yazılmayan span'de computed değer none ve span yalnız metin kadar, 38 piksel. content: "" eklenince kutu oluşuyor ve span 20 piksel genişleyip 58 oluyor. Giriş sorusunun cevabı: yalnız ikincisinde. Hiç görünmeyen bir pseudo-element'te ilk bakılacak yer content.

İkinci kural: img, input ve video gibi replaced element'lerin (içeriği CSS'in değil dışarıdan bir kaynağın belirlediği element'ler) içeriği yoktur, bu yüzden ::before ve ::after'ın eklenecek bir yeri de yoktur:

/* 2) replaced element'lerin ::before'u yok (yalnız kırık görsel gösterebilir) */
.cover::before { content: ""; position: absolute; inset: 0; background: #b91c1c; }
.cover { position: relative; display: block; inline-size: 60px; block-size: 90px; }
--- chromium
cover-ok     pixel (5, 5): rgb(193, 120, 40)
cover-broken pixel (5, 5): rgb(185, 28, 28)
--- webkit
cover-ok     pixel (5, 5): rgb(191, 116, 33)
cover-broken pixel (5, 5): rgb(153, 158, 164)

Yüklenen kapakta iki motorda da sol üst piksel kapağın turuncusu; kırmızı ::before çizilmiyor. Dosyası bulunamayan kapakta Chromium ::before'u çiziyor (kırmızı), çünkü kırık görselin yerine kendi içeriğini (alt metni) gösteriyor; WebKit ise kendi kırık görsel çerçevesini çiziyor (gri). Bu davranışa güvenme; görsele bir süs eklemek istiyorsan onu bir sarmalayıcı element'in pseudo-element'ine koy.

content Değerleri ve Alternatif Metin

content birden fazla parçayı yan yana alabilir: string'ler, attr() ile bir attribute'un değeri, counter() ve counters(), url() ile bir görsel, open-quote ve close-quote (dile ve quotes property'sine göre tırnak işareti). Üretilen içerik DOM'a (sayfanın element ağacı) girmez, ama screen reader'lara ulaşır. Bunu görmek için Chromium'un accessibility tree'sini (screen reader'ların okuduğu yapı) DevTools protokolü üzerinden okudum; bu kısım yalnız Chromium'da çalışıyor:

<button class="badge" id="badge-plain">New</button>
<button class="badge" id="badge-alt">New</button>
<a class="ext" id="ext" href="https://example.com/dune">Publisher page</a>
/* 3) süs mü, anlamlı mı? / işaretinden sonraki kısım alternatif metin */
.badge::before { content: "★ "; }
#badge-alt::before { content: "★ " / ""; }
.ext::after { content: " (" attr(href) ")"; }
accessible names: badge-plain "★ New", badge-alt "New", ext "Publisher page (https://example.com/dune)"
ext innerText: "Publisher page"

Birinci butonun erişilebilir adı "★ New": yıldız da okunuyor ve screen reader ona "siyah yıldız" gibi bir ad verebilir. İkinci butonda content: "★ " / "" yazdık; eğik çizgiden sonraki kısım alternatif metin ve boş olduğu için ad yalnız "New". Süs amaçlı üretilmiş içerikte alternatif metni boş bırak; bilgi taşıyan içerikte (bir ikonla "tamamlandı" demek gibi) anlamı alternatif metne yaz. Alternatif metin syntax'ı 2024'ten beri Baseline.

Link örneği öbür yönü gösteriyor. attr(href) ile eklenen URL accessibility tree'de linkin adına giriyor: "Publisher page (https://example.com/dune)". innerText ise yalnız "Publisher page" döndürüyor; yani üretilen metin kopyalanmaz, sayfa içi aramada bulunmaz ve JavaScript ile okunmaz. Bu yüzden kullanıcının ihtiyaç duyduğu bilgiyi (fiyat, stok durumu, bir uyarı) content ile verme; o bilgi HTML'de olmalı. attr()'in bu klasik kullanımı (değeri metin olarak eklemek) çok eskiden beri her yerde çalışıyor; değeri sayı, renk ya da uzunluk olarak başka property'lerde kullanan yeni attr() ise Baseline değil (Safari'de yok).

Counter'lar ve @counter-style

CSS counter'ları, element'leri sayan değişkenlerdir. counter-reset bir counter'ı başlatır (bir üst element'te), counter-increment artırır, counter-set belirli bir değere ayarlar. counter() tek bir değeri, counters() ise iç içe her seviyenin değerini bir ayırıcıyla birleştirip yazar:

<ol class="steps" id="steps">
    <li>Cart</li>
    <li>Address
        <ol class="steps">
            <li>Billing address</li>
            <li>Shipping address</li>
        </ol>
    </li>
    <li>Payment</li>
</ol>
/* 4) counter'lar: counters() ile iç içe numaralama */
.steps { counter-reset: step; list-style: none; padding: 0; }
.steps li { counter-increment: step; }
.steps li::before { content: counters(step, ".") ". "; }
steps:   1. Cart | 2. Address | 2.1. Billing address | 2.2. Shipping address | 3. Payment

İç içe listedeki her ol kendi counter-reset'iyle aynı adda yeni bir counter açıyor ve counters() bütün seviyeleri noktayla birleştiriyor: 2.1 ve 2.2. Liste işaretlerini list-style: none ile kapattık, numaraları ::before yazıyor. WebKit'te accessibility tree'yi okuyamadığım için aynı numaraları yalnız ekran görüntüsünde kontrol ettim.

Hazır sayma stilleri (decimal, lower-alpha, upper-roman…) yetmediğinde @counter-style ile kendi stilini tanımlarsın. Türkçe bir sınav sayfasında şıklar "a) b) c) ç)" diye gitmeli, ama lower-alpha İngiliz alfabesini kullanır ve "ç"yi atlar:

/* 5) Türk alfabesi için bir counter style */
@counter-style turkish-alpha {
    system: alphabetic;
    symbols: a b c ç d e f g ğ h ı i j k l m n o ö p r s ş t u ü v y z;
    suffix: ") ";
}
#answers { list-style: turkish-alpha; }
<ol id="answers"><li>Ankara</li><li>Izmir</li><li>Bursa</li><li>Konya</li></ol>
answers: a) Ankara | b) Izmir | c) Bursa | ç) Konya

Dördüncü şık "ç)": system: alphabetic, sembol listesini harf sırası gibi sayar (z'den sonra "aa", "ab"… gelir), suffix de her işaretin arkasına eklenir. WebKit'te de ekran görüntüsünde aynı harfler çıktı. @counter-style Mart 2026'dan beri widely available; Safari 17 ile geldi.

list-style, String Marker ve ::marker

Liste işaretleri için üç seviye var. list-style-type hazır bir stil ya da bir @counter-style adı alır; bir string de alabilir ve o string işaretin kendisi olur. list-style-position: inside işareti satırın içine alır. ::marker ise işaretin kutusunu seçer ve rengini, font'unu, hatta content'ini değiştirmeye izin verir:

/* 6) işaretler: işaret olarak string, renk ve content için ::marker */
.marks { list-style-position: inside; padding: 0; }
#string-marker { list-style-type: "– "; }
#marker-content li::marker { content: "→ "; color: #b91c1c; }
--- chromium
default-marker text starts at x=22, red marker pixels: 0
string-marker  text starts at x=13, red marker pixels: 0
marker-content text starts at x=18, red marker pixels: 14
--- webkit
default-marker text starts at x=15, red marker pixels: 0
string-marker  text starts at x=13, red marker pixels: 0
marker-content text starts at x=18, red marker pixels: 14

İşaret satırın içinde olduğu için metnin nereden başladığı işaretin genişliğini gösteriyor. Varsayılan madde işareti Chromium'da 22, WebKit'te 15 piksel yer kaplıyor; string marker ("– ") iki motorda da 13. ::marker ile değiştirilen "→ " iki motorda da 18 piksel ve kırmızı (14 kırmızı piksel). Burada bir uyarı gerekiyor: browser-compat-data'ya göre Safari ::marker'da yalnız color ve font-size'ı destekliyor ve web-features bu yüzden ::marker'ı "Baseline değil" sayıyor. Playwright'ın WebKit 27.2 build'i content'i de uyguladı, ama bu yayındaki Safari değil; gerçek Safari'de doğrulayamadım. Güvenli yol: işaretin metnini list-style-type string'iyle ver, ::marker'ı yalnız renk ve font boyutu için kullan.

list-style: none'ın bir de erişilebilirlik tarafı var. WebKit, işaretleri kaldırılmış listelerin bir kısmını sezgisel kurallarla liste olarak bildirmiyor; VoiceOver o zaman "liste, 3 öğe" demiyor. WebKit 2021'de bunu bilinçli bir karar olarak bıraktı ve Aralık 2025'te iOS'taki VoiceOver için aynı davranış yeniden raporlandı. Bunu bu makinede VoiceOver ile denemedim. Liste olduğu bilinmesi gereken, işaretsiz listelere role="list" ekle; bir başka yol, list-style: none yerine boş string marker kullanmak (list-style-type: "").

Checkout Adımları ve Okuma Listesi

Bookstore'un checkout sayfasındaki adım göstergesi, numaraları daire içinde gösteriyor. ::marker bir kutu çizemediği (kenarlık, arka plan, boyut almadığı) için liste kendi counter'ıyla sayıyor:

<ol class="checkout-steps" role="list">
    <li class="is-done">Cart</li>
    <li aria-current="step">Shipping</li>
    <li>Payment</li>
</ol>
/* daire içinde numaralar: ::marker kutu çizemez, bu yüzden liste kendisi sayar */
.checkout-steps {
    display: flex;
    gap: 1.5rem;
    margin: 0;
    padding: 0;
    list-style: none;
    counter-reset: step;
}
.checkout-steps li {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    color: var(--color-muted);
    counter-increment: step;
}
.checkout-steps li::before {
    content: counter(step);
    display: grid;
    place-items: center;
    inline-size: 1.75rem;
    block-size: 1.75rem;
    border: 2px solid currentColor;
    border-radius: 50%;
    font-weight: 700;
}
/* onay işareti süs; screen reader'a eğik çizgiden sonraki metin gider */
.checkout-steps .is-done::before { content: "✓" / "Completed:"; }
.checkout-steps [aria-current="step"] { color: var(--color-link); font-weight: 600; }
node scripts/10-pages.mjs
--- chromium
steps ::before: "✓" / "Completed:" | counter(step) | counter(step)
current step circle: 28px x 28px, border-radius 50%, color rgb(29, 78, 216)
steps for screen readers: Completed: Cart | 2 Shipping | 3 Payment
badge ::after content "", pixel (width - 2, 4) rgb(255, 255, 255), bottom left pixel rgb(185, 28, 28)
badge text for screen readers: "NEW"
reading list markers: "✓ " | "○ " | "○ "
red marker pixels left of "Emma": 15
reading list for screen readers: ✓ Emma finished | ○ Middlemarch | ○ Persuasion
::selection background: rgba(29, 78, 216, 0.2)
--- webkit
steps ::before: "✓" / "Completed:" | counter(step) | counter(step)
current step circle: 28px x 28px, border-radius 50%, color rgb(29, 78, 216)
badge ::after content "", pixel (width - 2, 4) rgb(255, 255, 255), bottom left pixel rgb(185, 28, 28)
reading list markers: "✓ " | "○ " | "○ "
red marker pixels left of "Emma": 14
::selection background: rgba(29, 78, 216, 0.2)

İlk üç satır adımlar için. Tamamlanan adımda numara yerine bir onay işareti var ve alternatif metni "Completed:"; Chromium'un accessibility tree'sinde adımlar "Completed: Cart", "2 Shipping", "3 Payment" olarak okunuyor. Onay işaretinin kendisi okunmuyor, anlamı okunuyor. Şu anki adım aria-current="step" ile işaretli ve aynı attribute stilin de seçicisi; durumu tek bir yerde tutuyoruz. Daireler 28×28 ve currentColor ile kenarlıklarını metnin renginden alıyor. list-style: none yazdığımız için ol'a role="list" verdik.

Okuma listesinde işaretler string: okunan kitapta "✓ ", diğerlerinde "○ ". Rengi ::marker veriyor; bu, Safari'nin de desteklediği kısım:

<section class="reading-list">
    <h2>My reading list</h2>
    <ul>
        <li class="is-read">Emma <small>finished</small></li>
        <li>Middlemarch</li>
        <li>Persuasion</li>
    </ul>
</section>
/* işaret olarak string: görsel yok, ::before yok */
.reading-list ul { padding-inline-start: 1.5rem; list-style-type: "○ "; }
.reading-list .is-read { list-style-type: "✓ "; }
/* Safari ::marker'da yalnız color ve font-size'ı uygular */
.reading-list li::marker { color: var(--color-accent); }
.reading-list small { color: var(--color-muted); }

Sayfa ölçümünde "Emma"nın solunda 14-15 kırmızı piksel var: işaret iki motorda da vurgu rengiyle çiziliyor. Chromium'un accessibility tree'si işaretleri de okuyor: "✓ Emma finished". İşaret tek başına bilgi taşımasın diye "finished" kelimesi metinde de var; bir screen reader'ın "✓" karakterini nasıl okuyacağı ürüne göre değişir.

Son parça "New" etiketinin sağ üst köşesindeki kıvrım. Tamamen süs, bu yüzden content: "":

/* süs amaçlı kıvrık köşe: boş content, screen reader'a bir şey gitmez */
.badge::after {
    content: "";
    position: absolute;
    inset-block-start: 0;
    inset-inline-end: 0;
    border-style: solid;
    border-width: 0 0.5rem 0.5rem 0;
    border-color: transparent var(--color-surface) rgb(0 0 0 / 0.25) transparent;
}

Kıvrımın içindeki piksel kartın beyazı, etiketin geri kalanı kırmızı. Accessibility tree'de etiketin metni yalnız NEW; boş content hiçbir şey eklemiyor. Metnin büyük harfle gelmesi ise text-transform: uppercase'ten: Chromium dönüştürülmüş metni bildiriyor (bu kararın Türkçe metinle ilgili tarafı UI/UX Tasarım İlkeleri serisinin 03. yazısında).

Diğer Pseudo-element'ler

::placeholder, bir input'un placeholder metnini seçer; çoğu zaman yalnız rengini değiştirirsin ve contrast'ın yeterli kalmasına dikkat edersin. ::file-selector-button, <input type="file">'ın içindeki butonu stillemeye izin verir ve 2023'ten beri Baseline. ::first-line bir bloğun ilk satırını seçer ve satırın genişliği değişince seçilen metin de değişir; serinin ikinci yazısındaki ::first-letter'ın kardeşi. ::backdrop, top layer'daki bir dialog'un arkasındaki katmanı seçer; top layer sitedeki CSS serisinin (CSS3: Flexbox, Grid ve Modern CSS) 04. yazısında, <dialog> ise sitedeki HTML serisinin (HTML5 ve Semantic HTML) 06. yazısında.

::selection, kullanıcının seçtiği metnin rengini değiştirir. Masaüstü tarayıcıların hepsinde çalışır, ama iOS Safari desteklemediği için web-features'ta "Baseline değil". Desteklenmediği yerde sistemin kendi seçim rengi kalır; bu yüzden zararsız bir iyileştirme:

/* Baseline değil (iOS Safari'de yok), olmadığı yerde zararsız */
::selection { background: rgb(29 78 216 / 0.2); }

Ölçümün son satırı: h1'in ::selection arka planı iki motorda da rgba(29, 78, 216, 0.2).

Üretimde Dikkat

Üretilen içerik çeviri araçlarına, sayfa içi aramaya ve kopyalamaya girmez; Bookstore çok dilli olursa content içindeki "Completed:" gibi metinler çevrilmeden kalır. Görünen ve okunan metinleri HTML'de tut, CSS'te yalnız süs ve sayı üret. Bu kuralın istisnası alternatif metin: süs bir işareti anlamlı bir kelimeye çevirmek için kullanılır ve o kelime de dil dosyalarından gelemez. Çok dilli bir projede bunu bir custom property ile vermek bir çözüm (content: "✓" / var(--label-completed)); tema ve custom property'ler CSS3 serisinin 10. yazısında.

Counter'lar sayfa genelinde de çalışır: bir makalede başlıkları "1.", "1.1." diye numaralamak için h2'de counter-increment, h3'te counters() yeterli. Yazdırma stilleri bu serinin son yazısının konusu.

Sık Düşülen Tuzaklar

Birincisi, content yazmayı unutmak. Pseudo-element'in bütün diğer property'leri doğru olsa da kutu oluşmaz.

İkincisi, content ile anlamlı bilgi vermek. Fiyat, uyarı ya da durum bilgisi kopyalanmaz, aranmaz ve bazı yardımcı teknolojilerde okunmaz; bilgi HTML'de olmalı.

Üçüncüsü, süs amaçlı bir işaretin screen reader'da okunduğunu fark etmemek. content: "★" / "" ile alternatif metni boş bırak.

Dördüncüsü, img ya da input'a ::before eklemeye çalışmak. Replaced element'lerde pseudo-element çizilmez; bir sarmalayıcı kullan.

Beşincisi, list-style: none ile listenin semantiğini kaybetmek. İşaretsiz ama liste olarak bilinmesi gereken listelere role="list" ekle.

Baseline Durumu

node scripts/baseline.mjs before-after alt-text-generated-content counters counter-set counter-style list-style marker placeholder selection file-selector-button first-line
before-after               widely 2018-01-29  Chrome 1, Firefox 1.5, Safari 4, iOS 3.2
alt-text-generated-content newly 2024-07-09   Chrome 77, Firefox 128, Safari 17.4, iOS 17.4
counters                   widely 2018-01-29  Chrome 2, Firefox 1, Safari 3, iOS 1
counter-set                widely 2026-06-11  Chrome 85, Firefox 68, Safari 17.2, iOS 17.2
counter-style              widely 2026-03-18  Chrome 91, Firefox 33, Safari 17, iOS 17
list-style                 widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
marker                     not Baseline       Chrome 86, Firefox 80, Safari -, iOS -
placeholder                widely 2022-07-15  Chrome 57, Firefox 51, Safari 10.1, iOS 10.3
selection                  not Baseline       Chrome 1, Firefox 62, Safari 1.1, iOS -
file-selector-button       widely 2023-10-26  Chrome 89, Firefox 82, Safari 14.1, iOS 14.5
first-line                 widely 2018-01-29  Chrome 1, Firefox 1, Safari 1, iOS 1
ÖzellikDurumNot
::before, ::after, content, counter'larWidely availablecontent olmadan kutu oluşmaz
Alternatif metin (content: "★" / "")Newly available (2024-07)Safari 17.4, Firefox 128
counter-set, @counter-styleWidely available2026-06 ve 2026-03'te widely
list-style-type stringDesteği yaygınbrowser-compat-data: Chrome 79, Firefox 39, Safari 14.1
::markerBaseline değilSafari'de yalnız color ve font-size
::selectionBaseline değiliOS Safari'de yok; zararsız iyileştirme
::placeholder, ::file-selector-button, ::first-lineWidely availablePlaceholder'da contrast'a dikkat

Küçük Bir Deneme

★☆☆ Lab'deki #badge-plain'in yıldızını, screen reader'a "Featured" diye okunacak bir alternatif metinle yaz ve 10-generated.mjs'in yazdırdığı erişilebilir adı kontrol et.

★★☆ Checkout adımlarına adımlar arasında bir çizgi ekle (li + li::before değil, mevcut ::before'u bozmadan ::after ile) ve 10-pages.mjs'in accessibility tree satırının değişmediğini doğrula.

★★★ "My reading list"i ol'a çevir, numaraları @counter-style ile "Book 1, Book 2" biçiminde yazdır (prefix descriptor'ü) ve işaretin rengini ::marker ile vurgu rengine çek; iki motorda da metnin nereden başladığını ölç.

Bir sonraki yazıda overflow ve scroll davranışına geçiyoruz: overflow: clip ile hidden farkı, "Recommended" şeridini JavaScript'siz kaydıran scroll snap, sticky header altında kalan anchor hedefleri için scroll-padding, overscroll-behavior ve scrollbar stilleri.