CSS Selectors & Specificity CSS Pseudo-Elements

If you followed along with the last lesson on pseudo-classes, you already have a solid feel for how CSS can respond to element states and positions: :hover, :focus, :nth-child(), and their friends. That was all about targeting elements based on when or where they appear in the document.

Today we’re going one step further, and honestly, this one is more enjoyable to work with.

CSS pseudo-elements let you target a specific part of an element, not the whole thing. The first letter of a paragraph. The text area right before the element’s content. The color of highlighted text when a user selects it. All of that, styled with pure CSS, without touching your HTML at all.

That’s the real win here: you’re adding visual detail through CSS alone. No extra <span> tags. No wrapper divs. Just a selector and a few properties.


01Pseudo-Class vs Pseudo-Element: Let’s Clear This Up First

Both look similar in code, and beginners often mix them up. Here’s the simplest way to tell them apart:

The Core Difference
Pseudo-Class
Targets an element based on its state or position.
Examples: :hover, :focus, :nth-child(2)
Pseudo-Element
Targets a specific part of an element.
Examples: ::before, ::first-letter, ::selection

A pseudo-class says: “when this element is in a certain condition, style it.” A pseudo-element says: “take this specific chunk of or around the element, and style just that.”

The selectors we covered in pseudo-classes all target whole elements. The selectors we’re covering today reach into or around elements in a way that pseudo-classes simply can’t.


02The Double Colon and Where It Came From

You’ll notice pseudo-elements use a double colon (::), while pseudo-classes use a single colon (:). This distinction wasn’t always there.

In CSS2, both used a single colon. So you’d write :before and :after alongside :hover and :focus, all looking the same. CSS3 introduced the double colon specifically for pseudo-elements, to visually separate them from pseudo-classes in your code.

So :before became ::before, :first-letter became ::first-letter, and so on. The old single-colon versions still work in most browsers for backward compatibility, but the correct, modern syntax is always the double colon. Use that.


03::before and ::after: The Two You’ll Reach For Most

Of all the CSS pseudo-elements, these two are the most powerful and the ones you’ll use constantly in real projects. Let’s give them proper attention.

::before inserts a virtual element right before an element’s actual content. ::after inserts one right after it. Neither of them exists in your HTML. They’re created entirely by CSS, which is what makes them so useful for decorative effects without cluttering your markup.

How They Sit Around Your Content
::before
Element Content
::after
Pink / dashedCSS-generated, doesn’t exist in HTML
Indigo / solidYour actual element
Cyan / dashedCSS-generated, doesn’t exist in HTML

The content Property: This One Is Non-Negotiable

Here’s something that trips up almost every beginner at least once: ::before and ::after require a content property. Without it, the pseudo-element won’t appear at all, even if you give it a width, height, and background color. The browser just ignores it.

The value of content can be a few different things:

/* Empty string: pseudo-element exists, but shows no text
   You'd use this when you want to size/position it as a shape */
.box::before {
  content: "";
  display: block;
  width: 40px;
  height: 4px;
  background: #6366f1;
}

/* A text string */
.required-field::after {
  content: " *";
  color: #ff5c7a;
}

/* A symbol or Unicode character */
.arrow-link::after {
  content: " →";
}

/* Pulled dynamically from an HTML attribute */
[data-tooltip]::before {
  content: attr(data-tooltip);
}

The content: "" with an empty string is probably the most common pattern you’ll see. You’re not trying to show text. You just need the pseudo-element to exist so you can position and style it as a shape, a line, an overlay, whatever you need.

Positioning and Display: Getting Them to Behave

By default, ::before and ::after are inline. That means they flow with text. Most of the time, you’ll want to change their display type:

/* Block: takes up its own line, can have width/height */
.card::before {
  content: "";
  display: block;
  width: 100%;
  height: 3px;
  background: linear-gradient(90deg, #6366f1, #ec4899);
}

/* Absolute: positioned relative to the parent element */
.card {
  position: relative; /* Required when using position: absolute inside */
}

.card::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 50%;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.6), transparent);
  pointer-events: none;
}

/* Flex: useful for centering content inside the pseudo-element */
.badge::after {
  content: "New";
  display: flex;
  align-items: center;
  justify-content: center;
}
💡
When you use position: absolute on a ::before or ::after, the parent element must have position: relative (or absolute, fixed, or sticky). If you forget this, the pseudo-element will position itself relative to a different ancestor, usually not the result you want.

Four Real Uses You’ll Actually Build

1. A decorative section heading with lines on both sides

Live Demo: Decorative Heading

Featured Projects

h2 {
  display: flex;
  align-items: center;
  gap: 1rem;
}

h2::before,
h2::after {
  content: "";
  flex: 1;
  height: 2px;
  border-radius: 2px;
}

h2::before {
  background: linear-gradient(90deg, transparent, #6366f1);
}

h2::after {
  background: linear-gradient(90deg, #6366f1, transparent);
}

No extra HTML. Just a clean CSS trick using ::before and ::after to add lines that automatically fill the remaining space on either side of the heading text.

2. A notification count badge on a button

Live Demo: Notification Badge via ::after
.notification-btn {
  position: relative;
}

.notification-btn::after {
  content: "3";
  position: absolute;
  top: -7px;
  right: -7px;
  width: 22px;
  height: 22px;
  background: #ec4899;
  color: #fff;
  border-radius: 50%;
  font-size: 0.68rem;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}

3. A large opening quote mark before a blockquote

Live Demo: Blockquote with ::before Quotation Mark

The details are not the details. They make the design. And that, in the end, is what separates the truly great from the merely good.

blockquote {
  position: relative;
  padding: 1.5rem 1.5rem 1.5rem 3rem;
}

blockquote::before {
  content: "\201C"; /* Unicode for the opening quotation mark */
  position: absolute;
  top: -0.3rem;
  left: 0.65rem;
  font-size: 4.5rem;
  color: #6366f1;
  font-family: Georgia, serif;
  line-height: 1;
  opacity: 0.75;
}

4. A gradient glow overlay behind a card

Live Demo: Radial Glow via ::before

Design System Tokens

Color variables, spacing scales, and type ramps all defined in one place. The glow behind this card is a ::before pseudo-element.

The soft glow in the background is entirely CSS ::before. No extra HTML.

.glow-card {
  position: relative;
  overflow: hidden;
}

.glow-card::before {
  content: "";
  position: absolute;
  top: -60%;
  left: -30%;
  width: 160%;
  height: 160%;
  background: radial-gradient(
    ellipse at center,
    rgba(99, 102, 241, 0.2) 0%,
    transparent 65%
  );
  pointer-events: none; /* Doesn't block clicks */
}
⚠️
One important limitation: ::before and ::after do not work on self-closing HTML elements like <img>, <input>, <br>, or <hr>. These elements can’t have children, and since pseudo-elements are essentially CSS children, the browser ignores them. Use a wrapper element in those cases.

04CSS ::first-letter: That Drop Cap You See in Print

Open a well-designed magazine or a classic novel chapter and you’ll often spot the first letter styled much larger, sometimes in a different font or color. That’s called a “drop cap,” and ::first-letter is the CSS pseudo-element that makes it happen.

Live Demo: Drop Cap with ::first-letter

Designing a good interface isn’t just about picking the right colors or fonts. It’s about making decisions that serve the person on the other side of the screen. Every detail you add either helps or gets in the way. The best designs are the ones that don’t draw attention to themselves.

p::first-letter {
  float: left;
  font-size: 3.6rem;
  font-weight: 700;
  line-height: 0.82;
  margin-right: 0.12em;
  margin-top: 0.06em;
  color: #6366f1;
  font-family: Georgia, serif;
}

A few things worth keeping in mind:

  • ::first-letter only works on block-level elements like <p>, <div>, <h1>, and similar. It won’t work on inline elements.
  • If the paragraph starts with a punctuation character (like a quote mark "), some browsers will include that as part of the first-letter selection.
  • You can use float, font-size, color, font-family, line-height, padding, margin, and background on it.

It’s a great way to give blog posts and articles an editorial feel without any additional markup.


05CSS ::first-line: Different Styling for Your Opening Line

::first-line targets the first rendered line of a block of text, as it actually appears on screen. What makes this interesting is that the “first line” is dynamic: it depends on the width of the container and the viewport. As the page resizes, what counts as the first line changes. CSS handles that automatically.

Live Demo: First Line Styled Differently

The constraint of having only one line to make an impression is actually a useful design challenge. It forces you to lead with what matters most. Everything else can follow, but the first line sets the tone for everything that comes after it.

p::first-line {
  color: #a5b4fc;
  font-weight: 700;
  letter-spacing: 0.025em;
}

::first-line doesn’t support every CSS property. Stick to text-related ones and you’ll be fine. The properties it supports include:

  • color
  • font-* properties (font-size, font-weight, font-family, etc.)
  • text-decoration and text-transform
  • letter-spacing and word-spacing
  • line-height
  • background

Properties like margin, padding, border, and width won’t have any effect here. Keep it to text styling and you’re good.


06Custom Placeholder CSS Styling with ::placeholder

Every form input has a placeholder attribute, that hint text you see before someone starts typing. Browsers style it however they feel like by default, usually a muted gray that may not fit your design at all.

The ::placeholder pseudo-element gives you control over it.

Live Demo: Default vs Styled Placeholder


/* Default: browser handles it */
input::placeholder {
  color: #4b5563;
}

/* Styled: take full control */
input::placeholder {
  color: #818cf8;
  font-style: italic;
  font-weight: 500;
  opacity: 1; /* Crucial: overrides browser's default opacity dimming */
}
The opacity: 1 line is easy to miss but important. Firefox reduces placeholder opacity to around 0.54 by default. Without opacity: 1, your custom color will appear dimmer than you expect. Set it explicitly and your color shows up exactly as defined.

You can adjust color, font-style, font-size, letter-spacing, and a handful of other text properties. Layout properties like width or padding won’t work here since the placeholder text sits inside the input’s own space.


07Changing Your Selection Highlight Color with ::selection

When a user drags their cursor across text, the browser applies a default highlight color, typically blue. With ::selection, you can replace that with any color you want. It’s a small detail, but it makes a real impression when it matches your brand.

Live Demo: Custom Selection Color
Try selecting some of this text with your cursor and notice the highlight color change. It’s pink instead of the usual blue, and it’s applied entirely through ::selection in CSS.

☝️ Click and drag to select the text above

/* Apply to all selected text on the page */
::selection {
  background-color: #ec4899;
  color: #ffffff;
}

/* Or target just a specific element */
.article-body::selection {
  background-color: #6366f1;
  color: #fff;
}
⚠️
Only color and background-color work reliably inside ::selection. Properties like text-shadow, border, and font-weight are ignored by most browsers. Keep it to just those two.

Also worth noting: ::selection doesn’t need a content property because it’s not generating any new content. It’s restyling something that already exists.


08CSS ::marker: Clean List Bullet Styling, Finally

Before ::marker was well-supported, customizing list bullets meant removing the default bullet entirely (list-style: none), making each <li> position-relative, and adding a ::before with a custom character. It worked, but it was a workaround.

Now, with ::marker, you can style the actual bullet directly:

Live Demo: Default vs ::marker vs ::before Custom Bullet
Browser Default
  • Typography
  • Color Palette
  • Grid Layout
Styled with ::marker
  • Typography
  • Color Palette
  • Grid Layout
::before as custom marker
  • Typography
  • Color Palette
  • Grid Layout
/* Styling bullets with ::marker */
ul li::marker {
  color: #ec4899;
  font-weight: 700;
  font-size: 1.15em;
}

/* Styling ordered list numbers */
ol li::marker {
  color: #6366f1;
  font-weight: 700;
}

/* Even swap the bullet character entirely */
ul li::marker {
  content: "✓  ";
  color: #2ecc71;
}

For more creative custom markers (like arrows, icons, or complex shapes), the ::before approach is still widely used:

ul {
  list-style: none;
  padding-left: 1.5rem;
}

ul li {
  position: relative;
}

ul li::before {
  content: "→";
  position: absolute;
  left: -1.3rem;
  color: #6366f1;
  font-weight: 700;
}

::marker supports color, font-size, font-weight, content, and a few others. It’s not the most flexible selector yet, but for straightforward bullet color and size changes, it’s the cleanest option available.


09The Mistakes That Catch Beginners Off Guard

Forgetting the content property on ::before or ::after

If you add a ::before or ::after and nothing shows up in the browser, this is always the first thing to check. Even content: "" is required. Without it, the pseudo-element simply doesn’t render.

Using position: absolute without position: relative on the parent

When you want a pseudo-element positioned inside a specific element, that element needs position: relative. Without it, the pseudo-element will anchor itself to whatever the nearest positioned ancestor is, which is often the viewport itself.

Trying ::before or ::after on self-closing elements

Self-closing elements like <img>, <input>, and <hr> can’t have children. Since pseudo-elements are treated as CSS-generated children, they simply don’t work on these elements. Wrap them in a <div> or <span> if you need that effect.

Expecting all CSS properties to work in ::first-line and ::selection

::first-line only supports a limited set of text-related properties. ::selection reliably supports only color and background-color. Other properties are silently ignored by the browser with no error message, which can make this confusing to debug.

Trying to select pseudo-elements with JavaScript

Since ::before and ::after aren’t real DOM elements, document.querySelector("::before") will return nothing. To change their styles dynamically, modify CSS custom properties or class names on the parent element instead. Don’t worry if JavaScript sounds unfamiliar right now, we’ll cover that separately later in the series.


10Quick Reference: All Seven Pseudo-Elements at a Glance

Pseudo-Element What It Targets Needs content? Works on
::before Virtual element before the element’s content Yes Block & inline elements (not self-closing)
::after Virtual element after the element’s content Yes Block & inline elements (not self-closing)
::first-letter The very first letter (or character) of text No Block-level elements only
::first-line The first rendered line of a text block No Block-level elements only
::placeholder The placeholder text inside form inputs No <input> and <textarea>
::selection Text that the user has highlighted No Most elements (limited properties)
::marker The bullet or number of a list item No <li> and display: list-item elements
📌
There are a handful of other pseudo-elements in the CSS spec, like ::backdrop (used for fullscreen overlays), ::cue (for WebVTT captions), and ::part() (for styling inside web components). These are more advanced and niche. The seven covered here are the ones you’ll encounter and use in everyday projects.

11A Quick Full Example Putting Several Together

Let’s see what it looks like when you combine a few of these in one small component:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Pseudo-Elements Demo</title>
  <style>
    body {
      background: #141d34;
      color: #cbd5e1;
      font-family: system-ui, sans-serif;
      padding: 2rem;
    }

    /* Drop cap on the first paragraph */
    .post-body p:first-of-type::first-letter {
      float: left;
      font-size: 3.5rem;
      font-weight: 700;
      line-height: 0.82;
      margin-right: 0.1em;
      color: #6366f1;
    }

    /* Pink highlight when selecting text */
    .post-body::selection {
      background: #ec4899;
      color: #fff;
    }

    /* Tag badge using ::before */
    .tag {
      position: relative;
      padding-left: 1.1rem;
      font-size: 0.8rem;
      color: #a5b4fc;
    }

    .tag::before {
      content: "#";
      position: absolute;
      left: 0;
      color: #6366f1;
      font-weight: 700;
    }

    /* Styled list markers */
    ul li::marker {
      color: #ec4899;
    }

    /* Styled placeholder */
    input::placeholder {
      color: #818cf8;
      font-style: italic;
      opacity: 1;
    }
  </style>
</head>
<body>
  <article class="post-body">
    <p>
      Pseudo-elements give you a layer of styling control that most beginners
      don't know exists. Once you start using them, you'll notice them everywhere.
    </p>

    <ul>
      <li>No extra HTML required</li>
      <li>Pure CSS decorative effects</li>
      <li>Works with the cascade and inheritance</li>
    </ul>

    <p><span class="tag">css</span> <span class="tag">frontend</span></p>
  </article>

  <input type="text" placeholder="Search articles..." />
</body>
</html>

Notice how each pseudo-element serves a specific purpose, none of them are doing the same job, and the HTML stays clean throughout. That’s the right way to think about it.


Pseudo-elements are one of those CSS features that quietly show up everywhere once you know what to look for. That heading with decorative lines on both sides? ::before and ::after. That big first letter at the start of a chapter? ::first-letter. The custom text highlight on a portfolio site? ::selection.

Go back through the examples here and try building each one from scratch. The ::before and ::after demos especially, since those are the ones you’ll reach for most often. Experiment with positioning, play with the content values, try combining two or three in a single component.

CSS Pseudo-Elements

Choose a difficulty to load your challenge

Loading challenge…

Ready to Practice?

Select a difficulty level above to load your challenge.

Easy

1
Live Preview
Great Work!