Mastering the ::before and ::after Pseudo-Elements in CSS
::before and ::after insert generated content before or after an element's actual content, enabling decoration without extra HTML elements.
The Wrong Assumption About Pseudo-Elements
The common wrong assumption is that `::before` and `::after` are a shortcut for adding text or icons into the DOM. They are not. These CSS features generate a box that is a child of the element's own box, but that box is not a DOM node. It does not appear in the accessibility tree in any reliable way. It cannot be focused and it cannot hold interactive children. What they are is a purely visual layer that sits inside the element's formatting context, styled by the same cascade as everything else on the page. The real truth: these generated boxes are the declarative replacement for the old habit of littering HTML with empty spans and divs whose only job was to provide a hook for a background image, a triangle, a line, or a decorative shape. You declare the content, you declare the box, and the engine computes the layout. That is the entire point.
Generated Content CSS: What the content Property Actually Does
Generated content CSS means the text or shape that appears only because the `content` property declares it. Without that property, the pseudo-element does not generate a box at all; it is not rendered and takes no space. The `content` property accepts strings, counters, attribute values, and images, but the most common use is a quoted string or an empty string with an explicit width and height. For a decorative shape, you write content: ""; and then style the box itself.
The essential rule: if you omit `content`, the pseudo-element is dead. This is the first thing to check when a `::before` or `::after` rule appears to do nothing. The second thing to check is whether you applied it to a replaced element. Per the CSS Pseudo-Elements Module Level 4 specification, these generated boxes do not apply to replaced elements such as <img>, <input>, <textarea>, or <select>. Those elements have intrinsic rendering that the pseudo-element cannot wrap. Attempting to use them there silently fails in all major engines.
What the Empty String Means for Layout
An empty string with display: block or display: inline-block and explicit dimensions is the standard way to create a purely decorative box. The box participates in the normal flow, so it can be positioned, sized, and transformed. That is what makes it a practical substitute for an empty <div> that previously existed only to hold a background image.
Decorative Pseudo-Element CSS: Creating Shapes Without Extra Markup
The classic case is a speech-bubble tail, a ribbon fold, or a checkmark that sits at the edge of a card. Before `::before` and `::after` became baseline, you would add <span class="tail"></span> inside the card, give it a border triangle, and then hide the span from screen readers with aria-hidden="true". That markup is now unnecessary. The generated box carries the same visual role without touching the DOM.
Here is a complete example that builds a downward-pointing arrow on a button. The arrow is a pure CSS triangle made from borders, and it exists only as the `::after` pseudo-element.
.button-with-arrow {
position: relative;
padding: 0.75rem 1.5rem;
background: #2563eb;
color: #fff;
border: 0;
border-radius: 0.25rem;
}
.button-with-arrow::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-top-color: #2563eb;
}
That block of CSS creates a visible arrow that sits below the button. The HTML remains a single button element with no child elements. The arrow is not in the DOM, so a screen reader will not announce it. That is acceptable here because the arrow is purely decorative; the button text carries the meaning. If the arrow conveyed information, for example, if it indicated a dropdown, you would need to add that state to the button's accessible name or use a separate visually hidden text node.
Both `::before` and `::after` are Baseline widely available across all major engines (Blink, WebKit, Gecko) with the double-colon syntax and no prefix. The single-colon syntax :before and :after remains supported for backward compatibility with IE8. Check caniuse for the latest support data if you need to target a specific older release.
::before vs ::after Use Cases: Which One Do You Need?
The distinction is positional: `::before` renders as the first child of the element's box, and `::after` renders as the last child. In horizontal writing mode, that means the former appears before the element's own content and the latter appears after it. This is the entire difference. If you want a leading icon or a badge that sits to the left, use `::before`. If you want a trailing line, a caret, or a decorative underline that extends past the text, use `::after`.
For a concrete decision rule: ask what the visual element is adjacent to. If it touches the left or top edge of the element's content, choose `::before`. If it touches the right or bottom edge, choose `::after`. This is not a performance decision; both create the same box type, and both have the same specificity of (0,0,1), which is a single class-level selector. The choice is purely about the visual order in the inline direction.
Here is a second example that places a checkmark before a list item. The checkmark is a unicode character, and the generated box is `::before`.
.checked-item {
list-style: none;
padding-left: 1.5rem;
position: relative;
}
.checked-item::before {
content: "✔";
position: absolute;
left: 0;
color: #16a34a;
font-weight: bold;
}
In this case, the checkmark is absolute positioned, so it does not affect the flow of the list item's text. The padding-left creates space for it. The checkmark is decorative, not semantic. If the list item's meaning depended on the checkmark, you would need to put the text "checked" into the item's accessible name or use a visually hidden span.
The Accessibility Warning: Pseudo-Element Content and Screen Readers
The critical warning is about the accessibility tree. Content inserted via `::before` or `::after` is not a DOM node, and whether a screen reader announces it depends on the engine and the assistive technology version. The specification defines behaviour, but interop is inconsistent. Some engines expose the text of these generated boxes to the accessibility tree. Others do not. The safest assumption is that essential meaning must never live only in a pseudo-element.
Place a word like "Sale" inside a `::before`, and a screen reader user may never hear it. The WCAG 1.1.1 success criterion requires text alternatives for non-text content, and it also requires that meaningful text is available through the accessibility tree. Pseudo-element content is not reliably available. The correct pattern: put the meaningful text in the HTML, hide it visually with a standard visually-hidden class, and use the generated box only for the visual decoration. This is a legal consideration in jurisdictions that adopt WCAG 2.2 into law, such as Section 508 in the United States and EN 301 549 in the European Union, but the technique itself is universal.
Another failure mode is using pseudo-elements for interactive content. You cannot put a link or a button inside one. The content is not focusable, it is not part of the tab order, and it will not receive keyboard events. Any attempt to create clickable pseudo-element content fails the page's accessibility requirements and breaks the user experience for keyboard-only users.
Common Mistakes and How to Avoid Them
The first mistake is omitting `content`. This is the number one reason a pseudo-element does not appear. Always declare `content`, even if it is an empty string.
The second mistake is applying the pseudo-element to a replaced element. <img>, <video>, <iframe>, and form controls like <input> and <textarea> do not support `::before` or `::after`. The specification says they do not apply. The engine ignores the rule.
The third mistake is using pseudo-elements for interactive or essential content. The content is not in the DOM, so it is not exposed to assistive technology consistently, and it cannot be focused.
A fourth mistake is forgetting that the pseudo-element box is a child of the element's box. This means it participates in the same stacking context and containing block. Position it absolutely, and it will position relative to the nearest positioned ancestor, which may be the element itself or a further ancestor depending on your position rules. If a pseudo-element is not where you expect, check the positioning context first.
A fifth mistake is using pseudo-elements to create layout that should be a real element. If the content is a heading, a paragraph, or a list item, it belongs in the HTML. The pseudo-element is for decoration, not for semantics.
Frequently Asked Questions
Why is my ::before pseudo-element not showing up?
The most common cause is a missing `content` property. Without a declared value, the pseudo-element generates no box. Check that you wrote content: ""; or a string value. Second, verify the element you applied it to is not a replaced element; these generated boxes do not apply there.
Can I use ::before on an img element?
No. Per the CSS Pseudo-Elements Module Level 4 specification, replaced elements such as <img>, <video>, and form controls do not support `::before` or `::after`. The browser ignores the rule.
Do ::before and ::after have the same specificity?
Yes. Both are pseudo-elements with a specificity of (0,0,1), which is equivalent to a single class selector. This matters when you have competing rules; the cascade resolves by source order and other factors after specificity is equal.
Is pseudo-element content accessible to screen readers?
Not reliably. Some engines expose it to the accessibility tree, but interop is inconsistent. Do not rely on pseudo-element content to convey essential meaning. Put that text in the HTML and hide it visually if necessary.
Comparing Pseudo-Elements to the Old Techniques
To make the replacement concrete, here is a comparison of the three approaches to adding a decorative icon to a button. The axes are markup burden, accessibility, and styling flexibility.
| Technique | HTML changes | Accessibility exposure | Styling range |
|---|---|---|---|
| Empty span in markup | Adds a child element that requires an aria-hidden attribute | Hidden explicitly, but adds noise to the DOM | Full CSS on a real element |
| Background image on the element | None, but the image is tied to the element's own background | Not exposed, purely decorative | Limited to background positioning and sizing |
| Pseudo-element with content | None; the box is generated by CSS | Exposure varies by engine, so use for decoration only | Full CSS on the pseudo-element, including transforms and borders |
The pseudo-element wins when the decoration needs to be separate from the element's background, because it creates an independent box. It loses when the content is semantic, because it is not in the DOM. The old technique of adding a span still works, but it is now unnecessary for purely visual decoration.
Specificity and the Cascade: Why (0,0,1) Matters in Practice
Both `::before` and `::after` have a specificity of (0,0,1). This is a single class-level selector. A rule like .card::before has the same specificity as a rule like .icon. Write two rules that target the same pseudo-element, and the one that appears later in the stylesheet wins, unless a higher-specificity selector is involved. This is a common source of confusion when you try to override a pseudo-element from a framework or a component style.
Suppose a card component sets a border on .card::before. Later, you want to change that border on a specific card. You write .card.special::before, which has specificity (0,2,1) because it has two classes and one pseudo-element. That will win. But write .special::before, which is (0,1,1), and it will lose to the component's rule if the component rule appears later in source order. The cascade is not about how many words you use; it is about the specific triple of counts, and pseudo-elements count as the third value.
This matters in real projects because pseudo-elements are often used for component-level decorations. A button's `::after` might be defined in a library. To override it, you need to know the library's specificity and either match it or exceed it. The practical advice: write your overriding rule with the same class count plus an extra class or an ID if necessary.
Stacking Contexts, Containing Blocks, and Pseudo-Element Positioning
When you absolutely position a pseudo-element, it uses the nearest positioned ancestor as its containing block. That ancestor is usually the element itself, if you have set position: relative on it. If you have not, the containing block may be a further ancestor, which is a common cause of misplacement.
Pseudo-elements also participate in the stacking context of their parent element. If the parent has a transform, opacity, or filter applied, it creates a new stacking context, and the pseudo-element is inside it. A pseudo-element with a negative z-index will not appear behind the parent's background in that case; it will be behind the parent's content but still within the parent's stacking context. If you need a pseudo-element to sit behind the parent's text but above the page background, ensure the parent does not create a stacking context unintentionally.
The containing block and the stacking context are two separate concepts. The containing block determines the coordinate system for top, left, right, and bottom. The stacking context determines the paint order relative to other elements. Both are inherited from the parent's computed style, and both are affected by properties like position, transform, and opacity.
The Honest Caveat: When Not to Use Pseudo-Elements
The honest caveat: pseudo-elements are not a universal replacement for all decorative markup. They fail when the decoration is interactive, when it carries semantic meaning, or when it needs to be styled independently of the element's content flow in a way that the pseudo-element's box cannot support. They also fail on replaced elements, which is a hard constraint, not a workaround.
If you find yourself writing CSS that depends on pseudo-element content being read by a screen reader, stop. That is a bug waiting to happen. If you find yourself using a pseudo-element to create something that is genuinely a new piece of content, a caption, a label, a warning, put that content in the HTML. The pseudo-element is a visual layer, not a content layer.