The Clean Markup Way to Decorate Headlines
You shouldn't have to wrap every headline in an extra <span> just to add a decorative stroke or underline. That's messy HTML and painful to maintain. This article reveals the simple, two-line CSS trick using pseudo-elements that lets you customize every pixel of your typography's look, keeping your markup squeaky clean and your designs highly custom. Stop polluting your document—start designing with pure CSS magic.
Where we are currently with this
As you can see below, this is our final piece, we’ve only (currently) got 2 p tags - one for each line. Refreshing my memory on this piece as I write it, it looks like it’s split across 2 lines (and two p tags) as we won’t be able to wrap it particularly well as one line. Let’s see if we can improve that and make it a bit nicer semantically.
See the Pen Pseudo elements with opacity and positioning by Dom Jay (@dominickjay217) on CodePen.
Now
So we’re going to start with our main bit of content, right? Without that, the whole things kind of pointless!
<p>leave it better than you found it</p>
leave it better than you found it
I’ll style this up with the font I’ve got in this project atm.
leave it better than you found it
Next, we’re going to add a data attribute of data-text to our text, and add the exact. same. text to it. This is absolutely vital, even the smallest change will result in the layout being broken - and, why would you want this effect, but with different text anyways?
leave it better than you found it
So currently, we don’t see any change here, right? Or at least, you shouldn’t do. You’ll see it present if you inspect the markup in your browser console, but nothing shows up on screen. Yet.
Next, we’re going to assign this data attribute content inside a pseudo-element of before on our text.
p::before {
content: attr(data-text);
}
leave it better than you found it
After assigning the data-attribute content to the before pseudo element, we can see that now the contents of the data-text attribute are shown before the contents of our p tag. We want it to sit behind our text though, and offset slightly up and to the left. Let’s adjust this by setting the before content to an absolute position, and assigning some top and left properties, ensuring also that we remember to assign our p tag a relative position to ensure the absolute-ly positioned content doesn’t float away.
p::before {
content: attr(data-text);
position: absolute;
top: -7px;
left: -7px;
}
leave it better than you found it
We can see this starting to come together now, we’ve got our repeated text positioned in the right place to where we want it. But given that it’s all the same color, it’s a bit difficult to look and distinguish one from the other, so we’ll add some different colors to the p tag and the before content. We’ve also got a bit of opacity on the before content, so let’s apply that.
p {
color: #FC4A1A;
}
p::before {
color: #FCB733;
opacity: 0.7;
}
Almost there, but the colors along with the opacity are a bit hard on my eyes - almost look like it’s out of focus. Let’s apply a stroke around the before content, and also a small text-shadow just to push it out a bit into the foreground.
-webkit-text-stroke we use here is a non-standard CSS property that creates a stroke - or outline - around the text. It’s a shorthand property that combines -webkite-text-stroke-width and -webkite-text-stroke-color. However, it is only available on Webkit-based browsers e.g. Safari and older versions of ChromeBlend Modes and Isolation
In the CodePen demo, we’re using two additional properties that affect how our text layers interact:
.demo {
isolation: isolate;
}
.demo::before {
mix-blend-mode: luminosity;
}
The isolation: isolate property creates a new stacking context for our element. This is important because:
- It prevents the pseudo-element from blending with elements outside its parent
- It ensures the blend mode only affects the relationship between the main text and its pseudo-element
- It can improve performance by limiting the scope of blend mode calculations
The mix-blend-mode: luminosity property then controls how the pseudo-element blends with the main text. In this case, it’s creating a more subtle, integrated effect between the two text layers.
Without isolation: isolate, the blend mode might interact with other elements on the page, potentially creating unexpected visual results. While you might not notice a difference in this specific demo (especially on a white/paler background), it’s a good practice to include it when working with blend modes to ensure consistent behavior across different contexts.
leave it better than you found it
As far as our original demo goes, that’s how this was put together. Of course, there’s alternative approaches to this, which we’ll cover next.
Alternative Approaches
While our pseudo-element approach works well, there are several other ways to achieve similar effects. Let’s explore some alternatives:
SVG Approach
SVG provides more control over text effects and better browser support. Here’s how we could achieve a similar effect:
<svg class="text-effect">
<text x="50%" y="50%" text-anchor="middle" class="text-front">leave it better than you found it</text>
<text x="50%" y="50%" text-anchor="middle" class="text-back">leave it better than you found it</text>
</svg>
.text-effect {
width: 100%;
height: auto;
}
.text-back {
fill: #FCB733;
opacity: 0.7;
transform: translate(-4px, -4px);
stroke: #000;
}
.text-front {
fill: #FC4A1A;
}
So we can see immediately that this just won’t work, not unless we want to force new text elements onto new lines to simulate the line breaks. I’m sure there’s a good use case for doing this as an SVG, possibly as a logo with that effect, but in terms of something a bit more dynamic - like a heading on a page - the trade off between browser support and basic expectations here just isn’t worth it.
Text Shadow Only Solution
If you want to avoid pseudo-elements entirely, you can use multiple text shadows:
.text-shadow-effect {
color: #FC4A1A;
text-shadow:
-7px -7px 0 #FCB733,
-7px -7px 2px rgba(0,0,0,0.2);
}
Leave it better than you found it
Interactive Examples
Let’s make our text effect more dynamic with some interactive elements:
Leave it better than you found it
Hover Effect
.interactive-demo {
transition: transform 0.3s ease;
}
.interactive-demo:hover::before {
transform: translate(12px, 12px);
opacity: 0.5;
}