On pseudo elements

Encapsulating visual patterns into a single class.

Here is a menu. Hover the items.

The Goal
Loading...

Three things are happening at once. The hover background is inset from the container edges with rounded corners. Dividers sit between items, aligned to the flat edge of that rounding. And the hit area is edge-to-edge, no dead pixels anywhere. You can slam your cursor to the side of the menu and the hover still fires.

Look at the markup. Five buttons in a div. No wrappers, no spacer elements, no state. The entire visual pattern is encoded in one class.

Getting here the obvious way is not pretty.

The obvious way

Most implementations break this into parts. The hover background needs to be inset, so the button gets horizontal margin. Dividers need to appear between items, so <hr> elements go into the markup. Dividers need to hide when adjacent items are hovered, so you track hover state in React and conditionally apply classes.

Extra Markup
Loading...

Read that JSX. Five <div> wrappers. Four <hr> elements. A useState hook. Ten mouse event handlers. A ternary for conditional classes. All to style a list of five buttons.

And it still has a problem. Hover near the left or right edge of the menu. Nothing happens. The buttons are narrower than the container because of the margin that creates the inset. Those 4 pixels of dead space on each side are the telltale sign of this approach. The visual inset and the interaction target are fighting each other.

You could fix the dead pixels by adding yet another wrapper, an inner span for the hover background while keeping the button full-width. Now you have even more elements in the DOM, and the pattern is spread across three layers of markup: the button, the background span, and the divider element.

This is the wrong direction.

Start with the markup you want

Forget the styling for a moment. What should this markup actually look like? It's a list of actions. So it should be buttons in a container. Nothing else.

Clean Markup
Loading...

That is the markup. It never changes from here. Everything that follows is pure CSS, layered onto .option without touching the HTML.

The hover background stretches edge-to-edge. It's flat and blunt. That's fine. This is the foundation.

::before for the hover background

Every element gets two free pseudo elements. The first one becomes the hover indicator. It's absolutely positioned inside the button with its own inset and border-radius, sitting behind the text content.

Inset Hover
Loading...

The button is still full-width. The cursor still works everywhere. But the visual feedback is inset and rounded, floating inside the button's bounds.

Two CSS properties make this work. isolation: isolate on the button creates a local stacking context. z-index: -1 on the pseudo element pushes it behind the text. Without isolation, that negative z-index would send the pseudo behind the entire menu container. With it, the pseudo stays layered inside the button, below the text but above the button's background.

The markup hasn't changed.

::after for the dividers

The second pseudo element draws the divider line. Its horizontal inset is var(--inset) + var(--radius). That's the exact point where the curved edge of the hover background meets the flat edge. The divider starts where the rounding ends, so it never bleeds into the curve.

Complete Pattern
Loading...

:last-child::after hides the final divider. :hover::after hides the divider below the hovered item. :has(+ .option:hover)::after hides the divider above. No JavaScript. No state. No toggled classes. The selectors read the DOM and the hover state is resolved entirely in CSS.

The markup still hasn't changed.

One class

Count what .option encapsulates:

  • An inset hover background with its own border-radius
  • Dividers between items with inset aligned to that radius
  • Hover-aware divider hiding, both above and below
  • Edge-to-edge hit area with zero dead pixels
  • Coordinated geometry through two custom properties

The DOM is five buttons in a div. There are no wrappers, no spacers, no decorative elements, no JavaScript. The pattern is self-enclosed. Add more items, change the labels, drop it into a different container. The class carries the full behavior with it.

That is what pseudo elements are for. Not decorative flourishes. Structural patterns, sealed into a single class, that keep the markup honest.