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/::afterto 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
::beforeor::afterrule matches it. If the resolvedcontentvalue is notnone, 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 indocument.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
displayit’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-letterworks 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, includingfloat.::selectionis 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).::placeholderand::markerhook 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
contentproperty (evencontent: \"\";) 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
displayisinlineunless you override it. - Keep
::before/::aftercontent decorative rather than essential; screen reader support for generated content is inconsistent, so never hide meaningful information there. - Style
::selectionconservatively — keep enough contrast between your background and text color that highlighted text stays readable. - Prefer
::markerto restyle list bullets/numbers instead of settinglist-style: noneand 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
::beforeand::afteradd 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
::placeholderto make its placeholder text italic and light gray, and use::afteron 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::selectionhighlight 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. ::beforeand::aftergenerate real, styleable boxes in the render tree, but only when acontentvalue is present — they never appear in the DOM.::first-lineand::first-letterformat existing content the engine identifies after layout;::first-linesupports far fewer properties than::first-letter.::selection,::placeholder, and::markertarget 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/::afterwith an explicitcontentvalue.
