Lesson 20 of 25

CSS Pseudo-elements

Elements You Never Wrote

A pseudo-element styles a part of an element that does not exist as a tag in your HTML — the first letter of a paragraph, the bullet of a list item, the highlighted text a user has selected — or invents an entirely new box inside an element. They are written with two colons, which is how you tell them apart from pseudo-classes at a glance.

::before and ::after are the two you will use most. Despite their names they do not sit before or after the element; they are inserted inside it, as its first and last child. So .card::before creates a box at the very start of the card's content, and it inherits from the card like any real child would.

They have one absolute requirement: the content property. Without it the pseudo-element is not generated at all, and this is the single most common reason a first attempt shows nothing. For purely decorative shapes — a coloured bar, a circle, an underline — the value is an empty string, content: "", which is enough to bring the box into existence so you can size and colour it.

One structural limit follows from them being children: elements that cannot have children cannot have them. <img>, <input>, <br> and <hr> are replaced or empty elements, so input::after does nothing. The usual workaround is to put the pseudo-element on a wrapper or on the neighbouring label instead.

Example
/* Nothing renders — content is missing */
.card::before { width: 40px; height: 4px; background: #d1039e; }

/* Works */
.card::before {
  content: "";              /* required, even when empty */
  display: block;           /* they are inline by default */
  width: 40px;
  height: 4px;
  background: #d1039e;
  margin-bottom: 12px;
}

/* They are children, so they sit INSIDE the element */
.badge::after {
  content: "";
  position: absolute;
  top: -4px;
  right: -4px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #c0392b;
}
.badge { position: relative; }   /* the anchor, as always */

/* Does nothing — an input cannot have children */
input::after { content: "*"; }
  • Two colons: ::before, not :before (the old one-colon form still works but is legacy)
  • content is mandatory — no content, no pseudo-element
  • They are inserted as the first and last child, inside the element
  • They are inline by default; set display: block or inline-block to give them a size
  • They cannot be added to <img>, <input>, <br> or <hr>
  • Only one ::before and one ::after per element

What content Can Hold

content takes more than a plain string. It can pull a value out of an attribute with attr(), which is how a tooltip reads its own text from data-tip without any duplication. It can hold an escaped character code, so a symbol goes in your CSS rather than being pasted into every heading. It can even be an image, though an <img> tag is nearly always the better choice for anything meaningful.

CSS counters let generated content do arithmetic. counter-reset starts a named counter on a container, counter-increment advances it on each item, and counter() prints it. This is how you number section headings, or produce nested numbering like 2.3 for a documentation page, entirely from CSS.

One caution matters more than any of the tricks. Generated content is not reliably announced by screen readers — support varies between browsers and assistive tools, and some users have it suppressed entirely. So never put information that only exists there. A required-field asterisk added with ::after is acceptable because the required attribute on the input carries the real meaning; a price or an error message that exists only in CSS is not, because for some visitors it simply is not there.

Example
/* A plain string */
.external::after { content: " (opens in a new tab)"; font-size: 12px; }

/* Read an attribute */
<span class="tip" data-tip="Saved automatically">?</span>

.tip::after { content: attr(data-tip); }

/* An escaped character, so the symbol lives in the CSS */
.quote::before { content: "\201C"; }   /* a left double quotation mark */

/* Counters: numbering with no numbers in the HTML */
.steps { counter-reset: step; }

.steps > li {
  counter-increment: step;
  list-style: none;
}

.steps > li::before {
  content: "Step " counter(step) ": ";
  font-weight: 600;
  color: #d1039e;
}
Notes
  • A quick test for whether generated content is acceptable: turn the stylesheet off in your head and ask whether the page still makes sense. Decoration, icons and numbering can safely disappear. Anything a person needs in order to understand or use the page belongs in the HTML.

Pseudo-elements That Style Existing Text

The second family does not create anything — it reaches into text the browser has already laid out. ::first-letter selects the opening character of a block, which is how you make a magazine-style drop cap without wrapping that letter in a span. ::first-line selects however much text fits on the first line, and it genuinely recalculates when the window is resized, so the styled portion changes as the line rewraps. Both only work on block-level containers, and both accept only a limited set of properties — font, colour, background, spacing and a few others.

::selection styles text while the user has it highlighted. Setting it to your brand colour is a small, cheap touch that makes a site feel finished. Like the others it accepts only a few properties: colour, background colour, text shadow and text decoration.

::placeholder styles the hint text inside an empty input. Keep it a distinctly lighter grey than real input text so the two are never confused — and remember that a placeholder is not a label. It disappears the moment someone starts typing, so a form that relies on placeholders alone leaves people unable to check what they have filled in. Use a real <label> and let the placeholder show format, such as an example phone number.

::marker styles the bullet or number of a list item, which used to require removing the marker and rebuilding it with ::before. Now you can simply colour it or change its content directly.

Example
/* Magazine drop cap */
.article > p:first-of-type::first-letter {
  float: left;
  font-size: 3.2em;
  line-height: 0.85;
  padding-right: 8px;
  color: #d1039e;
  font-weight: 700;
}

/* Emphasise the opening line — reflows with the window */
.lead::first-line { font-weight: 600; }

/* Brand-coloured selection */
::selection {
  background: #d1039e;
  color: #ffffff;
}

/* Placeholder styling — a hint, not a label */
input::placeholder {
  color: #9a9aa6;
  font-style: italic;
}

/* Bullets and numbers */
li::marker {
  color: #d1039e;
  font-weight: 700;
}
  • ::first-letter — the opening character of a block; block containers only
  • ::first-line — whatever fits on the first line, recalculated on resize
  • ::selection — highlighted text; only a few properties apply
  • ::placeholder — hint text in an empty field; never a substitute for a label
  • ::marker — the bullet or number of a list item

Patterns Worth Keeping

A few pseudo-element patterns turn up in real projects again and again, and each one replaces markup you would otherwise have to write and maintain.

The animated underline is the most-used. Put an ::after under a link as a one-pixel bar, scale it to zero horizontally, and grow it back on hover. Because it animates transform rather than width, it stays smooth, and setting transform-origin makes it sweep in from the left and out to the right.

The decorative quote mark puts a large, faded quotation glyph behind a testimonial without adding a single element to the HTML. The required-field asterisk generates the marker from the input's own required attribute, so it can never fall out of sync with the form's actual rules. And a clearfix — an ::after with content: "" and display: table — is the historic fix for a container collapsing around floated children. You are unlikely to need it in a new project built with flexbox or grid, but you will meet it in older code and it is useful to recognise.

The common thread is worth naming: pseudo-elements are for things that are purely presentational. If the HTML would be poorer without it, it is content and belongs in the markup. If the HTML does not miss it at all, a pseudo-element keeps your document clean.

Example
/* Animated underline */
.link {
  position: relative;
  text-decoration: none;
}

.link::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -2px;
  width: 100%;
  height: 2px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.25s ease-out;
}

.link:hover::after,
.link:focus-visible::after {
  transform: scaleX(1);
  transform-origin: left;
}

/* Decorative quotation mark behind a testimonial */
.testimonial { position: relative; padding-left: 48px; }

.testimonial::before {
  content: "\201C";
  position: absolute;
  left: 0;
  top: -12px;
  font-size: 72px;
  line-height: 1;
  color: #d1039e;
  opacity: 0.25;
}

/* Asterisk generated from the input's own attribute */
.field:has(input:required) .field__label::after {
  content: " *";
  color: #c0392b;
}

/* Legacy clearfix — you will meet this in older stylesheets */
.clearfix::after {
  content: "";
  display: table;
  clear: both;
}
Notes
  • Pseudo-elements can be transitioned and animated exactly like real elements, and they are separate from their parent for that purpose — so a hover on the parent can move the ::after while the parent itself stays perfectly still.
Ask AI