CSS Pseudo-Elements

A pseudo-element lets you style a specific part of an element, or insert generated content next to it, without adding any extra markup to your HTML. Instead of wrapping the first letter of a paragraph in a <span> just so you can style it, or adding an empty <div> to hold a decorative icon, you let CSS create or target that piece for you. Pseudo-elements are written with a double colon (::before) to distinguish them from pseudo-classes (:hover), and mastering them is essential for building polished UI details — badges, drop caps, custom bullets, tooltips, and more — while keeping your markup clean and semantic.

Overview: How Pseudo-Elements Work

A pseudo-element represents a part of an element that does not exist as its own node in the DOM, but that the browser’s rendering engine treats as if it were a real child when it builds the render tree. Take ::before and ::after: when you write .card::before { content: \"NEW\"; }, the engine generates an anonymous box, inserts it as the first child inside .card‘s content (for ::before) or as the last child (for ::after), and then lays it out using the exact same box model and layout rules as any real element — it gets its own padding, border, margin, background, and can participate in flex or grid layout. Crucially, this generated box only appears if the content property is set; without it, the engine creates nothing to render.

Not every pseudo-element generates a brand-new box, though. ::first-line and ::first-letter instead let you reach into content that already exists and format a sub-part of it that the layout engine identifies for you. Because line-wrapping depends on the element’s width, font, and content, the browser can only know what counts as the \”first line\” after it has performed layout — so it lays the block out, finds which text occupies the first formatted line, and applies your declared (limited) styles to that portion. This recalculates automatically whenever the layout changes, such as on window resize or font load.

Other pseudo-elements target very different things entirely: ::selection matches whatever text the user has highlighted with their mouse or keyboard and is repainted live as the selection changes; ::placeholder targets the placeholder text inside a form field; ::marker targets the bullet or number box of a list item; and ::backdrop targets the semi-transparent layer the browser paints behind a <dialog> or an element in fullscreen mode. In terms of the cascade, a pseudo-element carries the same specificity weight as a type selector — (0,0,0,1), identical to an element selector like div.

Single colon vs. double colon

CSS2 originally defined :before, :after, :first-line, and :first-letter with a single colon, the same syntax used for pseudo-classes. CSS3 introduced the double-colon syntax specifically to separate pseudo-elements (parts of an element) from pseudo-classes (states of an element). Browsers still accept the single-colon form for those original four for backward compatibility, but every pseudo-element introduced since — ::selection, ::placeholder, ::marker, ::backdrop — only works with the double colon. Always write the double colon; it is unambiguous and future-proof.

Syntax

selector::pseudo-element {\n  property: value;\n}
  • selector — any valid selector the pseudo-element attaches to, e.g. p, .card, nav a.
  • ::pseudo-element — the double-colon name identifying which part is targeted.
  • content — required for ::before/::after to generate a visible box; can be a string, attr() value, counter, image, or empty string \"\".
Pseudo-element Targets Requires content?
::before generated box inserted before an element’s content yes
::after generated box inserted after an element’s content yes
::first-letter the first typographic character of a block no
::first-line the first formatted line of a block no
::selection the portion of text currently highlighted by the user no
::placeholder placeholder text inside an <input> or <textarea> no
::marker the bullet/number box of a list item or <summary> no
::backdrop the layer behind a <dialog> or fullscreen element no

Examples

Example 1: A decorative prefix icon with ::before

nav a::before {\n  content: \"\u2192 \";\n  color: #0a84ff;\n  font-weight: bold;\n}

This applies to markup like <nav><a href=\"#\">Docs</a></nav>. Result: before the text of every link inside the <nav>, the browser inserts a bold blue right-pointing arrow character followed by a space, rendered inline as if it were part of the link’s own text — but it exists only in the render tree, not in the DOM, so JavaScript’s querySelector will never find it.

Example 2: A drop cap with ::first-letter and ::first-line

p.intro::first-line {\n  font-weight: 600;\n  letter-spacing: 0.02em;\n}\n\np.intro::first-letter {\n  font-size: 3rem;\n  line-height: 1;\n  float: left;\n  padding-right: 0.15em;\n  color: #b1361e;\n  font-family: Georgia, serif;\n}

Applied to a paragraph such as <p class=\"intro\">Lorem ipsum dolor sit amet...</p>. Result: the entire first line of the paragraph renders slightly bolder with extra letter spacing, while the very first character is enlarged to 3rem, colored dark red, set in a serif font, and floated left so the rest of the paragraph’s text wraps around it — a classic magazine-style drop cap, achieved with zero extra markup.

Example 3: A badge, custom text selection, and styled placeholder

.card {\n  position: relative;\n  max-width: 320px;\n  padding: 1.5rem;\n  border: 1px solid #ddd;\n  border-radius: 8px;\n}\n\n.card--new::before {\n  content: \"NEW\";\n  position: absolute;\n  top: -10px;\n  right: -10px;\n  background: #ff5722;\n  color: #fff;\n  font-size: 0.7rem;\n  font-weight: bold;\n  padding: 0.25em 0.6em;\n  border-radius: 999px;\n  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);\n}\n\n::selection {\n  background-color: #ffe066;\n  color: #1a1a1a;\n}\n\n.card input::placeholder {\n  color: #999;\n  font-style: italic;\n}

Applied to <div class=\"card card--new\"><h3>Plan Name</h3><input type=\"text\" placeholder=\"Enter promo code\"></div>. Result: an orange, pill-shaped \”NEW\” badge appears fixed at the top-right corner of the card, overlapping its border slightly since it’s positioned absolutely relative to the card. Anywhere on the page where the user drags to select text, the highlighted portion shows a pale yellow background with dark text instead of the browser’s default blue. Inside the card’s input, the words \”Enter promo code\” appear in italic gray until the user starts typing, at which point the placeholder disappears as usual.

How It Works Step by Step

  • The browser parses HTML into the DOM and CSS into the CSSOM, exactly as with any other rule.
  • While constructing the render/layout tree, for every element the engine checks whether a ::before or ::after rule matches it. If the resolved content value is not none, an anonymous box is generated and spliced into the box tree as that element’s first or last child — this happens purely in the rendering pipeline, so the box never appears in document.querySelectorAll.
  • That generated box then goes through ordinary box-model resolution (content, padding, border, margin) and ordinary layout (inline, block, flex item, grid item — whatever display it’s given), exactly like a real element.
  • For ::first-line, the engine cannot know where the line breaks until after it performs line-breaking, since that depends on the element’s width, the font, and the text itself. Once layout completes, it identifies which run of content forms the first line box and applies your declared properties — but only a restricted subset (mostly text and color-related properties; margin, padding, and border are not reliably applied) since a line box is not a full block box.
  • ::first-letter works similarly: after layout, the engine isolates the first letter (plus any leading punctuation) into its own generated box, which — unlike ::first-line — does accept most box-model properties, including float.
  • ::selection is repainted live by the rendering engine every time the user’s selection range changes, without triggering a full layout reflow; only a small, security-conscious set of properties is allowed (color, background, text-shadow, and a few others — not layout-affecting ones).
  • ::placeholder and ::marker hook into parts of native, browser-rendered form controls and list items, and likewise only expose a curated subset of properties to avoid breaking native widget behavior.

Common Mistakes

Mistake 1: Forgetting the content property

.badge::before {\n  color: white;\n  background: red;\n}

Nothing renders at all here. Without a content declaration, the engine never generates a box for ::before in the first place — the other declarations are simply discarded because there’s no element to apply them to. The fix is to always include content, even if it’s just an empty string used purely as a styling hook:

.badge::before {\n  content: \"\";\n  display: inline-block;\n  width: 8px;\n  height: 8px;\n  margin-right: 6px;\n  background: red;\n  border-radius: 50%;\n}

Mistake 2: Using single-colon syntax on newer pseudo-elements

input:placeholder {\n  color: gray;\n}

This targets a pseudo-class named placeholder that doesn’t exist, so the rule silently matches nothing — the single-colon form is only kept alive for the four original CSS2 pseudo-elements. Every pseudo-element introduced since requires the double colon:

input::placeholder {\n  color: gray;\n}

Mistake 3: Expecting box-model properties to work on ::first-line

p::first-line {\n  margin-top: 20px;\n  border: 1px solid black;\n}

This parses fine but has no reliable visual effect: a line box generated for ::first-line isn’t a full block-level box, so margin and border are not consistently applied to it across browsers. If you want spacing above the paragraph or a bordered accent on its opening character, target the real element or use ::first-letter instead, which does support box-model properties:

p {\n  margin-top: 20px;\n}\n\np::first-letter {\n  border: 1px solid black;\n  padding: 0.1em;\n}

Best Practices

  • Always set a content property (even content: \"\";) on ::before/::after — without it, the box is never generated.
  • Use double-colon syntax everywhere, even for the four legacy pseudo-elements, for consistency and forward-compatibility.
  • Remember generated content lives in the render tree only, not the DOM — it can’t be selected with JavaScript and its default display is inline unless you override it.
  • Keep ::before/::after content decorative rather than essential; screen reader support for generated content is inconsistent, so never hide meaningful information there.
  • Style ::selection conservatively — keep enough contrast between your background and text color that highlighted text stays readable.
  • Prefer ::marker to restyle list bullets/numbers instead of setting list-style: none and faking a bullet with ::before.
  • Don’t rely on margin, padding, width, or height inside ::first-line — its box model support is intentionally limited.

Practice Exercises

  • Style a <blockquote> so that ::before and ::after add large, decorative opening and closing quotation marks positioned outside the quoted text, sized much bigger than the body copy.
  • Given a required form input, use ::placeholder to make its placeholder text italic and light gray, and use ::after on its associated <label> to append a red asterisk (content: \" *\";) indicating the field is required.
  • Add a drop-cap effect to the first paragraph of an article using ::first-letter (roughly 3rem, floated left), then give the whole page a custom ::selection highlight that matches your site’s accent color while keeping the text readable.

Summary

  • Pseudo-elements style a part of an element or insert generated content, written with a double colon (::before) to distinguish them from pseudo-classes.
  • ::before and ::after generate real, styleable boxes in the render tree, but only when a content value is present — they never appear in the DOM.
  • ::first-line and ::first-letter format existing content the engine identifies after layout; ::first-line supports far fewer properties than ::first-letter.
  • ::selection, ::placeholder, and ::marker target user interaction state, form placeholder text, and list markers respectively, each with its own restricted set of allowed properties.
  • Every pseudo-element carries the specificity of a single type selector, (0,0,0,1).
  • Prefer the double-colon syntax for all pseudo-elements, and always pair ::before/::after with an explicit content value.