Quick Answer

::before and ::after generate a box that becomes the first or last child inside the element, and neither one renders unless the content property is set, even to an empty string. They are inline by default, they inherit from their parent, and they cannot be applied to replaced elements such as img, input or iframe. Use them for decoration, counters, custom bullets and tooltips, and keep anything a screen reader must announce in real markup instead.

No content, no element

This is the whole reason the article exists. A pseudo-element with no content property does not get created. Not invisible, not zero-sized: it never exists, so every other declaration in the rule is styling nothing.

/* renders nothing at all */
.badge::after {
  width: 8px;
  height: 8px;
  background: #16a34a;
}

/* renders */
.badge::after {
  content: "";
  display: block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #16a34a;
}

The initial value of content on a pseudo-element is normal, which computes to none there, and none means no box is generated. An empty string is a real value, so content: "" creates the box. That one line is the difference between a working rule and a rule that silently does nothing, which is why it belongs at the top of every pseudo-element rule you write, before the styling.

The property takes more than strings:

.chip::before   { content: attr(data-count); }        /* an attribute value */
.note::before   { content: "\2713 "; }                 /* a unicode escape */
.ext::after     { content: " " url("/icon.svg"); }     /* an image */
.step::before   { content: "Step " counter(step) ": "; }

Note the escape syntax. Inside a CSS string, a unicode character is written as a backslash followed by the hex code point, with a trailing space to end the escape. That is a different syntax from the HTML entity you would write in markup.

One historical note that causes needless worry. The double colon ::before is the modern syntax that distinguishes pseudo-elements from pseudo-classes like :hover. The single-colon form :before is the old syntax, and browsers still accept it for the four original pseudo-elements: before, after, first-line and first-letter. Write the double colon in new code; do not go on a hunt to replace single colons in old code, because both work.

Where the box actually sits

Think of ::before as an extra first child inside the element and ::after as an extra last child. Not before and after the element itself, which is what the names suggest to everyone at first. They are inside it, wrapping its content.

<button class="btn">Save</button>
.btn::before { content: "["; }
.btn::after  { content: "]"; }

/* renders as:  [Save]  */

Three consequences follow from being a child.

They inherit. Font, colour, line height and any custom properties come from the parent element, so a pseudo-element usually looks like the text around it without you doing anything.

They are inline by default. That means width and height do nothing until you change the display type. Any decorative shape needs display: block, display: inline-block, or position: absolute, which makes it block-level automatically.

They live inside the parent's box. If the parent has overflow: hidden, an absolutely positioned pseudo-element sticking out gets clipped. To position one, the usual setup is position: relative on the parent and position: absolute on the pseudo-element.

.tag {
  position: relative;
  padding-left: 18px;
}

.tag::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  width: 8px;
  height: 8px;
  margin-top: -4px;
  border-radius: 50%;
  background: currentColor;
}

The last thing to internalise is that they are not in the DOM. querySelector(".btn::after") will not hand you the pseudo-element, you cannot attach an event listener to one, and JavaScript cannot change its content directly. The only way to read one is getComputedStyle(el, "::after"). To change one from JavaScript, set a custom property or a data attribute on the real element and have the pseudo-element read it.

Elements they will never work on

You will eventually write img::after and spend twenty minutes wondering why nothing appears. It is not your CSS.

Pseudo-elements are generated inside an element's content box. Replaced elements do not have content the browser lays out; their content comes from outside CSS entirely, so there is nowhere for the generated box to go. The list includes <img>, <input>, <iframe>, <video>, <embed> and <object>. Form widgets such as <select> and <textarea> are not replaced elements in the strict sense, but they behave the same way here, and <br> has no content box for a generated element to sit in either.

Behaviour on these is inconsistent between browsers rather than uniformly nothing. Some browsers render a pseudo-element on an image whose source fails to load, which is why an apparently working rule stops working the moment the image is fixed. Do not build on that.

The fix in every case is to put the pseudo-element on a wrapper.

<span class="avatar">
  <img src="/user.jpg" alt="">
</span>
.avatar { position: relative; display: inline-block; }

.avatar::after {
  content: "";
  position: absolute;
  right: 2px;
  bottom: 2px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #16a34a;
  border: 2px solid #ffffff;
}

Checkboxes and radios are the same story, which is exactly why the standard custom-control pattern hides the real input and styles a <label> next to it. The input keeps all the keyboard behaviour and accessibility; the label carries the pseudo-elements.

.check input { position: absolute; opacity: 0; }

.check label::before {
  content: "";
  display: inline-block;
  width: 16px;
  height: 16px;
  border: 2px solid #5b6472;
  border-radius: 4px;
  vertical-align: -3px;
  margin-right: 8px;
}

.check input:checked + label::before {
  background: #0b5fff;
  border-color: #0b5fff;
}

Note opacity: 0 with position: absolute rather than display: none. A hidden input is removed from the accessibility tree and cannot receive keyboard focus, which breaks the control for anyone not using a mouse.

Patterns worth knowing

Required-field markers. Decoration driven by state, with no extra markup in the template.

.field.is-required label::after {
  content: " *";
  color: #dc2626;
}

Custom list bullets. Removes the default marker and gives you full control over the shape, colour and alignment.

.checklist li {
  list-style: none;
  position: relative;
  padding-left: 1.6em;
}

.checklist li::before {
  content: "\2713";
  position: absolute;
  left: 0;
  color: #16a34a;
  font-weight: 700;
}

Numbered steps with counters. CSS counters let the pseudo-element carry a running number, so inserting a step in the middle renumbers everything automatically.

.steps { counter-reset: step; list-style: none; padding-left: 0; }

.steps li {
  counter-increment: step;
  padding-left: 2.2em;
  position: relative;
}

.steps li::before {
  content: counter(step);
  position: absolute;
  left: 0;
  width: 1.6em;
  height: 1.6em;
  line-height: 1.6em;
  text-align: center;
  border-radius: 50%;
  background: #0b5fff;
  color: #ffffff;
  font-size: 0.85em;
}

Shapes from borders. A zero-sized box with transparent borders and one coloured side produces a triangle. It looks like a trick, but it is just how borders meet at a mitre.

.arrow::after {
  content: "";
  display: block;
  width: 0;
  height: 0;
  border: 6px solid transparent;
  border-top-color: #14161a;   /* points downwards */
}

The clearfix. Worth recognising in old code even though flexbox and grid have made it mostly unnecessary.

.clearfix::after { content: ""; display: block; clear: both; }

Content from attributes. attr() is reliable for the content property, and this is how a pseudo-element gets a value that varies per element without any JavaScript.

.file::after { content: " (" attr(data-size) ")"; color: #5b6472; }

Using attr() in other properties, such as feeding a number into width, is a newer capability with uneven support. Assume content only unless you have checked.

A tooltip from both pseudo-elements

Putting it together: ::after is the bubble, ::before is the arrow, and the text comes from a data attribute, so a single rule serves every tooltip on the page.

<button class="tip" data-tip="Saved to your Pune campus profile">Save</button>
.tip { position: relative; }

.tip::after {
  content: attr(data-tip);
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
  background: #14161a;
  color: #ffffff;
  padding: 6px 10px;
  border-radius: 6px;
  font-size: 13px;
  line-height: 1.3;
  opacity: 0;
  pointer-events: none;
  transition: opacity 150ms ease;
}

.tip::before {
  content: "";
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 5px solid transparent;
  border-top-color: #14161a;
  opacity: 0;
  pointer-events: none;
  transition: opacity 150ms ease;
}

.tip:hover::after,
.tip:hover::before,
.tip:focus-visible::after,
.tip:focus-visible::before {
  opacity: 1;
}

The parts that are doing real work:

  • pointer-events: none stops the bubble intercepting clicks meant for whatever is underneath it, and stops it flickering when the pointer crosses its own edge.
  • Toggling opacity rather than display is what allows the fade. A display change has nothing to interpolate.
  • :focus-visible is included so a keyboard user reaching the button by Tab sees the same tooltip a mouse user sees.
  • white-space: nowrap keeps short labels on one line. Remove it and add a max-width for longer text.

Two limits to be honest about. Any ancestor with overflow set to hidden, auto or scroll will clip the bubble, and a pseudo-element has no way to break out of a clipping ancestor, so tooltips near the edge of a scrolling container are a real problem this pattern cannot solve. The browser's popover attribute and the top layer exist precisely for that.

The bigger one is accessibility. Support for announcing generated content is inconsistent across screen readers, so never let a piece of information exist only inside content. If the tooltip text matters, give the button an aria-label or wire up a real element with aria-describedby, and treat the pseudo-element purely as the visual layer.

Frequently Asked Questions

Why is my ::after not showing up? In almost every case the content property is missing. Without it the pseudo-element is never generated, so width, background and everything else apply to a box that does not exist. Add content: "" as the first declaration. If it still does not appear, check that the element is not a replaced element such as img or input, and that it is not inline with only a width and height set.
What is the difference between :before and ::before? Only the syntax. The double colon is the modern form that distinguishes pseudo-elements, which generate a box, from pseudo-classes like :hover, which match a state. Browsers still accept the single colon for before, after, first-line and first-letter for backward compatibility. Write the double colon in new code and do not bother rewriting old code that uses one.
Can I use ::before on an image or an input? No, not reliably. Replaced elements get their content from outside CSS, so there is no content box for a generated element to sit in. Some browsers render something on an image with a broken source, which makes the rule look like it works until the image loads correctly. Wrap the element in a span or div and put the pseudo-element on the wrapper instead.
Can JavaScript change the text of a pseudo-element? Not directly, because pseudo-elements are not in the DOM and cannot be selected or given event listeners. The standard workaround is to write to a data attribute or a custom property on the real element and have the pseudo-element read it, using content: attr(data-label) or a var() in one of its properties. Reading a computed value is possible with getComputedStyle(element, '::after').
Do screen readers read content added with ::before? Support is inconsistent, and it varies by screen reader and browser combination. Some announce generated text, some ignore it, and some read decorative unicode characters aloud in a confusing way. Treat generated content as decoration only. Anything a user genuinely needs should live in real markup or be exposed through aria-label or aria-describedby.