Tapping Into ::before and ::after for Real UI Work

The ::before and ::after pseudo-elements are often treated as decorative tools for adding a bit of flair, but they can carry a surprising amount of functional weight. Because they let you inject generated content into a page without touching the HTML, you can build custom form controls, rescue failed image loads, and create layered visual effects with minimal markup.

One important constraint to keep in mind: these pseudo-elements won't work on replaced elements like an <img> or an <input type="image">. The spec defines them as non-replaced elements in other cases, so a checkbox or a text input is fair game. Also, any text you generate this way isn’t selectable or readable by assistive tech, so it’s best reserved for styling, not genuine content.

Recovering Broken Images with a Custom Placeholder

When an image fails to load, the browser’s default broken-image icon is rarely useful. You can replace that with a styled placeholder using a combination of the image’s pseudo-elements and the attr() function.

Start by positioning the <img> relatively so that any absolutely positioned pseudo-elements are anchored to it.

img {
  display: block; /* Avoid the space under the image caused by line height */
  position: relative;
  width: 100%
}

Next, use the image’s ::before pseudo-element to create the placeholder area with a light gray background and a subtle border.

img::before {
  background-color: hsl(0, 0%, 93.3%);
  border: 1px dashed hsl(0, 0%, 66.7%);
  /* ... */
}

Note that <img> is a replaced element, so you might expect this not to work. In Chrome and Firefox, however, the pseudo-element renders when the image fails to load, which is exactly what you need here. Safari, in contrast, only styles the alt text. At this point, the styling sits in the top-left corner of the broken image area.

Two card elements, both with a large image, a title, and a description. The card on the left has a red Chevrolet Camaro image. The card on the right shows alt text that says Red and white Chevrolet.

To make the placeholder fill the full image region, set the pseudo-element to display: block and give it a height that covers the available space.

img::before {
  /* ... */
  display: block;
  height: 100%;
}
Two card elements, both with a large image, a title, and a description. The card on the left has a red Chevrolet Camaro image. The card on the right shows alt text that says Red and white Chevrolet inside of a gray placeholder area. That area is highlighted with a red border.
Things are already starting to look better!

Refining the look with rounded corners requires giving the pseudo-element full width and using absolute positioning to guarantee placement. When you set content to an empty string, the pseudo-element covers the entire space, which also hides the alt text.

Two card elements, both with a large image, a title, and a description. The card on the left has a red Chevrolet Camaro image. The card on the right shows a gray placeholder area.

To bring the alt text back into view, use the ::after pseudo-element and pull the attribute value directly with the attr() function.

img::after {
  content: attr(alt);

  /* Some light styling */
  font-weight: bold;
  position: absolute;
  height: 100%;
  left: 0px;
  text-align: center;
  top: 1px;
  width: 100%;
}

This works cleanly in Chrome, but Firefox typically doesn’t display the result the same way. A quick fix is to target the alt attribute with an attribute selector — img[alt] — and apply matching styles so that browsers render a consistent placeholder.

img[alt] {
  text-align: center;
  font-weight: bold;
  color: hsl(0, 0%, 60%);
}

Decorative Blockquotes with Open and Close Quotes

Blockquotes break up long stretches of text, and pseudo-elements make it easy to add large quotation marks around them without adding extra markup. The content property supports the open-quote and close-quote values, which automatically pull the correct quotation marks based on context.

blockquote::before {
  content: open-quote;
  /* Place it at the top-left */
  top: 0;
  left: 0;
}

blockquote::after {
  content: close-quote;
  /* Place it at thee bottom-right */
  bottom: 0;
  right: 0;
}

With the content handled, the rest is styling. The blockquote needs position: relative to anchor the pseudo-elements, which are then positioned absolutely to sit at the start and end of the quote.

blockquote::before,
blockquote::after {
  background-color: #cccccc;
  display: block;
  width: 60px;
  height: 60px;
  line-height: 1.618;
  font-size: 3em;
  border-radius: 100%;
  text-align: center;
  position: absolute;
}

Replacing List Markers with Icons

Default list styling is dictated by the browser’s user agent stylesheet, but with ::before you can override those markers with your own content, including emojis.

.name-list li::before {
  content: "😊";
  margin-right: 15px;
  font-size: 20px;
}

For simple icon swaps, the ::marker pseudo-element is actually a more targeted solution, as it was designed specifically for list bullets. But using ::before remains a valid approach when you need more control over the marker’s size, position, or content beyond what ::marker can handle.

Turning a Checkbox into a Toggle Switch

Styling a standard checkbox as a toggle switch is a classic trick, and it works because checkboxes are treated as non-replaced elements in the rendering spec. With absolutely positioned pseudo-elements, you can construct the entire switch interface.

Begin with the form’s base styles, then hide the checkbox’s default appearance. Setting it to position: absolute, appearance: none, and sizing it to 100% makes it invisible but still clickable.

input {
  -webkit-appearance: none; /* Safari */
  cursor: pointer; /* Show it's an interactive element */
  height: 100%;
  position: absolute;
  width: 100%;
}

The checkbox’s ::before pseudo-element creates the track, changing the element’s visible shape. The ::after pseudo-element forms the toggle knob itself, using a linear-gradient background and a transform to center it.

input::after {
  background: linear-gradient(to right, orange, #8e2de2);
  border-radius: 50px;
  content: "";
  height: 25px;
  opacity: 0.6;
  position: absolute;
  top: 50%;
  transform: translate(7px, -50%);
  transition: all .4s;
  width: 25px;
}

Clicking the styled knob alone won’t update the checkbox’s state. The fix is to chain the :checked pseudo-class to the ::after element so that the knob moves when the state changes.

input:checked::after {
  opacity: 1;
  transform: translate(170%, -50%);
}

Gradient Borders and Overlays on Images

Creating gradient borders around an image is possible with a parent element and a pseudo-element. The core idea is to layer a gradient behind the image, then use a negative z-index to keep the gradient beneath the image in the stacking order.

.gradient-border::before {
  /* Renders the styles */
  content: "";
  /* Fills the entire space */
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  /* Creates the gradient */
  background-image: linear-gradient(#1a1a1a, #1560bd);
  /* Stacks the gradient behind the image */
  z-index: -1;
}

figure {
  /* Removes the default margin */
  margin: 0;
  /* Squeezes the image, revealing the gradient behind it */
  padding: 10px;
}

Applying a gradient overlay on top of an image uses the same pseudo-element approach, but with key differences. No z-index is needed since the gradient should sit above the image. The background also incorporates transparency via rgba() or similar so the image shows through, which is useful for adding texture or providing contrast for overlaid text.

figure::before {
  background-image: linear-gradient(to top right, #1a1a1a, transparent);
  content: "";
  height: 100%;
  position: absolute;
  width: 100%;
}

Styled Radio Buttons

Customizing radio buttons follows the same logic as the toggle switch. After setting base styles and removing the default appearance with appearance: none, the ::before pseudo-element draws the custom radio circle.

.form-input {
  -webkit-appearance: none; /* Safari */
  appearance: none;
}

The ::after pseudo-element handles the marker inside the circle when the input is checked. In this case, it changes the color of the ring from the default blue to white, but this often means the styled state ends up covering the form label if the label is directly next to the input.

.form-input::after {
  /* Renders the styles */
  content: '';
  /* Shows that it's interactive */
  cursor: pointer;
  /* A little border styling */
  border-radius: 50px;
  border: 4px solid #21209c;
  /* Positions the ring */
  position: absolute;
  left: 10%;
  top: 50%;
  transform: translate(0, -50%);
  /* Sets the dimensions */
  height: 15px;
  width: 15px;
}

/* When the input is in a checked state... */
.form-input:checked::after {
  /* Change ::after's border to white */
  border: 4px solid #ffffff;
}

Since pseudo-elements take part in the HTML document flow despite not existing in the markup, absolutely positioning them can cause them to sit on top of neighboring elements like labels. Adding a z-index to the label — or positioning it — ensures the label remains visible and clickable when a radio button is selected.

.form-label {
  color: #21209c;
  font-size: 1.1rem;
  margin-left: 10px;
  z-index: 1; /* Makes sure the label is stacked on top */
  /* position: absolute; This is an alternative option */
}